diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 815fa137..a9f81995 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -66,6 +66,7 @@ export enum ActionsEnum { deleteClient = "deleteClient", updateClient = "updateClient", listClients = "listClients", + getClient = "getClient", listOrgDomains = "listOrgDomains", } diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts new file mode 100644 index 00000000..470d712e --- /dev/null +++ b/server/routers/client/getClient.ts @@ -0,0 +1,74 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db/schema"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import stoi from "@server/lib/stoi"; +import { fromError } from "zod-validation-error"; + +const getClientSchema = z + .object({ + clientId: z + .string() + .transform(stoi) + .pipe(z.number().int().positive()), + orgId: z.string().optional() + }) + .strict(); + +async function query(clientId: number) { + const [res] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + return res; +} + +export type GetClientResponse = NonNullable>>; + +export async function getClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getClientSchema.safeParse(req.params); + if (!parsedParams.success) { + logger.error( + `Error parsing params: ${fromError(parsedParams.error).toString()}` + ); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + const client = await query(clientId); + + if (!client) { + return next(createHttpError(HttpCode.NOT_FOUND, "Client not found")); + } + + return response(res, { + data: client, + success: true, + error: false, + message: "Client retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 144957ed..385c7bed 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -2,4 +2,5 @@ export * from "./pickClientDefaults"; export * from "./createClient"; export * from "./deleteClient"; export * from "./listClients"; -export * from "./updateClient"; \ No newline at end of file +export * from "./updateClient"; +export * from "./getClient"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index ba4c1716..46ce4fcf 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -120,6 +120,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/client/:clientId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getClient), + client.getClient +); + authenticated.put( "/org/:orgId/client", verifyOrgAccess, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index d9ad9d16..9fae5aca 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -42,7 +42,8 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { megabytesOut: sites.megabytesOut, orgName: orgs.name, type: sites.type, - online: sites.online + online: sites.online, + address: sites.address, }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/app/[orgId]/settings/clients/ClientsTable.tsx index 7b829efb..a1c0dde2 100644 --- a/src/app/[orgId]/settings/clients/ClientsTable.tsx +++ b/src/app/[orgId]/settings/clients/ClientsTable.tsx @@ -30,6 +30,8 @@ import CreateClientFormModal from "./CreateClientsModal"; export type ClientRow = { id: number; name: string; + subnet: string; + // siteIds: string; mbIn: string; mbOut: string; orgId: string; @@ -53,7 +55,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { const api = createApiClient(useEnvContext()); - const deleteSite = (clientId: number) => { + const deleteClient = (clientId: number) => { api.delete(`/client/${clientId}`) .catch((e) => { console.error("Error deleting client", e); @@ -218,25 +220,41 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { ); } + }, + { + accessorKey: "subnet", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const clientRow = row.original; + return ( +
+ + + +
+ ); + } } - // { - // id: "actions", - // cell: ({ row }) => { - // const siteRow = row.original; - // return ( - //
- // - // - // - //
- // ); - // } - // } ]; return ( @@ -281,7 +299,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { } buttonText="Confirm Delete Client" - onConfirm={async () => deleteSite(selectedClient!.id)} + onConfirm={async () => deleteClient(selectedClient!.id)} string={selectedClient.name} title="Delete Client" /> diff --git a/src/app/[orgId]/settings/clients/CreateClientsForm.tsx b/src/app/[orgId]/settings/clients/CreateClientsForm.tsx index b1787cef..0e8a31f0 100644 --- a/src/app/[orgId]/settings/clients/CreateClientsForm.tsx +++ b/src/app/[orgId]/settings/clients/CreateClientsForm.tsx @@ -222,6 +222,7 @@ export default function CreateClientForm({ onCreate?.({ name: data.name, id: data.clientId, + subnet: data.subnet, mbIn: "0 MB", mbOut: "0 MB", orgId: orgId as string, diff --git a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx new file mode 100644 index 00000000..7117b4d5 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type ClientInfoCardProps = {}; + +export default function SiteInfoCard({}: ClientInfoCardProps) { + const { client, updateClient } = useClientContext(); + + return ( + + + Client Information + + + <> + + Status + + {client.online ? ( +
+
+ Online +
+ ) : ( +
+
+ Offline +
+ )} +
+
+ + + Address + + {client.subnet.split("/")[0]} + + +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx new file mode 100644 index 00000000..001e92c4 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState } from "react"; + +const GeneralFormSchema = z.object({ + name: z.string().nonempty("Name is required") +}); + +type GeneralFormValues = z.infer; + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + + const api = createApiClient(useEnvContext()); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: client?.name + }, + mode: "onChange" + }); + + async function onSubmit(data: GeneralFormValues) { + setLoading(true); + + await api + .post(`/client/${client?.clientId}`, { + name: data.name + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to update client", + description: formatAxiosError( + e, + "An error occurred while updating the client." + ) + }); + }); + + updateClient({ name: data.name }); + + toast({ + title: "Client updated", + description: "The client has been updated." + }); + + setLoading(false); + + router.refresh(); + } + + return ( + + + + + General Settings + + + Configure the general settings for this client + + + + + +
+ + ( + + Name + + + + + + This is the display name of the + client. + + + )} + /> + + +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx new file mode 100644 index 00000000..20caf093 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -0,0 +1,77 @@ +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import Link from "next/link"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import { GetClientResponse } from "@server/routers/client"; +import ClientInfoCard from "./ClientInfoCard"; +import ClientProvider from "@app/providers/ClientProvider"; +import { redirect } from "next/navigation"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ clientId: number; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.clientId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + console.error("Error fetching client data:", error); + redirect(`/${params.orgId}/settings/clients`); + } + + const sidebarNavItems = [ + { + title: "General", + href: "/{orgId}/settings/clients/{clientId}/general" + } + ]; + + return ( + <> +
+ + + + Clients + + + + {client.name} + + + +
+ + + + + + + {children} + + + + ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/page.tsx new file mode 100644 index 00000000..c484ec8c --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; clientId: number }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`); +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 145d99a7..b798bf93 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -37,6 +37,7 @@ export default async function ClientsPage(props: ClientsPageProps) { return { name: client.name, id: client.clientId, + subnet: client.subnet.split("/")[0], mbIn: formatSize(client.megabytesIn || 0), mbOut: formatSize(client.megabytesOut || 0), orgId: params.orgId, diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 4bafa38c..01043b3a 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -206,7 +206,6 @@ export default function GeneralPage() { )} /> - {/* New FormField for subnet input */} Subnet diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 546b2a5b..7fe2b6af 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -224,6 +224,7 @@ export default function CreateSiteForm({ name: data.name, id: data.siteId, nice: data.niceId.toString(), + address: data.address?.split("/")[0], mbIn: data.type == "wireguard" || data.type == "newt" ? "0 MB" diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 43ae82a1..0cebed47 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -37,6 +37,7 @@ export type SiteRow = { orgId: string; type: "newt" | "wireguard"; online: boolean; + address?: string; }; type SitesTableProps = { @@ -259,6 +260,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } } }, + { + accessorKey: "address", + header: ({ column }) => { + return ( + + ); + } + }, { id: "actions", cell: ({ row }) => { diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index f3fa4957..0e19ed16 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -41,6 +41,7 @@ export default async function SitesPage(props: SitesPageProps) { name: site.name, id: site.siteId, nice: site.niceId.toString(), + address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), orgId: params.orgId, diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 542327bf..3e83f70d 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -34,6 +34,7 @@ export function SidebarNav({ const niceId = params.niceId as string; const resourceId = params.resourceId as string; const userId = params.userId as string; + const clientId = params.clientId as string; const [selectedValue, setSelectedValue] = React.useState(getSelectedValue()); @@ -59,7 +60,8 @@ export function SidebarNav({ .replace("{orgId}", orgId) .replace("{niceId}", niceId) .replace("{resourceId}", resourceId) - .replace("{userId}", userId); + .replace("{userId}", userId) + .replace("{clientId}", clientId); } return ( diff --git a/src/contexts/clientContext.ts b/src/contexts/clientContext.ts new file mode 100644 index 00000000..4ed8dc81 --- /dev/null +++ b/src/contexts/clientContext.ts @@ -0,0 +1,11 @@ +import { GetClientResponse } from "@server/routers/client/getClient"; +import { createContext } from "react"; + +interface ClientContextType { + client: GetClientResponse; + updateClient: (updatedClient: Partial) => void; +} + +const ClientContext = createContext(undefined); + +export default ClientContext; diff --git a/src/hooks/useClientContext.ts b/src/hooks/useClientContext.ts new file mode 100644 index 00000000..fa958939 --- /dev/null +++ b/src/hooks/useClientContext.ts @@ -0,0 +1,10 @@ +import ClientContext from "@app/contexts/clientContext"; +import { useContext } from "react"; + +export function useClientContext() { + const context = useContext(ClientContext); + if (context === undefined) { + throw new Error('useSiteContext must be used within a SiteProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/providers/ClientProvider.tsx b/src/providers/ClientProvider.tsx new file mode 100644 index 00000000..5e89acd8 --- /dev/null +++ b/src/providers/ClientProvider.tsx @@ -0,0 +1,40 @@ +"use client"; + +import ClientContext from "@app/contexts/clientContext"; +import { GetClientResponse } from "@server/routers/client/getClient"; +import { useState } from "react"; + +interface ClientProviderProps { + children: React.ReactNode; + client: GetClientResponse; +} + +export function ClientProvider({ + children, + client: serverClient +}: ClientProviderProps) { + const [client, setClient] = useState(serverClient); + + const updateClient = (updatedClient: Partial) => { + if (!client) { + throw new Error("No client to update"); + } + setClient((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updatedClient + }; + }); + }; + + return ( + + {children} + + ); +} + +export default ClientProvider;