standardize header, save all button for targets, fix update site on resource

This commit is contained in:
Milo Schwartz 2024-11-13 20:08:05 -05:00
parent cf3cf4d827
commit 44b932937f
No known key found for this signature in database
33 changed files with 577 additions and 397 deletions

View file

@ -150,6 +150,7 @@ authenticated.get(
authenticated.post( authenticated.post(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateResource), verifyUserHasAction(ActionsEnum.updateResource),
resource.updateResource resource.updateResource
); );
@ -370,7 +371,7 @@ authRouter.use(
authRouter.put("/signup", auth.signup); authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login); authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout); authRouter.post("/logout", auth.logout);
authRouter.post('/newt/get-token', getToken); authRouter.post("/newt/get-token", getToken);
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
authRouter.post( authRouter.post(

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources } from "@server/db/schema"; import { Resource, resources } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -12,12 +12,7 @@ const getResourceSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
}); });
export type GetResourceResponse = { export type GetResourceResponse = Resource;
resourceId: number;
siteId: number;
orgId: string;
name: string;
};
export async function getResource( export async function getResource(
req: Request, req: Request,
@ -53,12 +48,7 @@ export async function getResource(
} }
return response(res, { return response(res, {
data: { data: resource[0],
resourceId: resource[0].resourceId,
siteId: resource[0].siteId,
orgId: resource[0].orgId,
name: resource[0].name,
},
success: true, success: true,
error: false, error: false,
message: "Resource retrieved successfully", message: "Resource retrieved successfully",

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources } from "@server/db/schema"; import { resources, sites } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -17,6 +17,8 @@ const updateResourceBodySchema = z
.object({ .object({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
subdomain: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(),
ssl: z.boolean().optional(),
siteId: z.number(),
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",

View file

@ -29,6 +29,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/utils";
type CreateRoleFormProps = { type CreateRoleFormProps = {
open: boolean; open: boolean;
@ -74,9 +75,10 @@ export default function CreateRoleForm({
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to create role", title: "Failed to create role",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while creating the role.", "An error occurred while creating the role."
),
}); });
}); });

View file

@ -36,6 +36,7 @@ import {
SelectValue, SelectValue,
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { RoleRow } from "./RolesTable"; import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/utils";
type CreateRoleFormProps = { type CreateRoleFormProps = {
open: boolean; open: boolean;
@ -71,9 +72,10 @@ export default function DeleteRoleForm({
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to fetch roles", title: "Failed to fetch roles",
description: description: formatAxiosError(
e.message || e,
"An error occurred while fetching the roles", "An error occurred while fetching the roles"
),
}); });
}); });
@ -109,9 +111,10 @@ export default function DeleteRoleForm({
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to remove role", title: "Failed to remove role",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while removing the role.", "An error occurred while removing the role."
),
}); });
}); });

View file

@ -28,6 +28,8 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/utils";
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }), email: z.string().email({ message: "Please enter a valid email" }),
@ -60,9 +62,10 @@ export default function AccessControlsPage() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to fetch roles", title: "Failed to fetch roles",
description: description: formatAxiosError(
e.message || e,
"An error occurred while fetching the roles", "An error occurred while fetching the roles"
),
}); });
}); });
@ -87,9 +90,10 @@ export default function AccessControlsPage() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to add user to role", title: "Failed to add user to role",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while adding user to the role.", "An error occurred while adding user to the role."
),
}); });
}); });
@ -106,14 +110,11 @@ export default function AccessControlsPage() {
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="Access Controls"
Access Controls description="Manage what this user can access and do in the organization"
</h2> size="1xl"
<p className="text-muted-foreground"> />
Manage what this user can access and do in the organization
</p>
</div>
<Form {...form}> <Form {...form}>
<form <form

View file

@ -53,7 +53,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
className="text-muted-foreground hover:underline" className="text-muted-foreground hover:underline"
> >
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">
<ArrowLeft /> <span>All Users</span> <ArrowLeft className="w-4 h-4" />{" "}
<span>All Users</span>
</div> </div>
</Link> </Link>
</div> </div>

View file

@ -38,6 +38,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/utils";
type InviteUserFormProps = { type InviteUserFormProps = {
open: boolean; open: boolean;
@ -94,9 +95,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to fetch roles", title: "Failed to fetch roles",
description: description: formatAxiosError(
e.message || e,
"An error occurred while fetching the roles", "An error occurred while fetching the roles"
),
}); });
}); });
@ -128,9 +130,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to invite user", title: "Failed to invite user",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while inviting the user.", "An error occurred while inviting the user"
),
}); });
}); });

View file

@ -19,6 +19,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
export type UserRow = { export type UserRow = {
id: string; id: string;
@ -162,9 +163,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to remove user", title: "Failed to remove user",
description: description: formatAxiosError(
e.message ?? e,
"An error occurred while removing the user.", "An error occurred while removing the user."
),
}); });
}); });

View file

@ -21,6 +21,7 @@ import {
SelectValue, SelectValue,
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -51,6 +52,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
console.error("Error logging out", e); console.error("Error logging out", e);
toast({ toast({
title: "Error logging out", title: "Error logging out",
description: formatAxiosError(e, "Error logging out"),
}); });
}) })
.then(() => { .then(() => {

View file

@ -1,5 +1,6 @@
import { internal } from "@app/api"; import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
@ -67,14 +68,12 @@ export default async function GeneralSettingsPage({
<> <>
<OrgProvider org={org}> <OrgProvider org={org}>
<OrgUserProvider orgUser={orgUser}> <OrgUserProvider orgUser={orgUser}>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="General"
General description="Configure your organization's general settings"
</h2> size="1xl"
<p className="text-muted-foreground"> />
Configure your organization's general settings
</p>
</div>
<SidebarSettings sidebarNavItems={sidebarNavItems}> <SidebarSettings sidebarNavItems={sidebarNavItems}>
{children} {children}
</SidebarSettings> </SidebarSettings>

View file

@ -1,9 +1,7 @@
"use client"; "use client";
import { useEffect, useState, use } from "react"; import { useEffect, useState, use } from "react";
import { Trash2, Server, Globe, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -14,13 +12,12 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import api from "@app/api"; import api from "@app/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { ListTargetsResponse } from "@server/routers/target/listTargets";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { set, z } from "zod";
import { import {
Form, Form,
FormControl, FormControl,
@ -50,16 +47,14 @@ import {
} from "@app/components/ui/table"; } from "@app/components/ui/table";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Target } from "@server/db/schema";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { Dot } from "lucide-react";
import { formatAxiosError } from "@app/lib/utils";
import { escape } from "querystring";
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: z ip: z.string().ip(),
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
"Invalid IP address format"
),
method: z.string(), method: z.string(),
port: z port: z
.string() .string()
@ -72,6 +67,11 @@ const addTargetSchema = z.object({
type AddTargetFormValues = z.infer<typeof addTargetSchema>; type AddTargetFormValues = z.infer<typeof addTargetSchema>;
type LocalTarget = ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
};
export default function ReverseProxyTargets(props: { export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>; params: Promise<{ resourceId: number }>;
}) { }) {
@ -80,7 +80,9 @@ export default function ReverseProxyTargets(props: {
const { toast } = useToast(); const { toast } = useToast();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const [targets, setTargets] = useState<ListTargetsResponse["targets"]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sslEnabled, setSslEnabled] = useState(resource.ssl);
const addTargetForm = useForm({ const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema), resolver: zodResolver(addTargetSchema),
@ -103,9 +105,10 @@ export default function ReverseProxyTargets(props: {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to fetch targets", title: "Failed to fetch targets",
description: description: formatAxiosError(
err.message || err,
"An error occurred while fetching targets", "An error occurred while fetching targets"
),
}); });
}); });
@ -117,68 +120,145 @@ export default function ReverseProxyTargets(props: {
}, []); }, []);
async function addTarget(data: AddTargetFormValues) { async function addTarget(data: AddTargetFormValues) {
const newTarget: LocalTarget = {
...data,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
};
setTargets([...targets, newTarget]);
addTargetForm.reset();
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId),
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
setTargets(
targets.map((target) =>
target.targetId === targetId
? { ...target, ...data, updated: true }
: target
)
);
}
async function saveAll() {
const res = await api const res = await api
.post(`/resource/${params.resourceId}`, { ssl: sslEnabled })
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update resource",
description: formatAxiosError(
err,
"Failed to update resource"
),
});
})
.then(() => {
updateResource({ ssl: sslEnabled });
});
for (const target of targets) {
const data = {
ip: target.ip,
port: target.port,
method: target.method,
protocol: target.protocol,
enabled: target.enabled,
};
if (target.new) {
await api
.put<AxiosResponse<CreateTargetResponse>>( .put<AxiosResponse<CreateTargetResponse>>(
`/resource/${params.resourceId}/target`, `/resource/${params.resourceId}/target`,
{ data
...data,
resourceId: undefined,
}
) )
.then((res) => {
setTargets(
targets.map((t) => {
if (
t.new &&
t.targetId === res.data.data.targetId
) {
return {
...t,
new: false,
};
}
return t;
})
);
})
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to add target", title: "Failed to add target",
description: description: formatAxiosError(
err.message || "An error occurred while adding target", err,
"Failed to add target"
),
}); });
}); });
} else if (target.updated) {
if (res && res.status === 201) { const res = await api
setTargets([...targets, res.data.data]); .post(`/target/${target.targetId}`, data)
addTargetForm.reset();
}
}
const removeTarget = (targetId: number) => {
api.delete(`/target/${targetId}`)
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
toast({
variant: "destructive",
title: "Failed to update target",
description: formatAxiosError(
err,
"Failed to update target"
),
});
});
}
}
for (const targetId of targetsToRemove) {
await api
.delete(`/target/${targetId}`)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to remove target",
description: formatAxiosError(
err,
"Failed to remove target"
),
});
}) })
.then((res) => { .then((res) => {
setTargets( setTargets(
targets.filter((target) => target.targetId !== targetId) targets.filter((target) => target.targetId !== targetId)
); );
}); });
};
async function updateTarget(targetId: number, data: Partial<Target>) {
setTargets(
targets.map((target) =>
target.targetId === targetId ? { ...target, ...data } : target
)
);
const res = await api.post(`/target/${targetId}`, data).catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update target",
description:
err.message || "An error occurred while updating target",
});
});
if (res && res.status === 200) {
toast({
title: "Target updated",
description: "The target has been updated successfully",
});
}
} }
const columns: ColumnDef<ListTargetsResponse["targets"][0]>[] = [ toast({
title: "Resource updated",
description: "Resource and targets updated successfully",
});
setTargetsToRemove([]);
}
const columns: ColumnDef<LocalTarget>[] = [
{ {
accessorKey: "ip", accessorKey: "ip",
header: "IP Address", header: "IP Address",
@ -259,12 +339,17 @@ export default function ReverseProxyTargets(props: {
{ {
id: "actions", id: "actions",
cell: ({ row }) => ( cell: ({ row }) => (
<>
<div className="flex items-center justify-end space-x-2">
{row.original.new && <Dot />}
<Button <Button
variant="outline" variant="outline"
onClick={() => removeTarget(row.original.targetId)} onClick={() => removeTarget(row.original.targetId)}
> >
Delete Delete
</Button> </Button>
</div>
</>
), ),
}, },
]; ];
@ -286,10 +371,15 @@ export default function ReverseProxyTargets(props: {
<SettingsSectionTitle <SettingsSectionTitle
title="SSL" title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates" description="Setup SSL to secure your connections with LetsEncrypt certificates"
size="1xl"
/> />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch id="ssl-toggle" /> <Switch
id="ssl-toggle"
defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)}
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label> <Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div> </div>
</div> </div>
@ -298,6 +388,7 @@ export default function ReverseProxyTargets(props: {
<SettingsSectionTitle <SettingsSectionTitle
title="Targets" title="Targets"
description="Setup targets to route traffic to your services" description="Setup targets to route traffic to your services"
size="1xl"
/> />
<Form {...addTargetForm}> <Form {...addTargetForm}>
@ -355,8 +446,8 @@ export default function ReverseProxyTargets(props: {
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Choose the method for the target Choose the method for how the
connection target is accessed
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -422,7 +513,9 @@ export default function ReverseProxyTargets(props: {
)} )}
/> />
</div> </div>
<Button type="submit">Add Target</Button> <Button type="submit" variant="gray">
Add Target
</Button>
</form> </form>
</Form> </Form>
</div> </div>
@ -466,7 +559,7 @@ export default function ReverseProxyTargets(props: {
colSpan={columns.length} colSpan={columns.length}
className="h-24 text-center" className="h-24 text-center"
> >
No results. No targets. Add a target using the form.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -474,6 +567,10 @@ export default function ReverseProxyTargets(props: {
</Table> </Table>
</div> </div>
</div> </div>
<div className="mt-8">
<Button onClick={saveAll}>Save Changes</Button>
</div>
</div> </div>
); );
} }

View file

@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { cn } from "@/lib/utils"; import { cn, formatAxiosError } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Form, Form,
@ -38,6 +38,7 @@ import { useParams } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { GetResourceResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string(), name: z.string(),
@ -48,22 +49,24 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralForm() { export default function GeneralForm() {
const params = useParams(); const params = useParams();
const orgId = params.orgId;
const { resource, updateResource } = useResourceContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const { toast } = useToast(); const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const orgId = params.orgId;
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const form = useForm<GeneralFormValues>({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: resource?.name, name: resource.name,
siteId: resource?.siteId, siteId: resource.siteId!,
}, },
mode: "onChange", mode: "onChange",
}); });
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") {
const fetchSites = async () => { const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>( const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/` `/org/${orgId}/sites/`
@ -71,13 +74,13 @@ export default function GeneralForm() {
setSites(res.data.data.sites); setSites(res.data.data.sites);
}; };
fetchSites(); fetchSites();
}
}, []); }, []);
async function onSubmit(data: GeneralFormValues) { async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
updateResource({ name: data.name, siteId: data.siteId }); updateResource({ name: data.name, siteId: data.siteId });
await api
.post<AxiosResponse<GetResourceResponse>>( api.post<AxiosResponse<GetResourceResponse>>(
`resource/${resource?.resourceId}`, `resource/${resource?.resourceId}`,
{ {
name: data.name, name: data.name,
@ -88,24 +91,29 @@ export default function GeneralForm() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to update resource", title: "Failed to update resource",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while updating the resource", "An error occurred while updating the resource"
),
}); });
})
.then(() => {
toast({
title: "Resource updated",
description: "The resource has been updated successfully",
}); });
})
.finally(() => setSaveLoading(false));
} }
return ( return (
<> <>
<div className="lg:max-w-2xl"> <div className="lg:max-w-2xl">
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="General Settings"
General Settings description="Configure the general settings for this resource"
</h2> size="1xl"
<p className="text-muted-foreground"> />
Configure the general settings for this resource
</p>
</div>
<Form {...form}> <Form {...form}>
<form <form
@ -160,10 +168,10 @@ export default function GeneralForm() {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[350px] p-0"> <PopoverContent className="w-[350px] p-0">
<Command> <Command>
<CommandInput placeholder="Search site..." /> <CommandInput placeholder="Search sites" />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
No site found. No sites found.
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{sites.map((site) => ( {sites.map((site) => (
@ -206,7 +214,13 @@ export default function GeneralForm() {
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit">Update Resource</Button> <Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
>
Save Changes
</Button>
</form> </form>
</Form> </Form>
</div> </div>

View file

@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
interface ResourceLayoutProps { interface ResourceLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -53,19 +54,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
className="text-muted-foreground hover:underline" className="text-muted-foreground hover:underline"
> >
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">
<ArrowLeft /> <span>All Resources</span> <ArrowLeft className="w-4 h-4" />{" "}
<span>All Resources</span>
</div> </div>
</Link> </Link>
</div> </div>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title={`${resource?.name} Settings`}
{resource?.name + " Settings"} description="Configure the settings on your resource"
</h2> />
<p className="text-muted-foreground">
Configure the settings on your resource
</p>
</div>
<ResourceProvider resource={resource}> <ResourceProvider resource={resource}>
<SidebarSettings <SidebarSettings

View file

@ -29,7 +29,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { cn } from "@app/lib/utils"; import { cn, formatAxiosError } from "@app/lib/utils";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { import {
Popover, Popover,
@ -123,7 +123,12 @@ export default function CreateResourceForm({
) )
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive",
title: "Error creating resource", title: "Error creating resource",
description: formatAxiosError(
e,
"An error occurred when creating the resource"
),
}); });
}); });

View file

@ -17,6 +17,8 @@ import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react"; import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod"; import { set } from "zod";
import { formatAxiosError } from "@app/lib/utils";
import { useToast } from "@app/hooks/useToast";
export type ResourceRow = { export type ResourceRow = {
id: number; id: number;
@ -34,6 +36,8 @@ type ResourcesTableProps = {
export default function SitesTable({ resources, orgId }: ResourcesTableProps) { export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] = const [selectedResource, setSelectedResource] =
@ -43,6 +47,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
api.delete(`/resource/${resourceId}`) api.delete(`/resource/${resourceId}`)
.catch((e) => { .catch((e) => {
console.error("Error deleting resource", e); console.error("Error deleting resource", e);
toast({
variant: "destructive",
title: "Error deleting resource",
description: formatAxiosError(e, "Error deleting resource"),
});
}) })
.then(() => { .then(() => {
router.refresh(); router.refresh();

View file

@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
import ResourcesTable, { ResourceRow } from "./components/ResourcesTable"; import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource"; import { ListResourcesResponse } from "@server/routers/resource";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourcesPageProps = { type ResourcesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -33,14 +34,10 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="Manage Resources"
Manage Resources description="Create secure proxies to your private applications"
</h2> />
<p className="text-muted-foreground">
Create secure proxies to your private applications.
</p>
</div>
<ResourcesTable resources={resourceRows} orgId={params.orgId} /> <ResourcesTable resources={resourceRows} orgId={params.orgId} />
</> </>

View file

@ -18,6 +18,8 @@ import { useForm } from "react-hook-form";
import api from "@app/api"; import api from "@app/api";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/utils";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string(), name: z.string(),
@ -48,9 +50,10 @@ export default function GeneralPage() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to update site", title: "Failed to update site",
description: description: formatAxiosError(
e.message || e,
"An error occurred while updating the site.", "An error occurred while updating the site."
),
}); });
}); });
@ -61,14 +64,11 @@ export default function GeneralPage() {
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="General Settings"
General Settings description="Configure the general settings for this site"
</h2> size="1xl"
<p className="text-muted-foreground"> />
Configure the general settings for this site
</p>
</div>
<Form {...form}> <Form {...form}>
<form <form

View file

@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
interface SettingsLayoutProps { interface SettingsLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -44,19 +45,15 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
className="text-muted-foreground hover:underline" className="text-muted-foreground hover:underline"
> >
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">
<ArrowLeft /> <span>All Sites</span> <ArrowLeft className="w-4 h-4" /> <span>All Sites</span>
</div> </div>
</Link> </Link>
</div> </div>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title={`${site?.name} Settings`}
{site?.name + " Settings"} description="Configure the settings on your site"
</h2> />
<p className="text-muted-foreground">
Configure the settings on your site
</p>
</div>
<SiteProvider site={site}> <SiteProvider site={site}>
<SidebarSettings <SidebarSettings

View file

@ -40,6 +40,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { formatAxiosError } from "@app/lib/utils";
const method = [ const method = [
{ label: "Wireguard", value: "wg" }, { label: "Wireguard", value: "wg" },
@ -108,7 +109,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
api.get(`/org/${orgId}/pick-site-defaults`) api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive",
title: "Error picking site defaults", title: "Error picking site defaults",
description: formatAxiosError(e),
}); });
}) })
.then((res) => { .then((res) => {
@ -130,7 +133,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive",
title: "Error creating site", title: "Error creating site",
description: formatAxiosError(e),
}); });
}); });

View file

@ -17,6 +17,8 @@ import { AxiosResponse } from "axios";
import { useState } from "react"; import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm"; import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@ -35,6 +37,8 @@ type SitesTableProps = {
export default function SitesTable({ sites, orgId }: SitesTableProps) { export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
@ -48,6 +52,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
api.delete(`/site/${siteId}`) api.delete(`/site/${siteId}`)
.catch((e) => { .catch((e) => {
console.error("Error deleting site", e); console.error("Error deleting site", e);
toast({
variant: "destructive",
title: "Error deleting site",
description: formatAxiosError(e, "Error deleting site"),
});
}) })
.then(() => { .then(() => {
router.refresh(); router.refresh();

View file

@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./components/SitesTable"; import SitesTable, { SiteRow } from "./components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type SitesPageProps = { type SitesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) {
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="Manage Sites"
Manage Sites description="Manage your existing sites here or create a new one."
</h2> />
<p className="text-muted-foreground">
Manage your existing sites here or create a new one.
</p>
</div>
<SitesTable sites={siteRows} orgId={params.orgId} /> <SitesTable sites={siteRows} orgId={params.orgId} />
</> </>

View file

@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
import { api } from "@app/api"; import { api } from "@app/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
type LoginFormProps = { type LoginFormProps = {
redirect?: string; redirect?: string;
@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
setError( setError(
e.response?.data?.message || formatAxiosError(e, "An error occurred while logging in")
"An error occurred while logging in",
); );
}); });
@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) {
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<Button type="submit" className="w-full" loading={loading}> <Button
type="submit"
className="w-full"
loading={loading}
>
Login Login
</Button> </Button>
</form> </form>

View file

@ -27,6 +27,7 @@ import { api } from "@app/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
type SignupFormProps = { type SignupFormProps = {
redirect?: string; redirect?: string;
@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
setError( setError(
e.response?.data?.message || formatAxiosError(e, "An error occurred while signing up")
"An error occurred while signing up",
); );
}); });

View file

@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert"; import { Alert, AlertDescription } from "../../../components/ui/alert";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
const FormSchema = z.object({ const FormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
@ -76,14 +77,14 @@ export default function VerifyEmailForm({
code: data.pin, code: data.pin,
}) })
.catch((e) => { .catch((e) => {
setError(e.response?.data?.message || "An error occurred"); setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to verify email:", e); console.error("Failed to verify email:", e);
}); });
if (res && res.data?.data?.valid) { if (res && res.data?.data?.valid) {
setError(null); setError(null);
setSuccessMessage( setSuccessMessage(
"Email successfully verified! Redirecting you...", "Email successfully verified! Redirecting you..."
); );
setTimeout(() => { setTimeout(() => {
if (redirect && redirect.includes("http")) { if (redirect && redirect.includes("http")) {
@ -103,7 +104,7 @@ export default function VerifyEmailForm({
setIsResending(true); setIsResending(true);
const res = await api.post("/auth/verify-email/request").catch((e) => { const res = await api.post("/auth/verify-email/request").catch((e) => {
setError(e.response?.data?.message || "An error occurred"); setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to resend verification code:", e); console.error("Failed to resend verification code:", e);
}); });

View file

@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import InviteStatusCard from "./InviteStatusCard"; import InviteStatusCard from "./InviteStatusCard";
import { formatAxiosError } from "@app/lib/utils";
export default async function InvitePage(props: { export default async function InvitePage(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@ -47,8 +48,7 @@ export default async function InvitePage(props: {
await authCookieHeader() await authCookieHeader()
) )
.catch((e) => { .catch((e) => {
error = e.response?.data?.message; console.error(e);
console.log(error);
}); });
if (res && res.status === 200) { if (res && res.status === 200) {

View file

@ -15,6 +15,7 @@ import {
CardTitle, CardTitle,
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import { formatAxiosError } from "@app/lib/utils";
type Step = "org" | "site" | "resources"; type Step = "org" | "site" | "resources";
@ -43,7 +44,7 @@ export default function StepperForm() {
const debouncedCheckOrgIdAvailability = useCallback( const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300), debounce(checkOrgIdAvailability, 300),
[checkOrgIdAvailability], [checkOrgIdAvailability]
); );
useEffect(() => { useEffect(() => {
@ -76,7 +77,9 @@ export default function StepperForm() {
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
title: "Error creating org...", variant: "destructive",
title: "Error creating org",
description: formatAxiosError(e),
}); });
}); });
@ -106,36 +109,60 @@ export default function StepperForm() {
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "org" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`} className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
> >
1 1
</div> </div>
<span <span
className={`text-sm font-medium ${currentStep === "org" ? "text-primary" : "text-muted-foreground"}`} className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
> >
Create Org Create Org
</span> </span>
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "site" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`} className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
> >
2 2
</div> </div>
<span <span
className={`text-sm font-medium ${currentStep === "site" ? "text-primary" : "text-muted-foreground"}`} className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
> >
Create Site Create Site
</span> </span>
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "resources" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`} className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
> >
3 3
</div> </div>
<span <span
className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`} className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
> >
Create Resources Create Resources
</span> </span>
@ -251,7 +278,7 @@ export default function StepperForm() {
function debounce<T extends (...args: any[]) => any>( function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number, wait: number
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null; let timeout: NodeJS.Timeout | null = null;

View file

@ -1,15 +1,23 @@
type SettingsSectionTitleProps = { type SettingsSectionTitleProps = {
title: string | React.ReactNode; title: string | React.ReactNode;
description: string | React.ReactNode; description: string | React.ReactNode;
size?: "2xl" | "1xl";
}; };
export default function SettingsSectionTitle({ export default function SettingsSectionTitle({
title, title,
description, description,
size,
}: SettingsSectionTitleProps) { }: SettingsSectionTitleProps) {
return ( return (
<div className="space-y-0.5 select-none mb-6"> <div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">{title}</h2> <h2
className={`text-${
size ? size : "2xl"
} font-bold tracking-tight`}
>
{title}
</h2>
<p className="text-muted-foreground">{description}</p> <p className="text-muted-foreground">{description}</p>
</div> </div>
); );

View file

@ -17,7 +17,7 @@ export function Toaster() {
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
return ( return (
<Toast key={id} {...props}> <Toast key={id} {...props} className="mt-2">
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (

View file

@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
import { createContext } from "react"; import { createContext } from "react";
interface ResourceContextType { interface ResourceContextType {
resource: GetResourceResponse | null; resource: GetResourceResponse;
updateResource: (updatedResource: Partial<GetResourceResponse>) => void; updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
} }

View file

@ -1,78 +1,75 @@
"use client" "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from "react";
import type { import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 5 * 1000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
@ -80,7 +77,7 @@ export const reducer = (state: State, action: Action): State => {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
@ -88,19 +85,19 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t
), ),
} };
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@ -113,44 +110,44 @@ export const reducer = (state: State, action: Action): State => {
} }
: t : t
), ),
} };
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
} }
} };
}
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
@ -159,36 +156,37 @@ function toast({ ...props }: Toast) {
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) =>
} dispatch({ type: "DISMISS_TOAST", toastId }),
};
} }
export { useToast, toast } export { useToast, toast };

View file

@ -1,6 +1,15 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
}
export function formatAxiosError(error: any, defaultMessage?: string): string {
return (
error.response?.data?.message ||
error?.message ||
defaultMessage ||
"An error occurred"
);
} }

View file

@ -6,16 +6,15 @@ import { useState } from "react";
interface ResourceProviderProps { interface ResourceProviderProps {
children: React.ReactNode; children: React.ReactNode;
resource: GetResourceResponse | null; resource: GetResourceResponse;
} }
export function ResourceProvider({ export function ResourceProvider({
children, children,
resource: serverResource, resource: serverResource,
}: ResourceProviderProps) { }: ResourceProviderProps) {
const [resource, setResource] = useState<GetResourceResponse | null>( const [resource, setResource] =
serverResource useState<GetResourceResponse>(serverResource);
);
const updateResource = (updatedResource: Partial<GetResourceResponse>) => { const updateResource = (updatedResource: Partial<GetResourceResponse>) => {
if (!resource) { if (!resource) {