diff --git a/server/routers/external.ts b/server/routers/external.ts index ac7d62a2..47e9a4b0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -150,6 +150,7 @@ authenticated.get( authenticated.post( "/resource/:resourceId", verifyResourceAccess, + verifySiteAccess, verifyUserHasAction(ActionsEnum.updateResource), resource.updateResource ); @@ -370,7 +371,7 @@ authRouter.use( authRouter.put("/signup", auth.signup); authRouter.post("/login", auth.login); 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( diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index f1b7121d..97055453 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources } from "@server/db/schema"; +import { Resource, resources } from "@server/db/schema"; import { eq } from "drizzle-orm"; import response from "@server/utils/response"; import HttpCode from "@server/types/HttpCode"; @@ -12,12 +12,7 @@ const getResourceSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()), }); -export type GetResourceResponse = { - resourceId: number; - siteId: number; - orgId: string; - name: string; -}; +export type GetResourceResponse = Resource; export async function getResource( req: Request, @@ -53,12 +48,7 @@ export async function getResource( } return response(res, { - data: { - resourceId: resource[0].resourceId, - siteId: resource[0].siteId, - orgId: resource[0].orgId, - name: resource[0].name, - }, + data: resource[0], success: true, error: false, message: "Resource retrieved successfully", diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 1964e257..719aad97 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources } from "@server/db/schema"; +import { resources, sites } from "@server/db/schema"; import { eq } from "drizzle-orm"; import response from "@server/utils/response"; import HttpCode from "@server/types/HttpCode"; @@ -17,6 +17,8 @@ const updateResourceBodySchema = z .object({ name: 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, { message: "At least one field must be provided for update", diff --git a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx index db3805b8..d4f7368c 100644 --- a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx @@ -29,6 +29,7 @@ import { } from "@app/components/Credenza"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; +import { formatAxiosError } from "@app/lib/utils"; type CreateRoleFormProps = { open: boolean; @@ -74,9 +75,10 @@ export default function CreateRoleForm({ toast({ variant: "destructive", title: "Failed to create role", - description: - e.response?.data?.message || - "An error occurred while creating the role.", + description: formatAxiosError( + e, + "An error occurred while creating the role." + ), }); }); diff --git a/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx index 706eb0a6..962adb36 100644 --- a/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx @@ -36,6 +36,7 @@ import { SelectValue, } from "@app/components/ui/select"; import { RoleRow } from "./RolesTable"; +import { formatAxiosError } from "@app/lib/utils"; type CreateRoleFormProps = { open: boolean; @@ -71,9 +72,10 @@ export default function DeleteRoleForm({ toast({ variant: "destructive", title: "Failed to fetch roles", - description: - e.message || - "An error occurred while fetching the roles", + description: formatAxiosError( + e, + "An error occurred while fetching the roles" + ), }); }); @@ -109,9 +111,10 @@ export default function DeleteRoleForm({ toast({ variant: "destructive", title: "Failed to remove role", - description: - e.response?.data?.message || - "An error occurred while removing the role.", + description: formatAxiosError( + e, + "An error occurred while removing the role." + ), }); }); diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 62992860..e9a8e54e 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -28,6 +28,8 @@ import { ListRolesResponse } from "@server/routers/role"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { useParams } from "next/navigation"; import { Button } from "@app/components/ui/button"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { formatAxiosError } from "@app/lib/utils"; const formSchema = z.object({ email: z.string().email({ message: "Please enter a valid email" }), @@ -60,9 +62,10 @@ export default function AccessControlsPage() { toast({ variant: "destructive", title: "Failed to fetch roles", - description: - e.message || - "An error occurred while fetching the roles", + description: formatAxiosError( + e, + "An error occurred while fetching the roles" + ), }); }); @@ -87,9 +90,10 @@ export default function AccessControlsPage() { toast({ variant: "destructive", title: "Failed to add user to role", - description: - e.response?.data?.message || - "An error occurred while adding user to the role.", + description: formatAxiosError( + e, + "An error occurred while adding user to the role." + ), }); }); @@ -106,14 +110,11 @@ export default function AccessControlsPage() { return ( <> -
-

- Access Controls -

-

- Manage what this user can access and do in the organization -

-
+
- All Users + {" "} + All Users
diff --git a/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx index 941cb3f9..6f05eb8c 100644 --- a/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/components/InviteUserForm.tsx @@ -38,6 +38,7 @@ import { } from "@app/components/Credenza"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { ListRolesResponse } from "@server/routers/role"; +import { formatAxiosError } from "@app/lib/utils"; type InviteUserFormProps = { open: boolean; @@ -94,9 +95,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { toast({ variant: "destructive", title: "Failed to fetch roles", - description: - e.message || - "An error occurred while fetching the roles", + description: formatAxiosError( + e, + "An error occurred while fetching the roles" + ), }); }); @@ -128,9 +130,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { toast({ variant: "destructive", title: "Failed to invite user", - description: - e.response?.data?.message || - "An error occurred while inviting the user.", + description: formatAxiosError( + e, + "An error occurred while inviting the user" + ), }); }); diff --git a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx index 679b0010..ba89abd9 100644 --- a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx @@ -19,6 +19,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { formatAxiosError } from "@app/lib/utils"; export type UserRow = { id: string; @@ -162,9 +163,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { toast({ variant: "destructive", title: "Failed to remove user", - description: - e.message ?? - "An error occurred while removing the user.", + description: formatAxiosError( + e, + "An error occurred while removing the user." + ), }); }); diff --git a/src/app/[orgId]/settings/components/Header.tsx b/src/app/[orgId]/settings/components/Header.tsx index 70d22c44..3007a424 100644 --- a/src/app/[orgId]/settings/components/Header.tsx +++ b/src/app/[orgId]/settings/components/Header.tsx @@ -21,6 +21,7 @@ import { SelectValue, } from "@app/components/ui/select"; import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/utils"; import { ListOrgsResponse } from "@server/routers/org"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -51,6 +52,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) { console.error("Error logging out", e); toast({ title: "Error logging out", + description: formatAxiosError(e, "Error logging out"), }); }) .then(() => { diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index f5ea8c72..45702aea 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -1,5 +1,6 @@ import { internal } from "@app/api"; import { authCookieHeader } from "@app/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { SidebarSettings } from "@app/components/SidebarSettings"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; @@ -67,14 +68,12 @@ export default async function GeneralSettingsPage({ <> -
-

- General -

-

- Configure your organization's general settings -

-
+ + {children} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 648d8112..75f38e47 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -1,9 +1,7 @@ "use client"; import { useEffect, useState, use } from "react"; -import { Trash2, Server, Globe, Cpu } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -14,13 +12,12 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; import api from "@app/api"; import { AxiosResponse } from "axios"; import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { set, z } from "zod"; import { Form, FormControl, @@ -50,16 +47,14 @@ import { } from "@app/components/ui/table"; import { useToast } from "@app/hooks/useToast"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { Target } from "@server/db/schema"; 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({ - ip: z - .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" - ), + ip: z.string().ip(), method: z.string(), port: z .string() @@ -72,6 +67,11 @@ const addTargetSchema = z.object({ type AddTargetFormValues = z.infer; +type LocalTarget = ArrayElement & { + new?: boolean; + updated?: boolean; +}; + export default function ReverseProxyTargets(props: { params: Promise<{ resourceId: number }>; }) { @@ -80,7 +80,9 @@ export default function ReverseProxyTargets(props: { const { toast } = useToast(); const { resource, updateResource } = useResourceContext(); - const [targets, setTargets] = useState([]); + const [targets, setTargets] = useState([]); + const [targetsToRemove, setTargetsToRemove] = useState([]); + const [sslEnabled, setSslEnabled] = useState(resource.ssl); const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), @@ -103,9 +105,10 @@ export default function ReverseProxyTargets(props: { toast({ variant: "destructive", title: "Failed to fetch targets", - description: - err.message || - "An error occurred while fetching targets", + description: formatAxiosError( + err, + "An error occurred while fetching targets" + ), }); }); @@ -117,68 +120,145 @@ export default function ReverseProxyTargets(props: { }, []); async function addTarget(data: AddTargetFormValues) { - const res = await api - .put>( - `/resource/${params.resourceId}/target`, - { - ...data, - resourceId: undefined, - } + 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) { + setTargets( + targets.map((target) => + target.targetId === targetId + ? { ...target, ...data, updated: true } + : target ) + ); + } + + async function saveAll() { + const res = await api + .post(`/resource/${params.resourceId}`, { ssl: sslEnabled }) .catch((err) => { console.error(err); toast({ variant: "destructive", - title: "Failed to add target", - description: - err.message || "An error occurred while adding target", + title: "Failed to update resource", + description: formatAxiosError( + err, + "Failed to update resource" + ), }); - }); - - if (res && res.status === 201) { - setTargets([...targets, res.data.data]); - addTargetForm.reset(); - } - } - - const removeTarget = (targetId: number) => { - api.delete(`/target/${targetId}`) - .catch((err) => { - console.error(err); }) - .then((res) => { - setTargets( - targets.filter((target) => target.targetId !== targetId) - ); + .then(() => { + updateResource({ ssl: sslEnabled }); }); - }; - async function updateTarget(targetId: number, data: Partial) { - setTargets( - targets.map((target) => - target.targetId === targetId ? { ...target, ...data } : target - ) - ); + for (const target of targets) { + const data = { + ip: target.ip, + port: target.port, + method: target.method, + protocol: target.protocol, + enabled: target.enabled, + }; - 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 (target.new) { + await api + .put>( + `/resource/${params.resourceId}/target`, + data + ) + .then((res) => { + setTargets( + targets.map((t) => { + if ( + t.new && + t.targetId === res.data.data.targetId + ) { + return { + ...t, + new: false, + }; + } + return t; + }) + ); + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to add target", + description: formatAxiosError( + err, + "Failed to add target" + ), + }); + }); + } else if (target.updated) { + const res = await api + .post(`/target/${target.targetId}`, data) + .catch((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) => { + setTargets( + targets.filter((target) => target.targetId !== targetId) + ); + }); + } + + toast({ + title: "Resource updated", + description: "Resource and targets updated successfully", }); - if (res && res.status === 200) { - toast({ - title: "Target updated", - description: "The target has been updated successfully", - }); - } + setTargetsToRemove([]); } - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: "ip", header: "IP Address", @@ -259,12 +339,17 @@ export default function ReverseProxyTargets(props: { { id: "actions", cell: ({ row }) => ( - + <> +
+ {row.original.new && } + +
+ ), }, ]; @@ -286,10 +371,15 @@ export default function ReverseProxyTargets(props: {
- + setSslEnabled(val)} + />
@@ -298,6 +388,7 @@ export default function ReverseProxyTargets(props: { @@ -355,8 +446,8 @@ export default function ReverseProxyTargets(props: { - Choose the method for the target - connection + Choose the method for how the + target is accessed @@ -422,7 +513,9 @@ export default function ReverseProxyTargets(props: { )} /> - + @@ -466,7 +559,7 @@ export default function ReverseProxyTargets(props: { colSpan={columns.length} className="h-24 text-center" > - No results. + No targets. Add a target using the form. )} @@ -474,6 +567,10 @@ export default function ReverseProxyTargets(props: { + +
+ +
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index d8cf656c..b865e126 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { cn } from "@/lib/utils"; +import { cn, formatAxiosError } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Form, @@ -38,6 +38,7 @@ import { useParams } from "next/navigation"; import { useForm } from "react-hook-form"; import { GetResourceResponse } from "@server/routers/resource"; import { useToast } from "@app/hooks/useToast"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; const GeneralFormSchema = z.object({ name: z.string(), @@ -48,64 +49,71 @@ type GeneralFormValues = z.infer; export default function GeneralForm() { const params = useParams(); - const orgId = params.orgId; - const { resource, updateResource } = useResourceContext(); - const [sites, setSites] = useState([]); const { toast } = useToast(); + const { resource, updateResource } = useResourceContext(); + + const orgId = params.orgId; + + const [sites, setSites] = useState([]); + const [saveLoading, setSaveLoading] = useState(false); const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { - name: resource?.name, - siteId: resource?.siteId, + name: resource.name, + siteId: resource.siteId!, }, mode: "onChange", }); useEffect(() => { - if (typeof window !== "undefined") { - const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); - }; - fetchSites(); - } + const fetchSites = async () => { + const res = await api.get>( + `/org/${orgId}/sites/` + ); + setSites(res.data.data.sites); + }; + fetchSites(); }, []); async function onSubmit(data: GeneralFormValues) { + setSaveLoading(true); updateResource({ name: data.name, siteId: data.siteId }); - await api - .post>( - `resource/${resource?.resourceId}`, - { - name: data.name, - siteId: data.siteId, - } - ) + + api.post>( + `resource/${resource?.resourceId}`, + { + name: data.name, + siteId: data.siteId, + } + ) .catch((e) => { toast({ variant: "destructive", title: "Failed to update resource", - description: - e.response?.data?.message || - "An error occurred while updating the resource", + description: formatAxiosError( + e, + "An error occurred while updating the resource" + ), }); - }); + }) + .then(() => { + toast({ + title: "Resource updated", + description: "The resource has been updated successfully", + }); + }) + .finally(() => setSaveLoading(false)); } return ( <>
-
-

- General Settings -

-

- Configure the general settings for this resource -

-
+
- + - No site found. + No sites found. {sites.map((site) => ( @@ -206,7 +214,13 @@ export default function GeneralForm() { )} /> - +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 18a9f445..30e5c72e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies"; import { SidebarSettings } from "@app/components/SidebarSettings"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; interface ResourceLayoutProps { children: React.ReactNode; @@ -53,19 +54,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { className="text-muted-foreground hover:underline" >
- All Resources + {" "} + All Resources
-
-

- {resource?.name + " Settings"} -

-

- Configure the settings on your resource -

-
+ { toast({ + variant: "destructive", title: "Error creating resource", + description: formatAxiosError( + e, + "An error occurred when creating the resource" + ), }); }); diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index e262fef5..84dfae49 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -17,6 +17,8 @@ import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { set } from "zod"; +import { formatAxiosError } from "@app/lib/utils"; +import { useToast } from "@app/hooks/useToast"; export type ResourceRow = { id: number; @@ -34,6 +36,8 @@ type ResourcesTableProps = { export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const router = useRouter(); + const { toast } = useToast(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = @@ -43,6 +47,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { api.delete(`/resource/${resourceId}`) .catch((e) => { console.error("Error deleting resource", e); + toast({ + variant: "destructive", + title: "Error deleting resource", + description: formatAxiosError(e, "Error deleting resource"), + }); }) .then(() => { router.refresh(); diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 6b6aa33c..ecec6cc0 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies"; import ResourcesTable, { ResourceRow } from "./components/ResourcesTable"; import { AxiosResponse } from "axios"; import { ListResourcesResponse } from "@server/routers/resource"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourcesPageProps = { params: Promise<{ orgId: string }>; @@ -33,14 +34,10 @@ export default async function ResourcesPage(props: ResourcesPageProps) { return ( <> -
-

- Manage Resources -

-

- Create secure proxies to your private applications. -

-
+ diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index ae5f28c4..f736b54f 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -18,6 +18,8 @@ import { useForm } from "react-hook-form"; import api from "@app/api"; import { useToast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { formatAxiosError } from "@app/lib/utils"; const GeneralFormSchema = z.object({ name: z.string(), @@ -41,18 +43,19 @@ export default function GeneralPage() { async function onSubmit(data: GeneralFormValues) { await api - .post(`/site/${site?.siteId}`, { - name: data.name, - }) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to update site", - description: - e.message || - "An error occurred while updating the site.", + .post(`/site/${site?.siteId}`, { + name: data.name, + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to update site", + description: formatAxiosError( + e, + "An error occurred while updating the site." + ), + }); }); - }); updateSite({ name: data.name }); @@ -61,14 +64,11 @@ export default function GeneralPage() { return ( <> -
-

- General Settings -

-

- Configure the general settings for this site -

-
+
- All Sites + All Sites
-
-

- {site?.name + " Settings"} -

-

- Configure the settings on your site -

-
+ { toast({ + variant: "destructive", title: "Error picking site defaults", + description: formatAxiosError(e), }); }) .then((res) => { @@ -130,7 +133,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) { }) .catch((e) => { toast({ + variant: "destructive", title: "Error creating site", + description: formatAxiosError(e), }); }); diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index 44e92441..c8d8c5f9 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -17,6 +17,8 @@ import { AxiosResponse } from "axios"; import { useState } from "react"; import CreateSiteForm from "./CreateSiteForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/utils"; export type SiteRow = { id: number; @@ -35,6 +37,8 @@ type SitesTableProps = { export default function SitesTable({ sites, orgId }: SitesTableProps) { const router = useRouter(); + const { toast } = useToast(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); @@ -48,6 +52,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { api.delete(`/site/${siteId}`) .catch((e) => { console.error("Error deleting site", e); + toast({ + variant: "destructive", + title: "Error deleting site", + description: formatAxiosError(e, "Error deleting site"), + }); }) .then(() => { router.refresh(); diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 9dcc6a45..fc758e92 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies"; import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "./components/SitesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type SitesPageProps = { params: Promise<{ orgId: string }>; @@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> -
-

- Manage Sites -

-

- Manage your existing sites here or create a new one. -

-
+ diff --git a/src/app/auth/login/LoginForm.tsx b/src/app/auth/login/LoginForm.tsx index c40c6f7e..f19376e8 100644 --- a/src/app/auth/login/LoginForm.tsx +++ b/src/app/auth/login/LoginForm.tsx @@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth"; import { api } from "@app/api"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; +import { formatAxiosError } from "@app/lib/utils"; type LoginFormProps = { redirect?: string; @@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) { .catch((e) => { console.error(e); setError( - e.response?.data?.message || - "An error occurred while logging in", + formatAxiosError(e, "An error occurred while logging in") ); }); @@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) { {error} )} - diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index b8af07aa..33b2fed6 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -27,6 +27,7 @@ import { api } from "@app/api"; import { useRouter } from "next/navigation"; import { passwordSchema } from "@server/auth/passwordSchema"; import { AxiosResponse } from "axios"; +import { formatAxiosError } from "@app/lib/utils"; type SignupFormProps = { redirect?: string; @@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) { .catch((e) => { console.error(e); setError( - e.response?.data?.message || - "An error occurred while signing up", + formatAxiosError(e, "An error occurred while signing up") ); }); diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index b1071462..d6829d8c 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; import { useToast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; +import { formatAxiosError } from "@app/lib/utils"; const FormSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), @@ -76,14 +77,14 @@ export default function VerifyEmailForm({ code: data.pin, }) .catch((e) => { - setError(e.response?.data?.message || "An error occurred"); + setError(formatAxiosError(e, "An error occurred")); console.error("Failed to verify email:", e); }); if (res && res.data?.data?.valid) { setError(null); setSuccessMessage( - "Email successfully verified! Redirecting you...", + "Email successfully verified! Redirecting you..." ); setTimeout(() => { if (redirect && redirect.includes("http")) { @@ -103,7 +104,7 @@ export default function VerifyEmailForm({ setIsResending(true); 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); }); diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index e8d83af8..689dccd5 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import InviteStatusCard from "./InviteStatusCard"; +import { formatAxiosError } from "@app/lib/utils"; export default async function InvitePage(props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; @@ -47,8 +48,7 @@ export default async function InvitePage(props: { await authCookieHeader() ) .catch((e) => { - error = e.response?.data?.message; - console.log(error); + console.error(e); }); if (res && res.status === 200) { diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 483303e4..4d33812d 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -15,6 +15,7 @@ import { CardTitle, } from "@app/components/ui/card"; import CopyTextBox from "@app/components/CopyTextBox"; +import { formatAxiosError } from "@app/lib/utils"; type Step = "org" | "site" | "resources"; @@ -43,7 +44,7 @@ export default function StepperForm() { const debouncedCheckOrgIdAvailability = useCallback( debounce(checkOrgIdAvailability, 300), - [checkOrgIdAvailability], + [checkOrgIdAvailability] ); useEffect(() => { @@ -76,7 +77,9 @@ export default function StepperForm() { }) .catch((e) => { toast({ - title: "Error creating org...", + variant: "destructive", + title: "Error creating org", + description: formatAxiosError(e), }); }); @@ -106,36 +109,60 @@ export default function StepperForm() {
1
Create Org
2
Create Site
3
Create Resources @@ -251,7 +278,7 @@ export default function StepperForm() { function debounce any>( func: T, - wait: number, + wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; diff --git a/src/components/SettingsSectionTitle.tsx b/src/components/SettingsSectionTitle.tsx index 733c8501..5280532e 100644 --- a/src/components/SettingsSectionTitle.tsx +++ b/src/components/SettingsSectionTitle.tsx @@ -1,15 +1,23 @@ type SettingsSectionTitleProps = { title: string | React.ReactNode; description: string | React.ReactNode; + size?: "2xl" | "1xl"; }; export default function SettingsSectionTitle({ title, description, + size, }: SettingsSectionTitleProps) { return (
-

{title}

+

+ {title} +

{description}

); diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index d1bd3c3d..16c780bc 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -17,7 +17,7 @@ export function Toaster() { {toasts.map(function ({ id, title, description, action, ...props }) { return ( - +
{title && {title}} {description && ( diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index e3090b3d..40c43120 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource"; import { createContext } from "react"; interface ResourceContextType { - resource: GetResourceResponse | null; + resource: GetResourceResponse; updateResource: (updatedResource: Partial) => void; } diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts index 02e111d8..992106aa 100644 --- a/src/hooks/useToast.ts +++ b/src/hooks/useToast.ts @@ -1,194 +1,192 @@ -"use client" +"use client"; // Inspired by react-hot-toast library -import * as React from "react" +import * as React from "react"; -import type { - ToastActionElement, - ToastProps, -} from "@/components/ui/toast" +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 3; +const TOAST_REMOVE_DELAY = 5 * 1000; type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; -let count = 0 +let count = 0; function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); } -type ActionType = typeof actionTypes +type ActionType = typeof actionTypes; type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; interface State { - toasts: ToasterToast[] + toasts: ToasterToast[]; } -const toastTimeouts = new Map>() +const toastTimeouts = new Map>(); const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } + if (toastTimeouts.has(toastId)) { + return; + } - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); - toastTimeouts.set(toastId, timeout) -} + toastTimeouts.set(toastId, timeout); +}; export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; - case "DISMISS_TOAST": { - const { toastId } = action + case "DISMISS_TOAST": { + const { toastId } = action; - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + 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) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); } -type Toast = Omit +type Toast = Omit; function toast({ ...props }: Toast) { - const id = genId() + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - const update = (props: ToasterToast) => dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } + return { + id: id, + dismiss, + update, + }; } function useToast() { - const [state, setState] = React.useState(memoryState) + const [state, setState] = React.useState(memoryState); - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } + return { + ...state, + toast, + dismiss: (toastId?: string) => + dispatch({ type: "DISMISS_TOAST", toastId }), + }; } -export { useToast, toast } +export { useToast, toast }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index aa65f25e..3a5000ea 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,15 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; 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" + ); } diff --git a/src/providers/ResourceProvider.tsx b/src/providers/ResourceProvider.tsx index 28748697..cdf1c8d5 100644 --- a/src/providers/ResourceProvider.tsx +++ b/src/providers/ResourceProvider.tsx @@ -6,16 +6,15 @@ import { useState } from "react"; interface ResourceProviderProps { children: React.ReactNode; - resource: GetResourceResponse | null; + resource: GetResourceResponse; } export function ResourceProvider({ children, resource: serverResource, }: ResourceProviderProps) { - const [resource, setResource] = useState( - serverResource - ); + const [resource, setResource] = + useState(serverResource); const updateResource = (updatedResource: Partial) => { if (!resource) {