From 098723b88d2793f18a0f3729e2e9cdb18ff386a2 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 21 Feb 2025 16:58:30 -0500 Subject: [PATCH] clients frontend demo first pass --- server/routers/client/createClient.ts | 6 + server/routers/client/listClients.ts | 31 +- server/routers/client/pickClientDefaults.ts | 21 +- server/routers/site/createSite.ts | 2 +- .../settings/clients/ClientsDataTable.tsx | 153 ++++++++ .../[orgId]/settings/clients/ClientsTable.tsx | 271 ++++++++++++++ .../settings/clients/CreateClientsForm.tsx | 336 ++++++++++++++++++ .../settings/clients/CreateClientsModal.tsx | 80 +++++ src/app/[orgId]/settings/clients/page.tsx | 57 +++ src/app/[orgId]/settings/layout.tsx | 7 +- 10 files changed, 940 insertions(+), 24 deletions(-) create mode 100644 src/app/[orgId]/settings/clients/ClientsDataTable.tsx create mode 100644 src/app/[orgId]/settings/clients/ClientsTable.tsx create mode 100644 src/app/[orgId]/settings/clients/CreateClientsForm.tsx create mode 100644 src/app/[orgId]/settings/clients/CreateClientsModal.tsx create mode 100644 src/app/[orgId]/settings/clients/page.tsx diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 85f1735e..03b7826e 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -104,6 +104,12 @@ export async function createClient( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } + if (site.type !== "newt") { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Site is not a newt site") + ); + } + await db.transaction(async (trx) => { const adminRole = await trx .select() diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index ad03bbf7..9273d54a 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -3,10 +3,8 @@ import { clients, orgs, roleClients, - roleSites, sites, userClients, - userSites } from "@server/db/schema"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -41,16 +39,17 @@ const listClientsSchema = z.object({ function queryClients(orgId: string, accessibleClientIds: number[]) { return db .select({ - siteId: sites.siteId, - niceId: sites.niceId, - name: sites.name, - pubKey: sites.pubKey, - subnet: sites.subnet, - megabytesIn: sites.megabytesIn, - megabytesOut: sites.megabytesOut, + clientId: clients.clientId, + orgId: clients.orgId, + siteId: clients.siteId, + name: clients.name, + pubKey: clients.pubKey, + subnet: clients.subnet, + megabytesIn: clients.megabytesIn, + megabytesOut: clients.megabytesOut, orgName: orgs.name, - type: sites.type, - online: sites.online + type: clients.type, + online: clients.online }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) @@ -115,22 +114,22 @@ export async function listClients( ) .where( or( - eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) + eq(userClients.userId, req.user!.userId), + eq(roleClients.roleId, req.userOrgRoleId!) ) ); - const accessibleSiteIds = accessibleClients.map( + const accessibleClientIds = accessibleClients.map( (site) => site.clientId ); - const baseQuery = queryClients(orgId, accessibleSiteIds); + const baseQuery = queryClients(orgId, accessibleClientIds); let countQuery = db .select({ count: count() }) .from(sites) .where( and( - inArray(sites.siteId, accessibleSiteIds), + inArray(sites.siteId, accessibleClientIds), eq(sites.orgId, orgId) ) ); diff --git a/server/routers/client/pickClientDefaults.ts b/server/routers/client/pickClientDefaults.ts index fd048259..32caaa3b 100644 --- a/server/routers/client/pickClientDefaults.ts +++ b/server/routers/client/pickClientDefaults.ts @@ -14,7 +14,7 @@ import { fromError } from "zod-validation-error"; const getSiteSchema = z .object({ - siteId: z.number().int().positive() + siteId: z.string().transform(Number).pipe(z.number()) }) .strict(); @@ -26,8 +26,8 @@ export type PickClientDefaultsResponse = { listenPort: number; endpoint: string; subnet: string; - clientId: string; - clientSecret: string; + olmId: string; + olmSecret: string; }; export async function pickClientDefaults( @@ -57,6 +57,15 @@ export async function pickClientDefaults( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } + if (site.type !== "newt") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site is not a newt site" + ) + ); + } + // make sure all the required fields are present const sitesRequiredFields = z.object({ @@ -109,7 +118,7 @@ export async function pickClientDefaults( ); } - const clientId = generateId(15); + const olmId = generateId(15); const secret = generateId(48); return response(res, { @@ -121,8 +130,8 @@ export async function pickClientDefaults( listenPort: listenPort, endpoint: endpoint, subnet: newSubnet, - clientId, - clientSecret: secret + olmId: olmId, + olmSecret: secret }, success: true, error: false, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 1b30b5c8..a1d1876f 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -35,7 +35,7 @@ const createSiteSchema = z subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), - type: z.enum(["newt", "wireguard"]) + type: z.enum(["newt", "wireguard", "local"]) }) .strict(); diff --git a/src/app/[orgId]/settings/clients/ClientsDataTable.tsx b/src/app/[orgId]/settings/clients/ClientsDataTable.tsx new file mode 100644 index 00000000..07bee372 --- /dev/null +++ b/src/app/[orgId]/settings/clients/ClientsDataTable.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Plus, Search } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + addClient?: () => void; +} + +export function ClientsDataTable({ + addClient, + columns, + data +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 20, + pageIndex: 0 + } + }, + state: { + sorting, + columnFilters + } + }); + + return ( +
+
+
+ + table + .getColumn("name") + ?.setFilterValue(event.target.value) + } + className="w-full pl-8" + /> + +
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No clients. Create one to get started. + + + )} + +
+
+
+ +
+
+ ); +} diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/app/[orgId]/settings/clients/ClientsTable.tsx new file mode 100644 index 00000000..bba06c00 --- /dev/null +++ b/src/app/[orgId]/settings/clients/ClientsTable.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ClientsDataTable } from "./ClientsDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { + ArrowRight, + ArrowUpDown, + Check, + MoreHorizontal, + X +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import CreateClientFormModal from "./CreateClientsModal"; + +export type ClientRow = { + id: number; + name: string; + mbIn: string; + mbOut: string; + orgId: string; + online: boolean; +}; + +type ClientTableProps = { + clients: ClientRow[]; + orgId: string; +}; + +export default function ClientsTable({ clients, orgId }: ClientTableProps) { + const router = useRouter(); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState( + null + ); + const [rows, setRows] = useState(clients); + + const api = createApiClient(useEnvContext()); + + const deleteSite = (clientId: number) => { + api.delete(`/client/${clientId}`) + .catch((e) => { + console.error("Error deleting client", e); + toast({ + variant: "destructive", + title: "Error deleting client", + description: formatAxiosError(e, "Error deleting client") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== clientId); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const clientRow = row.original; + const router = useRouter(); + + return ( + + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "online", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ Online +
+ ); + } else { + return ( + +
+ Offline +
+ ); + } + } + }, + { + accessorKey: "mbIn", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "mbOut", + header: ({ column }) => { + return ( + + ); + } + } + // { + // id: "actions", + // cell: ({ row }) => { + // const siteRow = row.original; + // return ( + //
+ // + // + // + //
+ // ); + // } + // } + ]; + + return ( + <> + { + setRows([val, ...rows]); + }} + orgId={orgId} + /> + + {selectedClient && ( + { + setIsDeleteModalOpen(val); + setSelectedClient(null); + }} + dialog={ +
+

+ Are you sure you want to remove the client{" "} + + {selectedClient?.name || selectedClient?.id} + {" "} + from the site and organization? +

+ +

+ + Once removed, the client will no longer be + able to connect to the site.{" "} + +

+ +

+ To confirm, please type the name of the client + below. +

+
+ } + buttonText="Confirm Delete Client" + onConfirm={async () => deleteSite(selectedClient!.id)} + string={selectedClient.name} + title="Delete Client" + /> + )} + + { + setIsCreateModalOpen(true); + }} + /> + + ); +} diff --git a/src/app/[orgId]/settings/clients/CreateClientsForm.tsx b/src/app/[orgId]/settings/clients/CreateClientsForm.tsx new file mode 100644 index 00000000..ba5a9838 --- /dev/null +++ b/src/app/[orgId]/settings/clients/CreateClientsForm.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { AxiosResponse } from "axios"; +import { Collapsible } from "@app/components/ui/collapsible"; +import { ClientRow } from "./ClientsTable"; +import { + CreateClientResponse, + PickClientDefaultsResponse +} from "@server/routers/client"; +import { ListSitesResponse } from "@server/routers/site"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; + +const createClientFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(30, { + message: "Name must not be longer than 30 characters." + }), + siteId: z.coerce.number() +}); + +type CreateSiteFormValues = z.infer; + +const defaultValues: Partial = { + name: "" +}; + +type CreateSiteFormProps = { + onCreate?: (client: ClientRow) => void; + setLoading?: (loading: boolean) => void; + setChecked?: (checked: boolean) => void; + orgId: string; +}; + +export default function CreateClientForm({ + onCreate, + setLoading, + setChecked, + orgId +}: CreateSiteFormProps) { + const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + + const [sites, setSites] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isChecked, setIsChecked] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [clientDefaults, setClientDefaults] = + useState(null); + const [olmCommand, setOlmCommand] = useState(null); + + const handleCheckboxChange = (checked: boolean) => { + setIsChecked(checked); + }; + + const form = useForm({ + resolver: zodResolver(createClientFormSchema), + defaultValues + }); + + useEffect(() => { + if (!open) return; + + // reset all values + setLoading?.(false); + setIsLoading(false); + form.reset(); + setChecked?.(false); + setClientDefaults(null); + + const fetchSites = async () => { + const res = await api.get>( + `/org/${orgId}/sites/` + ); + setSites(res.data.data.sites); + + if (res.data.data.sites.length > 0) { + form.setValue("siteId", res.data.data.sites[0].siteId); + } + }; + + fetchSites(); + }, [open]); + + useEffect(() => { + const siteId = form.getValues("siteId"); + + if (siteId === undefined || siteId === null) return; + + api.get(`/site/${siteId}/pick-client-defaults`) + .catch((e) => { + toast({ + variant: "destructive", + title: `Error fetching client defaults for site ${siteId}`, + description: formatAxiosError(e) + }); + }) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + setClientDefaults(data); + const olmConfig = `olm --id ${data?.olmId} --secret ${data?.olmSecret} --endpoint ${env.app.dashboardUrl}`; + setOlmCommand(olmConfig); + } + }); + }, [form.watch("siteId")]); + + async function onSubmit(data: CreateSiteFormValues) { + setLoading?.(true); + setIsLoading(true); + + if (!clientDefaults) { + toast({ + variant: "destructive", + title: "Error creating site", + description: "Site defaults not found" + }); + setLoading?.(false); + setIsLoading(false); + return; + } + + const payload = { + name: data.name, + siteId: data.siteId, + orgId, + subnet: clientDefaults.subnet, + secret: clientDefaults.olmSecret, + olmId: clientDefaults.olmId + }; + + const res = await api + .put< + AxiosResponse + >(`/site/${data.siteId}/client`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating client", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + onCreate?.({ + name: data.name, + id: data.clientId, + mbIn: "0 MB", + mbOut: "0 MB", + orgId: orgId as string, + online: false + }); + } + + setLoading?.(false); + setIsLoading(false); + } + + return ( +
+
+ + ( + + Name + + + + + + )} + /> + + ( + + Client + + + + + + + + + + + + No site found. + + + {sites.map((site) => ( + { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + {site.name} + + ))} + + + + + + + The client will be have connectivity to this + site. + + + + )} + /> + +
+
+ +
+ +
+
+
+ + You will only be able to see the configuration once. + +
+ +
+ + +
+ + +
+ ); +} diff --git a/src/app/[orgId]/settings/clients/CreateClientsModal.tsx b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx new file mode 100644 index 00000000..450e655f --- /dev/null +++ b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import CreateClientForm from "./CreateClientsForm"; +import { ClientRow } from "./ClientsTable"; + +type CreateClientFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + onCreate?: (client: ClientRow) => void; + orgId: string; +}; + +export default function CreateClientFormModal({ + open, + setOpen, + onCreate, + orgId +}: CreateClientFormProps) { + const [loading, setLoading] = useState(false); + const [isChecked, setIsChecked] = useState(false); + + return ( + <> + { + setOpen(val); + setLoading(false); + }} + > + + + Create Client + + Create a new client to connect to your sites + + + +
+ setLoading(val)} + setChecked={(val) => setIsChecked(val)} + onCreate={onCreate} + orgId={orgId} + /> +
+
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx new file mode 100644 index 00000000..145d99a7 --- /dev/null +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -0,0 +1,57 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { ClientRow } from "./ClientsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListClientsResponse } from "@server/routers/client"; +import ClientsTable from "./ClientsTable"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const params = await props.params; + let clients: ListClientsResponse["clients"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/clients`, + await authCookieHeader() + ); + clients = res.data.data.clients; + } catch (e) {} + + function formatSize(mb: number): string { + if (mb >= 1024 * 1024) { + return `${(mb / (1024 * 1024)).toFixed(2)} TB`; + } else if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB`; + } else { + return `${mb.toFixed(2)} MB`; + } + } + + const clientRows: ClientRow[] = clients.map((client) => { + return { + name: client.name, + id: client.clientId, + mbIn: formatSize(client.megabytesIn || 0), + mbOut: formatSize(client.megabytesOut || 0), + orgId: params.orgId, + online: client.online + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index b0b561a2..79727e91 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,6 +1,6 @@ import { Metadata } from "next"; import { TopbarNav } from "@app/components/TopbarNav"; -import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react"; +import { Cog, Combine, Laptop, Link, Settings, Users, Waypoints, Workflow } from "lucide-react"; import { Header } from "@app/components/Header"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; @@ -30,6 +30,11 @@ const topNavItems = [ href: "/{orgId}/settings/resources", icon: }, + { + title: "Clients", + href: "/{orgId}/settings/clients", + icon: + }, { title: "Users & Roles", href: "/{orgId}/settings/access",