diff --git a/messages/en-US.json b/messages/en-US.json index ed004d99..df2d9799 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1280,5 +1280,30 @@ "termsOfService": "terms of service", "and": "and", "privacyPolicy": "privacy policy" - } -} + }, + "siteRequired": "Site is required.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "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", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "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." +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/app/[orgId]/settings/clients/ClientsTable.tsx index 35ded645..90f04ca8 100644 --- a/src/app/[orgId]/settings/clients/ClientsTable.tsx +++ b/src/app/[orgId]/settings/clients/ClientsTable.tsx @@ -76,42 +76,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { }; 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 }) => { @@ -243,6 +207,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { const clientRow = row.original; return (
+ + + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + Delete + + + @@ -309,7 +300,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { columns={columns} data={rows} addClient={() => { - setIsCreateModalOpen(true); + router.push(`/${orgId}/settings/clients/create`) }} /> diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx new file mode 100644 index 00000000..850504f5 --- /dev/null +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -0,0 +1,711 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { createElement, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon, Terminal } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { + FaApple, + FaCubes, + FaDocker, + FaFreebsd, + FaWindows +} from "react-icons/fa"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + CreateClientBody, + CreateClientResponse, + PickClientDefaultsResponse +} from "@server/routers/client"; +import { ListSitesResponse } from "@server/routers/site"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; + +import { useTranslations } from "next-intl"; + +type ClientType = "olm"; + +interface TunnelTypeOption { + id: ClientType; + title: string; + description: string; + disabled?: boolean; +} + +type Commands = { + mac: Record; + linux: Record; + windows: Record; +}; + +const platforms = ["linux", "mac", "windows"] as const; + +type Platform = (typeof platforms)[number]; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + + const createClientFormSchema = z.object({ + name: z + .string() + .min(2, { message: t("nameMin", { len: 2 }) }) + .max(30, { message: t("nameMax", { len: 30 }) }), + method: z.enum(["olm"]), + siteIds: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .refine((val) => val.length > 0, { + message: t("siteRequired") + }), + subnet: z.string().min(1, { + message: t("subnetRequired") + }) + }); + + type CreateClientFormValues = z.infer; + + const [tunnelTypes, setTunnelTypes] = useState< + ReadonlyArray + >([ + { + id: "olm", + title: t("olmTunnel"), + description: t("olmTunnelDescription"), + disabled: true + } + ]); + + const [loadingPage, setLoadingPage] = useState(true); + const [sites, setSites] = useState([]); + const [activeSitesTagIndex, setActiveSitesTagIndex] = useState< + number | null + >(null); + + const [platform, setPlatform] = useState("linux"); + const [architecture, setArchitecture] = useState("amd64"); + const [commands, setCommands] = useState(null); + + const [olmId, setOlmId] = useState(""); + const [olmSecret, setOlmSecret] = useState(""); + const [olmCommand, setOlmCommand] = useState(""); + + const [createLoading, setCreateLoading] = useState(false); + + const [clientDefaults, setClientDefaults] = + useState(null); + + const hydrateCommands = ( + id: string, + secret: string, + endpoint: string, + version: string + ) => { + const commands = { + mac: { + "Apple Silicon (arm64)": [ + `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + "Intel x64 (amd64)": [ + `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + }, + linux: { + amd64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm32: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm32v6: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + riscv64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + }, + windows: { + x64: [ + `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_amd64.exe"`, + `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + } + }; + setCommands(commands); + }; + + const getArchitectures = () => { + switch (platform) { + case "linux": + return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"]; + case "mac": + return ["Apple Silicon (arm64)", "Intel x64 (amd64)"]; + case "windows": + return ["x64"]; + default: + return ["x64"]; + } + }; + + const getPlatformName = (platformName: string) => { + switch (platformName) { + case "windows": + return "Windows"; + case "mac": + return "macOS"; + case "docker": + return "Docker"; + default: + return "Linux"; + } + }; + + const getCommand = () => { + const placeholder = [t("unknownCommand")]; + if (!commands) { + return placeholder; + } + let platformCommands = commands[platform as keyof Commands]; + + if (!platformCommands) { + // get first key + const firstPlatform = Object.keys(commands)[0] as Platform; + platformCommands = commands[firstPlatform as keyof Commands]; + + setPlatform(firstPlatform); + } + + let architectureCommands = platformCommands[architecture]; + if (!architectureCommands) { + // get first key + const firstArchitecture = Object.keys(platformCommands)[0]; + architectureCommands = platformCommands[firstArchitecture]; + + setArchitecture(firstArchitecture); + } + + return architectureCommands || placeholder; + }; + + const getPlatformIcon = (platformName: string) => { + switch (platformName) { + case "windows": + return ; + case "mac": + return ; + case "docker": + return ; + case "podman": + return ; + case "freebsd": + return ; + default: + return ; + } + }; + + const form = useForm({ + resolver: zodResolver(createClientFormSchema), + defaultValues: { + name: "", + method: "olm", + siteIds: [], + subnet: "" + } + }); + + async function onSubmit(data: CreateClientFormValues) { + setCreateLoading(true); + + if (!clientDefaults) { + toast({ + variant: "destructive", + title: t("errorCreatingClient"), + description: t("clientDefaultsNotFound") + }); + setCreateLoading(false); + return; + } + + let payload: CreateClientBody = { + name: data.name, + type: data.method as "olm", + siteIds: data.siteIds.map((site) => parseInt(site.id)), + olmId: clientDefaults.olmId, + secret: clientDefaults.olmSecret, + subnet: data.subnet + }; + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/client`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("errorCreatingClient"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + router.push(`/${orgId}/settings/clients/${data.clientId}`); + } + + setCreateLoading(false); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(true); + + // Fetch available sites + + const res = await api.get>( + `/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 + })) + ); + + let olmVersion = "latest"; + + try { + const response = await fetch( + `https://api.github.com/repos/fosrl/olm/releases/latest` + ); + if (!response.ok) { + throw new Error( + t("olmErrorFetchReleases", { + err: response.statusText + }) + ); + } + const data = await response.json(); + const latestVersion = data.tag_name; + olmVersion = latestVersion; + } catch (error) { + console.error( + t("olmErrorFetchLatest", { + err: + error instanceof Error + ? error.message + : String(error) + }) + ); + } + + await api + .get(`/org/${orgId}/pick-client-defaults`) + .catch((e) => { + form.setValue("method", "olm"); + }) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + + setClientDefaults(data); + + const olmId = data.olmId; + const olmSecret = data.olmSecret; + const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`; + + setOlmId(olmId); + setOlmSecret(olmSecret); + setOlmCommand(olmCommand); + + hydrateCommands( + olmId, + olmSecret, + env.app.dashboardUrl, + olmVersion + ); + + if (data.subnet) { + form.setValue("subnet", data.subnet); + } + + setTunnelTypes((prev: any) => { + return prev.map((item: any) => { + return { ...item, disabled: false }; + }); + }); + } + }); + + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + + + + {t("clientInformation")} + + + + +
+ + ( + + + {t("name")} + + + + + + + {t("clientNameDescription")} + + + )} + /> + + ( + + + {t("address")} + + + + + + + {t("addressDescription")} + + + )} + /> + + ( + + + {t("sites")} + + { + form.setValue( + "siteIds", + olmags as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + sites + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + {t("sitesDescription")} + + + + )} + /> + + +
+
+
+ + {form.watch("method") === "olm" && ( + <> + + + + {t("clientOlmCredentials")} + + + {t("clientOlmCredentialsDescription")} + + + + + + + {t("olmEndpoint")} + + + + + + + + {t("olmId")} + + + + + + + + {t("olmSecretKey")} + + + + + + + + + + + {t("clientCredentialsSave")} + + + {t( + "clientCredentialsSaveDescription" + )} + + + + + + + + {t("clientInstallOlm")} + + + {t("clientInstallOlmDescription")} + + + +
+

+ {t("operatingSystem")} +

+
+ {platforms.map((os) => ( + + ))} +
+
+ +
+

+ {["docker", "podman"].includes( + platform + ) + ? t("method") + : t("architecture")} +

+
+ {getArchitectures().map( + (arch) => ( + + ) + )} +
+
+

+ {t("commands")} +

+
+ +
+
+
+
+
+ + )} +
+ +
+ + +
+
+ )} + + ); +} \ No newline at end of file