[] = [
+ {
+ accessorKey: "startedAt",
+ header: ({ column }) => {
+ return t("timestamp");
+ },
+ cell: ({ row }) => {
+ return (
+
+ {new Date(
+ row.original.startedAt * 1000
+ ).toLocaleString()}
+
+ );
+ }
+ },
+ {
+ accessorKey: "protocol",
+ header: ({ column }) => {
+ return (
+
+ {t("protocol")}
+ ({
+ label: protocol.toUpperCase(),
+ value: protocol
+ })
+ )}
+ selectedValue={filters.protocol}
+ onValueChange={(value) =>
+ handleFilterChange("protocol", value)
+ }
+ searchPlaceholder="Search..."
+ emptyMessage="None found"
+ />
+
+ );
+ },
+ cell: ({ row }) => {
+ return (
+
+ {row.original.protocol?.toUpperCase()}
+
+ );
+ }
+ },
+ {
+ accessorKey: "resourceName",
+ header: ({ column }) => {
+ return (
+
+ {t("resource")}
+ ({
+ value: res.id.toString(),
+ label: res.name || "Unnamed Resource"
+ }))}
+ selectedValue={filters.siteResourceId}
+ onValueChange={(value) =>
+ handleFilterChange("siteResourceId", value)
+ }
+ searchPlaceholder="Search..."
+ emptyMessage="None found"
+ />
+
+ );
+ },
+ cell: ({ row }) => {
+ if (row.original.resourceName && row.original.resourceNiceId) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {row.original.resourceName ?? "—"}
+
+ );
+ }
+ },
+ {
+ accessorKey: "clientName",
+ header: ({ column }) => {
+ return (
+
+ {t("client")}
+ ({
+ value: c.id.toString(),
+ label: c.name
+ }))}
+ selectedValue={filters.clientId}
+ onValueChange={(value) =>
+ handleFilterChange("clientId", value)
+ }
+ searchPlaceholder="Search..."
+ emptyMessage="None found"
+ />
+
+ );
+ },
+ cell: ({ row }) => {
+ const clientType = row.original.clientType === "olm" ? "machine" : "user";
+ if (row.original.clientName && row.original.clientNiceId) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {row.original.clientName ?? "—"}
+
+ );
+ }
+ },
+ {
+ accessorKey: "userEmail",
+ header: ({ column }) => {
+ return (
+
+ {t("user")}
+ ({
+ value: u.id,
+ label: u.email || u.id
+ }))}
+ selectedValue={filters.userId}
+ onValueChange={(value) =>
+ handleFilterChange("userId", value)
+ }
+ searchPlaceholder="Search..."
+ emptyMessage="None found"
+ />
+
+ );
+ },
+ cell: ({ row }) => {
+ if (row.original.userEmail || row.original.userId) {
+ return (
+
+
+ {row.original.userEmail ?? row.original.userId}
+
+ );
+ }
+ return —;
+ }
+ },
+ {
+ accessorKey: "sourceAddr",
+ header: ({ column }) => {
+ return t("sourceAddress");
+ },
+ cell: ({ row }) => {
+ return (
+
+ {row.original.sourceAddr}
+
+ );
+ }
+ },
+ {
+ accessorKey: "destAddr",
+ header: ({ column }) => {
+ return (
+
+ {t("destinationAddress")}
+ ({
+ value: addr,
+ label: addr
+ }))}
+ selectedValue={filters.destAddr}
+ onValueChange={(value) =>
+ handleFilterChange("destAddr", value)
+ }
+ searchPlaceholder="Search..."
+ emptyMessage="None found"
+ />
+
+ );
+ },
+ cell: ({ row }) => {
+ return (
+
+ {row.original.destAddr}
+
+ );
+ }
+ },
+ {
+ accessorKey: "duration",
+ header: ({ column }) => {
+ return t("duration");
+ },
+ cell: ({ row }) => {
+ return (
+
+ {formatDuration(
+ row.original.startedAt,
+ row.original.endedAt
+ )}
+
+ );
+ }
+ }
+ ];
+
+ const renderExpandedRow = (row: any) => {
+ return (
+
+
+
+ {/*
+ Connection Details
+
*/}
+
+ Session ID:{" "}
+
+ {row.sessionId ?? "—"}
+
+
+
+ Protocol:{" "}
+ {row.protocol?.toUpperCase() ?? "—"}
+
+
+ Source:{" "}
+
+ {row.sourceAddr ?? "—"}
+
+
+
+ Destination:{" "}
+
+ {row.destAddr ?? "—"}
+
+
+
+
+ {/*
+ Resource & Site
+
*/}
+ {/*
+ Resource:{" "}
+ {row.resourceName ?? "—"}
+ {row.resourceNiceId && (
+
+ ({row.resourceNiceId})
+
+ )}
+
*/}
+
+ Site: {row.siteName ?? "—"}
+ {row.siteNiceId && (
+
+ ({row.siteNiceId})
+
+ )}
+
+
+ Site ID: {row.siteId ?? "—"}
+
+
+ Started At:{" "}
+ {row.startedAt
+ ? new Date(
+ row.startedAt * 1000
+ ).toLocaleString()
+ : "—"}
+
+
+ Ended At:{" "}
+ {row.endedAt
+ ? new Date(
+ row.endedAt * 1000
+ ).toLocaleString()
+ : "Active"}
+
+
+ Duration:{" "}
+ {formatDuration(row.startedAt, row.endedAt)}
+
+ {/*
+ Resource ID:{" "}
+ {row.siteResourceId ?? "—"}
+
*/}
+
+
+ {/*
+ Client & Transfer
+
*/}
+ {/*
+ Bytes Sent (TX):{" "}
+ {formatBytes(row.bytesTx)}
+
*/}
+ {/*
+ Bytes Received (RX):{" "}
+ {formatBytes(row.bytesRx)}
+
*/}
+ {/*
+ Total Transfer:{" "}
+ {formatBytes(
+ (row.bytesTx ?? 0) + (row.bytesRx ?? 0)
+ )}
+
*/}
+
+
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+ startTransition(exportData)}
+ isExporting={isExporting}
+ onDateRangeChange={handleDateRangeChange}
+ dateRange={{
+ start: dateRange.startDate,
+ end: dateRange.endDate
+ }}
+ defaultSort={{
+ id: "startedAt",
+ desc: true
+ }}
+ // Server-side pagination props
+ totalCount={totalCount}
+ currentPage={currentPage}
+ pageSize={pageSize}
+ onPageChange={handlePageChange}
+ onPageSizeChange={handlePageSizeChange}
+ isLoading={isLoading}
+ // Row expansion props
+ expandable={true}
+ renderExpandedRow={renderExpandedRow}
+ disabled={
+ !isPaidUser(tierMatrix.connectionLogs) || build === "oss"
+ }
+ />
+ >
+ );
+}
diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx
new file mode 100644
index 000000000..e8b53104f
--- /dev/null
+++ b/src/app/[orgId]/settings/provisioning/page.tsx
@@ -0,0 +1,60 @@
+import { internal } from "@app/lib/api";
+import { authCookieHeader } from "@app/lib/api/cookies";
+import { AxiosResponse } from "axios";
+import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import SiteProvisioningKeysTable, {
+ SiteProvisioningKeyRow
+} from "../../../../components/SiteProvisioningKeysTable";
+import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
+import { getTranslations } from "next-intl/server";
+import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
+
+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,
+ lastUsed: k.lastUsed,
+ maxBatchSize: k.maxBatchSize,
+ numUsed: k.numUsed,
+ validUntil: k.validUntil
+ }));
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index 0066721db..66e6cdad0 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -2,7 +2,9 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
+ Boxes,
Building2,
+ Cable,
ChartLine,
Combine,
CreditCard,
@@ -189,6 +191,11 @@ export const orgNavSections = (
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon:
+ },
+ {
+ title: "sidebarLogsConnection",
+ href: "/{orgId}/settings/logs/connection",
+ icon:
}
]
: [])
@@ -203,6 +210,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..3a1c7c372
--- /dev/null
+++ b/src/components/CreateSiteProvisioningKeyCredenza.tsx
@@ -0,0 +1,398 @@
+"use client";
+
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Button } from "@app/components/ui/button";
+import { Checkbox } from "@app/components/ui/checkbox";
+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/types";
+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";
+import {
+ DateTimePicker,
+ DateTimeValue
+} from "@app/components/DateTimePicker";
+
+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 })
+ }),
+ unlimitedBatchSize: z.boolean(),
+ maxBatchSize: z
+ .number()
+ .int()
+ .min(1, { message: t("provisioningKeysMaxBatchSizeInvalid") })
+ .max(1_000_000, {
+ message: t("provisioningKeysMaxBatchSizeInvalid")
+ }),
+ validUntil: z.string().optional()
+ })
+ .superRefine((data, ctx) => {
+ const v = data.validUntil;
+ if (v == null || v.trim() === "") {
+ return;
+ }
+ if (Number.isNaN(Date.parse(v))) {
+ ctx.addIssue({
+ code: "custom",
+ message: t("provisioningKeysValidUntilInvalid"),
+ path: ["validUntil"]
+ });
+ }
+ });
+
+ type CreateFormValues = z.infer;
+
+ const form = useForm({
+ resolver: zodResolver(createFormSchema),
+ defaultValues: {
+ name: "",
+ unlimitedBatchSize: false,
+ maxBatchSize: 100,
+ validUntil: ""
+ }
+ });
+
+ useEffect(() => {
+ if (!open) {
+ setCreated(null);
+ form.reset({
+ name: "",
+ unlimitedBatchSize: false,
+ maxBatchSize: 100,
+ validUntil: ""
+ });
+ }
+ }, [open, form]);
+
+ async function onSubmit(data: CreateFormValues) {
+ setLoading(true);
+ try {
+ const res = await api
+ .put<
+ AxiosResponse
+ >(`/org/${orgId}/site-provisioning-key`, {
+ name: data.name,
+ maxBatchSize: data.unlimitedBatchSize
+ ? null
+ : data.maxBatchSize,
+ validUntil:
+ data.validUntil == null || data.validUntil.trim() === ""
+ ? undefined
+ : data.validUntil
+ })
+ .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.siteProvisioningKey;
+
+ const unlimitedBatchSize = form.watch("unlimitedBatchSize");
+
+ return (
+
+
+
+
+ {created
+ ? t("provisioningKeysList")
+ : t("provisioningKeysCreate")}
+
+ {!created && (
+
+ {t("provisioningKeysCreateDescription")}
+
+ )}
+
+
+ {!created && (
+
+
+ )}
+
+ {created && credential && (
+
+
+
+
+ {t("provisioningKeysSave")}
+
+
+ {t("provisioningKeysSaveDescription")}
+
+
+
+
+ )}
+
+
+ {!created ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/EditSiteProvisioningKeyCredenza.tsx b/src/components/EditSiteProvisioningKeyCredenza.tsx
new file mode 100644
index 000000000..138190edc
--- /dev/null
+++ b/src/components/EditSiteProvisioningKeyCredenza.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Button } from "@app/components/ui/button";
+import { Checkbox } from "@app/components/ui/checkbox";
+import { Input } from "@app/components/ui/input";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
+import { AxiosResponse } from "axios";
+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 {
+ DateTimePicker,
+ DateTimeValue
+} from "@app/components/DateTimePicker";
+
+const FORM_ID = "edit-site-provisioning-key-form";
+
+export type EditableSiteProvisioningKey = {
+ id: string;
+ name: string;
+ maxBatchSize: number | null;
+ validUntil: string | null;
+};
+
+type EditSiteProvisioningKeyCredenzaProps = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ orgId: string;
+ provisioningKey: EditableSiteProvisioningKey | null;
+};
+
+export default function EditSiteProvisioningKeyCredenza({
+ open,
+ setOpen,
+ orgId,
+ provisioningKey
+}: EditSiteProvisioningKeyCredenzaProps) {
+ const t = useTranslations();
+ const router = useRouter();
+ const api = createApiClient(useEnvContext());
+ const [loading, setLoading] = useState(false);
+
+ const editFormSchema = z
+ .object({
+ name: z.string(),
+ unlimitedBatchSize: z.boolean(),
+ maxBatchSize: z
+ .number()
+ .int()
+ .min(1, { message: t("provisioningKeysMaxBatchSizeInvalid") })
+ .max(1_000_000, {
+ message: t("provisioningKeysMaxBatchSizeInvalid")
+ }),
+ validUntil: z.string().optional()
+ })
+ .superRefine((data, ctx) => {
+ const v = data.validUntil;
+ if (v == null || v.trim() === "") {
+ return;
+ }
+ if (Number.isNaN(Date.parse(v))) {
+ ctx.addIssue({
+ code: "custom",
+ message: t("provisioningKeysValidUntilInvalid"),
+ path: ["validUntil"]
+ });
+ }
+ });
+
+ type EditFormValues = z.infer;
+
+ const form = useForm({
+ resolver: zodResolver(editFormSchema),
+ defaultValues: {
+ name: "",
+ unlimitedBatchSize: false,
+ maxBatchSize: 100,
+ validUntil: ""
+ }
+ });
+
+ useEffect(() => {
+ if (!open || !provisioningKey) {
+ return;
+ }
+ form.reset({
+ name: provisioningKey.name,
+ unlimitedBatchSize: provisioningKey.maxBatchSize == null,
+ maxBatchSize: provisioningKey.maxBatchSize ?? 100,
+ validUntil: provisioningKey.validUntil ?? ""
+ });
+ }, [open, provisioningKey, form]);
+
+ async function onSubmit(data: EditFormValues) {
+ if (!provisioningKey) {
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await api
+ .patch<
+ AxiosResponse
+ >(
+ `/org/${orgId}/site-provisioning-key/${provisioningKey.id}`,
+ {
+ maxBatchSize: data.unlimitedBatchSize
+ ? null
+ : data.maxBatchSize,
+ validUntil:
+ data.validUntil == null ||
+ data.validUntil.trim() === ""
+ ? ""
+ : data.validUntil
+ }
+ )
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: t("provisioningKeysUpdateError"),
+ description: formatAxiosError(e)
+ });
+ });
+
+ if (res && res.status === 200) {
+ toast({
+ title: t("provisioningKeysUpdated"),
+ description: t("provisioningKeysUpdatedDescription")
+ });
+ setOpen(false);
+ router.refresh();
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const unlimitedBatchSize = form.watch("unlimitedBatchSize");
+
+ if (!provisioningKey) {
+ return null;
+ }
+
+ return (
+
+
+
+ {t("provisioningKeysEdit")}
+
+ {t("provisioningKeysEditDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx
index e1c883a2b..854cad6db 100644
--- a/src/components/LayoutMobileMenu.tsx
+++ b/src/components/LayoutMobileMenu.tsx
@@ -93,7 +93,7 @@ export function LayoutMobileMenu({
)
}
>
-
+
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx
index e9e2d61eb..1cd2131f7 100644
--- a/src/components/LayoutSidebar.tsx
+++ b/src/components/LayoutSidebar.tsx
@@ -169,8 +169,8 @@ export function LayoutSidebar({
>
@@ -222,36 +222,34 @@ export function LayoutSidebar({
)}
-
-
-
+
{canShowProductUpdates && (
-
+
)}
{build === "enterprise" && (
-
+
)}
{build === "oss" && (
-
+
)}
{build === "saas" && (
-
+
)}
{!isSidebarCollapsed && (
-
+
{loadFooterLinks() ? (
<>
{loadFooterLinks()!.map((link, index) => (
diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx
index 01689d9d7..76ab0252d 100644
--- a/src/components/ProductUpdates.tsx
+++ b/src/components/ProductUpdates.tsx
@@ -192,13 +192,13 @@ function ProductUpdatesListPopup({
-
+
{t("productUpdateWhatsNew")}
@@ -346,13 +346,13 @@ function NewVersionAvailable({
rel="noopener noreferrer"
className={cn(
"relative z-2 group cursor-pointer block",
- "rounded-md border border-primary/30 bg-linear-to-br dark:from-primary/20 from-primary/20 via-background to-background p-2 py-3 w-full flex flex-col gap-2 text-sm",
+ "rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full"
)}
>
-
+
{t("pangolinUpdateAvailable")}
diff --git a/src/components/SiteProvisioningKeysTable.tsx b/src/components/SiteProvisioningKeysTable.tsx
new file mode 100644
index 000000000..df7fd241c
--- /dev/null
+++ b/src/components/SiteProvisioningKeysTable.tsx
@@ -0,0 +1,320 @@
+"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 EditSiteProvisioningKeyCredenza from "@app/components/EditSiteProvisioningKeyCredenza";
+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 { usePaidStatus } from "@app/hooks/usePaidStatus";
+import moment from "moment";
+import { useTranslations } from "next-intl";
+import { build } from "@server/build";
+import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
+
+export type SiteProvisioningKeyRow = {
+ id: string;
+ key: string;
+ name: string;
+ createdAt: string;
+ lastUsed: string | null;
+ maxBatchSize: number | null;
+ numUsed: number;
+ validUntil: string | null;
+};
+
+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 { isPaidUser } = usePaidStatus();
+ const canUseSiteProvisioning =
+ isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) &&
+ build !== "oss";
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const [editingKey, setEditingKey] =
+ useState(null);
+
+ 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: "maxBatchSize",
+ friendlyName: t("provisioningKeysMaxBatchSize"),
+ header: () => (
+ {t("provisioningKeysMaxBatchSize")}
+ ),
+ cell: ({ row }) => {
+ const r = row.original;
+ return (
+
+ {r.maxBatchSize == null
+ ? t("provisioningKeysMaxBatchUnlimited")
+ : r.maxBatchSize}
+
+ );
+ }
+ },
+ {
+ accessorKey: "numUsed",
+ friendlyName: t("provisioningKeysNumUsed"),
+ header: () => (
+ {t("provisioningKeysNumUsed")}
+ ),
+ cell: ({ row }) => {
+ const r = row.original;
+ return {r.numUsed};
+ }
+ },
+ {
+ accessorKey: "validUntil",
+ friendlyName: t("provisioningKeysValidUntil"),
+ header: () => (
+ {t("provisioningKeysValidUntil")}
+ ),
+ cell: ({ row }) => {
+ const r = row.original;
+ return (
+
+ {r.validUntil
+ ? moment(r.validUntil).format("lll")
+ : t("provisioningKeysNoExpiry")}
+
+ );
+ }
+ },
+ {
+ accessorKey: "lastUsed",
+ friendlyName: t("provisioningKeysLastUsed"),
+ header: () => (
+ {t("provisioningKeysLastUsed")}
+ ),
+ cell: ({ row }) => {
+ const r = row.original;
+ return (
+
+ {r.lastUsed
+ ? moment(r.lastUsed).format("lll")
+ : t("provisioningKeysNeverUsed")}
+
+ );
+ }
+ },
+ {
+ 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 (
+
+
+
+
+
+
+ {
+ setEditingKey(r);
+ setEditOpen(true);
+ }}
+ >
+ {t("edit")}
+
+ {
+ setSelected(r);
+ setIsDeleteModalOpen(true);
+ }}
+ >
+
+ {t("delete")}
+
+
+
+
+
+ );
+ }
+ }
+ ];
+
+ return (
+ <>
+
+
+ {
+ setEditOpen(v);
+ if (!v) {
+ setEditingKey(null);
+ }
+ }}
+ orgId={orgId}
+ provisioningKey={editingKey}
+ />
+
+ {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")}
+ />
+ )}
+
+ {
+ if (canUseSiteProvisioning) {
+ setCreateOpen(true);
+ }
+ }}
+ addButtonDisabled={!canUseSiteProvisioning}
+ onRefresh={refreshData}
+ isRefreshing={isRefreshing}
+ addButtonText={t("provisioningKeysAdd")}
+ enableColumnVisibility={true}
+ stickyLeftColumn="name"
+ stickyRightColumn="actions"
+ />
+ >
+ );
+}
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx
index 834c56e88..a0c11ffdf 100644
--- a/src/components/ui/data-table.tsx
+++ b/src/components/ui/data-table.tsx
@@ -171,6 +171,7 @@ type DataTableProps = {
title?: string;
addButtonText?: string;
onAdd?: () => void;
+ addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
searchPlaceholder?: string;
@@ -203,6 +204,7 @@ export function DataTable({
title,
addButtonText,
onAdd,
+ addButtonDisabled = false,
onRefresh,
isRefreshing,
searchPlaceholder = "Search...",
@@ -635,7 +637,7 @@ export function DataTable({
)}
{onAdd && addButtonText && (