diff --git a/messages/en-US.json b/messages/en-US.json index 895ee1332..5cb3721ef 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -323,6 +323,25 @@ "apiKeysDelete": "Delete API Key", "apiKeysManage": "Manage API Keys", "apiKeysDescription": "API keys are used to authenticate with the integration API", + "provisioningKeysTitle": "Provisioning Key", + "provisioningKeysManage": "Manage Provisioning Keys", + "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", + "provisioningKeys": "Provisioning Keys", + "searchProvisioningKeys": "Search provisioning keys...", + "provisioningKeysAdd": "Generate Provisioning Key", + "provisioningKeysErrorDelete": "Error deleting provisioning key", + "provisioningKeysErrorDeleteMessage": "Error deleting provisioning key", + "provisioningKeysQuestionRemove": "Are you sure you want to remove this provisioning key from the organization?", + "provisioningKeysMessageRemove": "Once removed, the key can no longer be used for site provisioning.", + "provisioningKeysDeleteConfirm": "Confirm Delete Provisioning Key", + "provisioningKeysDelete": "Delete Provisioning key", + "provisioningKeysCreate": "Generate Provisioning Key", + "provisioningKeysCreateDescription": "Generate a new provisioning key for the organization", + "provisioningKeysSeeAll": "See all provisioning keys", + "provisioningKeysSave": "Save the provisioning key", + "provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.", + "provisioningKeysErrorCreate": "Error creating provisioning key", + "provisioningKeysList": "New provisioning key", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", @@ -1266,6 +1285,7 @@ "sidebarRoles": "Roles", "sidebarShareableLinks": "Links", "sidebarApiKeys": "API Keys", + "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", diff --git a/src/app/[orgId]/settings/provisioning/create/page.tsx b/src/app/[orgId]/settings/provisioning/create/page.tsx new file mode 100644 index 000000000..98573147a --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/create/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +type PageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function ProvisioningCreateRedirect(props: PageProps) { + const params = await props.params; + redirect(`/${params.orgId}/settings/provisioning`); +} diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx new file mode 100644 index 000000000..f8a30b86f --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -0,0 +1,50 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import SiteProvisioningKeysTable, { + SiteProvisioningKeyRow +} from "../../../../components/SiteProvisioningKeysTable"; +import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/listSiteProvisioningKeys"; +import { getTranslations } from "next-intl/server"; + +type ProvisioningPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ProvisioningPage(props: ProvisioningPageProps) { + const params = await props.params; + const t = await getTranslations(); + + let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = + []; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/site-provisioning-keys`, + await authCookieHeader() + ); + siteProvisioningKeys = res.data.data.siteProvisioningKeys; + } catch (e) {} + + const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ + name: k.name, + id: k.siteProvisioningKeyId, + key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, + createdAt: k.createdAt + })); + + return ( + <> + + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 0066721db..c90b211a3 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { + Boxes, Building2, ChartLine, Combine, @@ -203,6 +204,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/api-keys", icon: }, + { + title: "sidebarProvisioning", + href: "/{orgId}/settings/provisioning", + icon: + }, { title: "sidebarBluePrints", href: "/{orgId}/settings/blueprints", diff --git a/src/components/CreateSiteProvisioningKeyCredenza.tsx b/src/components/CreateSiteProvisioningKeyCredenza.tsx new file mode 100644 index 000000000..456731ed6 --- /dev/null +++ b/src/components/CreateSiteProvisioningKeyCredenza.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/createSiteProvisioningKey"; +import { AxiosResponse } from "axios"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import CopyTextBox from "@app/components/CopyTextBox"; + +const FORM_ID = "create-site-provisioning-key-form"; + +type CreateSiteProvisioningKeyCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; +}; + +export default function CreateSiteProvisioningKeyCredenza({ + open, + setOpen, + orgId +}: CreateSiteProvisioningKeyCredenzaProps) { + const t = useTranslations(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const [created, setCreated] = + useState(null); + + const createFormSchema = z.object({ + name: z + .string() + .min(1, { + message: t("nameMin", { len: 1 }) + }) + .max(255, { + message: t("nameMax", { len: 255 }) + }) + }); + + type CreateFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + useEffect(() => { + if (!open) { + setCreated(null); + form.reset({ name: "" }); + } + }, [open, form]); + + async function onSubmit(data: CreateFormValues) { + setLoading(true); + try { + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/site-provisioning-key`, { name: data.name }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("provisioningKeysErrorCreate"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + setCreated(res.data.data); + router.refresh(); + } + } finally { + setLoading(false); + } + } + + const credential = + created && + `${created.siteProvisioningKeyId}.${created.siteProvisioningKey}`; + + return ( + + + + + {created + ? t("provisioningKeysList") + : t("provisioningKeysCreate")} + + {!created && ( + + {t("provisioningKeysCreateDescription")} + + )} + + + {!created && ( +
+ + ( + + {t("name")} + + + + + + )} + /> + + + )} + + {created && credential && ( +
+ + + + {t("provisioningKeysSave")} + + + {t("provisioningKeysSaveDescription")} + + + +
+ )} +
+ + {!created ? ( + <> + + + + + + ) : ( + + + + )} + +
+
+ ); +} diff --git a/src/components/SiteProvisioningKeysTable.tsx b/src/components/SiteProvisioningKeysTable.tsx new file mode 100644 index 000000000..3fb3eb872 --- /dev/null +++ b/src/components/SiteProvisioningKeysTable.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { + DataTable, + ExtendedColumnDef +} from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import CreateSiteProvisioningKeyCredenza from "@app/components/CreateSiteProvisioningKeyCredenza"; +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 moment from "moment"; +import { useTranslations } from "next-intl"; + +export type SiteProvisioningKeyRow = { + id: string; + key: string; + name: string; + createdAt: string; +}; + +type SiteProvisioningKeysTableProps = { + keys: SiteProvisioningKeyRow[]; + orgId: string; +}; + +export default function SiteProvisioningKeysTable({ + keys, + orgId +}: SiteProvisioningKeysTableProps) { + const router = useRouter(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState( + null + ); + const [rows, setRows] = useState(keys); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const [isRefreshing, setIsRefreshing] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + + useEffect(() => { + setRows(keys); + }, [keys]); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const deleteKey = async (siteProvisioningKeyId: string) => { + try { + await api.delete( + `/org/${orgId}/site-provisioning-key/${siteProvisioningKeyId}` + ); + router.refresh(); + setIsDeleteModalOpen(false); + setSelected(null); + setRows((prev) => prev.filter((row) => row.id !== siteProvisioningKeyId)); + } catch (e) { + console.error(t("provisioningKeysErrorDelete"), e); + toast({ + variant: "destructive", + title: t("provisioningKeysErrorDelete"), + description: formatAxiosError( + e, + t("provisioningKeysErrorDeleteMessage") + ) + }); + throw e; + } + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + friendlyName: t("key"), + header: () => {t("key")}, + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + friendlyName: t("createdAt"), + header: () => {t("createdAt")}, + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")}; + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + +
+ ); + } + } + ]; + + return ( + <> + + + {selected && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelected(null); + } + }} + dialog={ +
+

{t("provisioningKeysQuestionRemove")}

+

{t("provisioningKeysMessageRemove")}

+
+ } + buttonText={t("provisioningKeysDeleteConfirm")} + onConfirm={async () => deleteKey(selected.id)} + string={selected.name} + title={t("provisioningKeysDelete")} + /> + )} + + setCreateOpen(true)} + onRefresh={refreshData} + isRefreshing={isRefreshing} + addButtonText={t("provisioningKeysAdd")} + enableColumnVisibility={true} + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +}