Edit client page done

This commit is contained in:
Owen 2025-04-18 15:32:20 -04:00
parent 581fdd67b1
commit dc49027b30
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
20 changed files with 491 additions and 26 deletions

View file

@ -66,6 +66,7 @@ export enum ActionsEnum {
deleteClient = "deleteClient",
updateClient = "updateClient",
listClients = "listClients",
getClient = "getClient",
listOrgDomains = "listOrgDomains",
}

View file

@ -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<Awaited<ReturnType<typeof query>>>;
export async function getClient(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
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<GetClientResponse>(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")
);
}
}

View file

@ -2,4 +2,5 @@ export * from "./pickClientDefaults";
export * from "./createClient";
export * from "./deleteClient";
export * from "./listClients";
export * from "./updateClient";
export * from "./updateClient";
export * from "./getClient";

View file

@ -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,

View file

@ -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))

View file

@ -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) {
</Button>
);
}
},
{
accessorKey: "subnet",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const clientRow = row.original;
return (
<div className="flex items-center justify-end">
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
// {
// id: "actions",
// cell: ({ row }) => {
// const siteRow = row.original;
// return (
// <div className="flex items-center justify-end">
// <Link
// href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
// >
// <Button variant={"outline"} className="ml-2">
// Edit
// <ArrowRight className="ml-2 w-4 h-4" />
// </Button>
// </Link>
// </div>
// );
// }
// }
];
return (
@ -281,7 +299,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteSite(selectedClient!.id)}
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>

View file

@ -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,

View file

@ -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 (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Client Information</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionContent>
{client.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionContent>
{client.subnet.split("/")[0]}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View file

@ -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<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const router = useRouter();
const form = useForm<GeneralFormValues>({
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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this client
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
client.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -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<AxiosResponse<GetClientResponse>>(
`/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 (
<>
<div className="mb-4 flex-row">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="../">Clients</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{client.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<SettingsSectionTitle
title={`${client?.name} Settings`}
description="Configure the settings on your site"
/>
<ClientProvider client={client}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<ClientInfoCard />
{children}
</SidebarSettings>
</ClientProvider>
</>
);
}

View file

@ -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`);
}

View file

@ -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,

View file

@ -206,7 +206,6 @@ export default function GeneralPage() {
)}
/>
{/* New FormField for subnet input */}
<FormField
control={form.control}
name="subnet"
@ -215,8 +214,8 @@ export default function GeneralPage() {
<FormLabel>Subnet</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g., 192.168.1.0/24"
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />

View file

@ -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"

View file

@ -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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {

View file

@ -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,

View file

@ -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<string>(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 (

View file

@ -0,0 +1,11 @@
import { GetClientResponse } from "@server/routers/client/getClient";
import { createContext } from "react";
interface ClientContextType {
client: GetClientResponse;
updateClient: (updatedClient: Partial<GetClientResponse>) => void;
}
const ClientContext = createContext<ClientContextType | undefined>(undefined);
export default ClientContext;

View file

@ -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;
}

View file

@ -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<GetClientResponse>(serverClient);
const updateClient = (updatedClient: Partial<GetClientResponse>) => {
if (!client) {
throw new Error("No client to update");
}
setClient((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updatedClient
};
});
};
return (
<ClientContext.Provider value={{ client, updateClient }}>
{children}
</ClientContext.Provider>
);
}
export default ClientProvider;