Clients ui done

This commit is contained in:
Owen
2025-07-24 21:42:44 -07:00
parent 760fe3aca9
commit 1466788f77
8 changed files with 31 additions and 474 deletions

View File

@@ -59,7 +59,6 @@
"siteErrorCreate": "Error creating site",
"siteErrorCreateKeyPair": "Key pair or site defaults not found",
"siteErrorCreateDefaults": "Site defaults not found",
"siteNameDescription": "This is the display name for the site.",
"method": "Method",
"siteMethodDescription": "This is how you will expose connections.",
"siteLearnNewt": "Learn how to install Newt on your system",
@@ -1291,7 +1290,6 @@
"seeAllClients": "See All Clients",
"clientInformation": "Client Information",
"clientNamePlaceholder": "Client name",
"clientNameDescription": "A friendly name for this client",
"address": "Address",
"subnetPlaceholder": "Subnet",
"addressDescription": "The address that this client will use for connectivity",
@@ -1305,5 +1303,14 @@
"olmId": "Olm ID",
"olmSecretKey": "Olm Secret Key",
"clientCredentialsSave": "Save Your Credentials",
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place."
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"generalSettingsDescription": "Configure the general settings for this client",
"clientUpdated": "Client updated",
"clientUpdatedDescription": "The client has been updated.",
"clientUpdateFailed": "Failed to update client",
"clientUpdateError": "An error occurred while updating the client.",
"sitesFetchFailed": "Failed to fetch sites",
"sitesFetchError": "An error occurred while fetching sites.",
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release."
}

View File

@@ -25,7 +25,6 @@ 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;
@@ -250,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
return (
<>
<CreateClientFormModal
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreate={(val) => {
setRows([val, ...rows]);
}}
orgId={orgId}
/>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}

View File

@@ -1,349 +0,0 @@
"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 { ClientRow } from "./ClientsTable";
import {
CreateClientBody,
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";
import { ScrollArea } from "@app/components/ui/scroll-area";
import { Badge } from "@app/components/ui/badge";
import { X } from "lucide-react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
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."
}),
siteIds: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.refine((val) => val.length > 0, {
message: "At least one site is required."
}),
subnet: z.string().min(1, {
message: "Subnet is required."
})
});
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
const defaultValues: Partial<CreateClientFormValues> = {
name: "",
siteIds: [],
subnet: ""
};
type CreateClientFormProps = {
onCreate?: (client: ClientRow) => void;
setLoading?: (loading: boolean) => void;
setChecked?: (checked: boolean) => void;
orgId: string;
};
export default function CreateClientForm({
onCreate,
setLoading,
setChecked,
orgId
}: CreateClientFormProps) {
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const [sites, setSites] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const [olmCommand, setOlmCommand] = useState<string | null>(null);
const [selectedSites, setSelectedSites] = useState<
Array<{ id: number; name: string }>
>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
number | null
>(null);
const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked);
if (setChecked) {
setChecked(checked);
}
};
const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema),
defaultValues
});
useEffect(() => {
if (!open) return;
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setClientDefaults(null);
setSelectedSites([]);
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
const sites = res.data.data.sites.filter(
(s) => s.type === "newt" && s.subnet
);
setSites(
sites.map((site) => ({
id: site.siteId.toString(),
text: site.name
}))
);
};
const fetchDefaults = async () => {
api.get(`/org/${orgId}/pick-client-defaults`)
.catch((e) => {
toast({
variant: "destructive",
title: `Error fetching client defaults`,
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);
// Set the subnet value from client defaults
if (data?.subnet) {
form.setValue("subnet", data.subnet);
}
}
});
};
fetchSites();
fetchDefaults();
}, [open]);
async function onSubmit(data: CreateClientFormValues) {
setLoading?.(true);
setIsLoading(true);
if (!clientDefaults) {
toast({
variant: "destructive",
title: "Error creating client",
description: "Client defaults not found"
});
setLoading?.(false);
setIsLoading(false);
return;
}
const payload = {
name: data.name,
siteIds: data.siteIds.map((site) => parseInt(site.id)),
olmId: clientDefaults.olmId,
secret: clientDefaults.olmSecret,
subnet: data.subnet,
type: "olm"
} as CreateClientBody;
const res = await api
.put<
AxiosResponse<CreateClientResponse>
>(`/org/${orgId}/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,
subnet: data.subnet,
mbIn: "0 MB",
mbOut: "0 MB",
orgId: orgId as string,
online: false
});
}
setLoading?.(false);
setIsLoading(false);
}
return (
<div className="space-y-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Client name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Subnet"
{...field}
/>
</FormControl>
<FormDescription>
The address that this client will use for
connectivity.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>Sites</FormLabel>
<TagInput
{...field}
activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex}
placeholder="Select sites"
size="sm"
tags={form.getValues().siteIds}
setTags={(newTags) => {
form.setValue(
"siteIds",
newTags as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={sites}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
<FormDescription>
The client will have connectivity to the
selected sites. The sites must be configured
to accept client connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{olmCommand && (
<div className="w-full">
<div className="mb-2">
<div className="mx-auto">
<CopyTextBox
text={olmCommand}
wrapText={false}
/>
</div>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the configuration
once.
</span>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
</label>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -1,80 +0,0 @@
"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 (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Client</CredenzaTitle>
<CredenzaDescription>
Create a new client to connect to your sites
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="max-w-md">
<CreateClientForm
setLoading={(val) => setLoading(val)}
setChecked={(val) => setIsChecked(val)}
onCreate={onCreate}
orgId={orgId}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="create-client-form"
loading={loading}
disabled={loading || !isChecked}
onClick={() => {
setOpen(false);
}}
>
Create Client
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -9,38 +9,40 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type ClientInfoCardProps = {};
export default function SiteInfoCard({}: ClientInfoCardProps) {
const { client, updateClient } = useClientContext();
const t = useTranslations();
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Client Information</AlertTitle>
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionTitle>{t("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>
<span>{t("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>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{client.subnet.split("/")[0]}
</InfoSectionContent>

View File

@@ -34,6 +34,7 @@ import { useEffect, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { ListSitesResponse } from "@server/routers/site";
import { useTranslations } from "next-intl";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
@@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const t = useTranslations();
const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
@@ -119,18 +121,18 @@ export default function GeneralPage() {
updateClient({ name: data.name });
toast({
title: "Client updated",
description: "The client has been updated."
title: t("clientUpdated"),
description: t("clientUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: "Failed to update client",
title: t("clientUpdateFailed"),
description: formatAxiosError(
e,
"An error occurred while updating the client."
t("clientUpdateError")
)
});
} finally {
@@ -143,10 +145,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
{t("generalSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this client
{t("generalSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -163,15 +165,11 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
client.
</FormDescription>
</FormItem>
)}
/>
@@ -181,12 +179,12 @@ export default function GeneralPage() {
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>Sites</FormLabel>
<FormLabel>{t("sites")}</FormLabel>
<TagInput
{...field}
activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex}
placeholder="Select sites"
placeholder={t("selectSites")}
size="sm"
tags={form.getValues().siteIds}
setTags={(newTags) => {
@@ -202,9 +200,7 @@ export default function GeneralPage() {
sortTags={true}
/>
<FormDescription>
The client will have connectivity to the
selected sites. The sites must be configured
to accept client connections.
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -222,7 +218,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save Settings
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -100,7 +100,7 @@ export default function Page() {
.refine((val) => val.length > 0, {
message: t("siteRequired")
}),
subnet: z.string().min(1, {
subnet: z.string().ip().min(1, {
message: t("subnetRequired")
})
});
@@ -442,14 +442,10 @@ export default function Page() {
<FormControl>
<Input
autoComplete="off"
placeholder={t("clientNamePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("clientNameDescription")}
</FormDescription>
</FormItem>
)}
/>

View File

@@ -587,11 +587,6 @@ WantedBy=default.target`
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>