From 77cef554bef6f0aa6097530ee2ffbee2b5c98ed7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 14:20:47 -0700 Subject: [PATCH 1/3] Provisioning room basics done --- messages/en-US.json | 5 + server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/routers/newt/registerNewt.ts | 3 +- server/routers/site/createSite.ts | 9 +- server/routers/site/listSites.ts | 17 +- server/routers/site/updateSite.ts | 3 +- .../settings/provisioning/keys/page.tsx | 56 +++ .../[orgId]/settings/provisioning/layout.tsx | 38 ++ .../[orgId]/settings/provisioning/page.tsx | 56 +-- .../settings/provisioning/pending/page.tsx | 82 ++++ src/app/[orgId]/settings/sites/page.tsx | 1 + src/components/PendingSitesTable.tsx | 440 ++++++++++++++++++ 13 files changed, 654 insertions(+), 62 deletions(-) create mode 100644 src/app/[orgId]/settings/provisioning/keys/page.tsx create mode 100644 src/app/[orgId]/settings/provisioning/layout.tsx create mode 100644 src/app/[orgId]/settings/provisioning/pending/page.tsx create mode 100644 src/components/PendingSitesTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7a3fde1d4..ad64cb5e2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -331,6 +331,11 @@ "provisioningKeysTitle": "Provisioning Key", "provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", + "provisioningManage": "Provisioning", + "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", + "pendingSites": "Pending Sites", + "siteApproveSuccess": "Site approved successfully", + "siteApproveError": "Error approving site", "provisioningKeys": "Provisioning Keys", "searchProvisioningKeys": "Search provisioning keys...", "provisioningKeysAdd": "Generate Provisioning Key", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bb05ca358..a64aad2ef 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -100,7 +100,8 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + status: varchar("status").$type<"pending" | "accepted">() }); export const resources = pgTable("resources", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5d7c01377..52969d183 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -110,7 +110,8 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + status: text("status").$type<"pending" | "accepted">() }); export const resources = sqliteTable("resources", { diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 427ac173f..4923fb88e 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -196,7 +196,8 @@ export async function registerNewt( name: niceId, niceId, type: "newt", - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "pending" }) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 4edebb080..bf62e93cd 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -298,7 +298,8 @@ export async function createSite( niceId, address: updatedAddress || null, type, - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "accepted" }) .returning(); } else if (type == "wireguard") { @@ -355,7 +356,8 @@ export async function createSite( niceId, subnet, type, - pubKey: pubKey || null + pubKey: pubKey || null, + status: "accepted" }) .returning(); } else if (type == "local") { @@ -370,7 +372,8 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/32" + subnet: "0.0.0.0/32", + status: "accepted" }) .returning(); } else { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index a244c650c..1d7e99042 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -135,6 +135,15 @@ const listSitesSchema = z.object({ .openapi({ type: "boolean", description: "Filter by online status" + }), + status: z + .enum(["pending", "accepted"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "accepted"], + description: "Filter by site status" }) }); @@ -156,7 +165,8 @@ function querySitesBase() { exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, - remoteExitNodeId: remoteExitNodes.remoteExitNodeId + remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + status: sites.status }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) @@ -245,7 +255,7 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const { pageSize, page, query, sort_by, order, online } = + const { pageSize, page, query, sort_by, order, online, status } = parsedQuery.data; const accessibleSiteIds = accessibleSites.map((site) => site.siteId); @@ -273,6 +283,9 @@ export async function listSites( if (typeof online !== "undefined") { conditions.push(eq(sites.online, online)); } + if (typeof status !== "undefined") { + conditions.push(eq(sites.status, status)); + } const baseQuery = querySitesBase().where(and(...conditions)); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index ca0f76783..244adf7b8 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -19,7 +19,8 @@ const updateSiteBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + status: z.enum(["pending", "accepted"]).optional(), // remoteSubnets: z.string().optional() // subdomain: z // .string() diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx new file mode 100644 index 000000000..1cfbf5b05 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -0,0 +1,56 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +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 ProvisioningKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ProvisioningKeysPage( + props: ProvisioningKeysPageProps +) { + 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 ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/layout.tsx b/src/app/[orgId]/settings/provisioning/layout.tsx new file mode 100644 index 000000000..bd2da7812 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +interface ProvisioningLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +} + +export default async function ProvisioningLayout({ + children, + params +}: ProvisioningLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("provisioningKeys"), + href: `/${orgId}/settings/provisioning/keys` + }, + { + title: t("pendingSites"), + href: `/${orgId}/settings/provisioning/pending` + } + ]; + + return ( + <> + + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx index e8b53104f..51db66c2d 100644 --- a/src/app/[orgId]/settings/provisioning/page.tsx +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -1,60 +1,10 @@ -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"; +import { redirect } from "next/navigation"; 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 ( - <> - - - - - - - ); -} + redirect(`/${params.orgId}/settings/provisioning/keys`); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx new file mode 100644 index 000000000..78178cb14 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -0,0 +1,82 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListSitesResponse } from "@server/routers/site"; +import { AxiosResponse } from "axios"; +import { SiteRow } from "@app/components/SitesTable"; +import PendingSitesTable from "@app/components/PendingSitesTable"; +import { getTranslations } from "next-intl/server"; + +type PendingSitesPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +export default async function PendingSitesPage(props: PendingSitesPageProps) { + const params = await props.params; + + const incomingSearchParams = new URLSearchParams(await props.searchParams); + incomingSearchParams.set("status", "pending"); + + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${params.orgId}/sites?${incomingSearchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; + } catch (e) {} + + const t = await getTranslations(); + + function formatSize(mb: number, type: string): string { + if (type === "local") { + return "-"; + } + if (mb >= 1024 * 1024) { + return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) }); + } else if (mb >= 1024) { + return t("gigabytes", { count: (mb / 1024).toFixed(2) }); + } else { + return t("megabytes", { count: mb.toFixed(2) }); + } + } + + const siteRows: SiteRow[] = sites.map((site) => ({ + 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, + type: site.type as any, + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, + exitNodeName: site.exitNodeName || undefined, + exitNodeEndpoint: site.exitNodeEndpoint || undefined, + remoteExitNodeId: (site as any).remoteExitNodeId || undefined + })); + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 161c757f6..5839ba2be 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -18,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "accepted"); let sites: ListSitesResponse["sites"] = []; let pagination: ListSitesResponse["pagination"] = { diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx new file mode 100644 index 000000000..f9126a091 --- /dev/null +++ b/src/components/PendingSitesTable.tsx @@ -0,0 +1,440 @@ +"use client"; + +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { build } from "@server/build"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ArrowUpRight, + Check, + ChevronsUpDownIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; +import { SiteRow } from "./SitesTable"; + +type PendingSitesTableProps = { + sites: SiteRow[]; + pagination: PaginationState; + orgId: string; + rowCount: number; +}; + +export default function PendingSitesTable({ + sites, + orgId, + pagination, + rowCount +}: PendingSitesTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [isRefreshing, startTransition] = useTransition(); + const [approvingIds, setApprovingIds] = useState>(new Set()); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function refreshData() { + startTransition(async () => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + async function approveSite(siteId: number) { + setApprovingIds((prev) => new Set(prev).add(siteId)); + try { + await api.post(`/site/${siteId}`, { status: "accepted" }); + toast({ + title: t("success"), + description: t("siteApproveSuccess"), + variant: "default" + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteApproveError"), + description: formatAxiosError(e, t("siteApproveError")) + }); + } finally { + setApprovingIds((prev) => { + const next = new Set(prev); + next.delete(siteId); + return next; + }); + } + } + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + } + }, + { + id: "niceId", + accessorKey: "nice", + friendlyName: t("identifier"), + enableHiding: true, + header: () => { + return {t("identifier")}; + }, + cell: ({ row }) => { + return {row.original.nice || "-"}; + } + }, + { + accessorKey: "online", + friendlyName: t("online"), + header: () => { + return ( + + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if ( + originalRow.type == "newt" || + originalRow.type == "wireguard" + ) { + if (originalRow.online) { + return ( + +
+ {t("online")} +
+ ); + } else { + return ( + +
+ {t("offline")} +
+ ); + } + } else { + return -; + } + } + }, + { + accessorKey: "mbIn", + friendlyName: t("dataIn"), + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: t("dataOut"), + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + } + }, + { + accessorKey: "type", + friendlyName: t("type"), + header: () => { + return {t("type")}; + }, + cell: ({ row }) => { + const originalRow = row.original; + + if (originalRow.type === "newt") { + return ( +
+ +
+ Newt + {originalRow.newtVersion && ( + v{originalRow.newtVersion} + )} +
+
+ {originalRow.newtUpdateAvailable && ( + + )} +
+ ); + } + + if (originalRow.type === "wireguard") { + return ( +
+ WireGuard +
+ ); + } + + if (originalRow.type === "local") { + return ( +
+ Local +
+ ); + } + } + }, + { + accessorKey: "exitNode", + friendlyName: t("exitNode"), + header: () => { + return {t("exitNode")}; + }, + cell: ({ row }) => { + const originalRow = row.original; + if (!originalRow.exitNodeName) { + return "-"; + } + + const isCloudNode = + build == "saas" && + originalRow.exitNodeName && + [ + "mercury", + "venus", + "earth", + "mars", + "jupiter", + "saturn", + "uranus", + "neptune" + ].includes(originalRow.exitNodeName.toLowerCase()); + + if (isCloudNode) { + const capitalizedName = + originalRow.exitNodeName.charAt(0).toUpperCase() + + originalRow.exitNodeName.slice(1).toLowerCase(); + return ( + + Pangolin {capitalizedName} + + ); + } + + if (originalRow.remoteExitNodeId) { + return ( + + + + ); + } + + return {originalRow.exitNodeName}; + } + }, + { + accessorKey: "address", + header: () => { + return {t("address")}; + }, + cell: ({ row }: { row: any }) => { + const originalRow = row.original; + return originalRow.address ? ( +
+ {originalRow.address} +
+ ) : ( + "-" + ); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const siteRow = row.original; + const isApproving = approvingIds.has(siteRow.id); + return ( +
+ +
+ ); + } + } + ]; + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + + ); +} \ No newline at end of file From fcf92d4e2c910efbe24ed650222f218eba6c18d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 16:28:51 -0700 Subject: [PATCH 2/3] Add basic provisioning room v1 and update keys --- messages/en-US.json | 8 ++ server/db/pg/schema/privateSchema.ts | 3 +- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/privateSchema.ts | 5 +- server/db/sqlite/schema/schema.ts | 2 +- .../createSiteProvisioningKey.ts | 11 ++- .../listSiteProvisioningKeys.ts | 3 +- .../updateSiteProvisioningKey.ts | 15 ++- server/routers/newt/registerNewt.ts | 5 +- server/routers/site/createSite.ts | 6 +- server/routers/site/listSites.ts | 4 +- server/routers/site/updateSite.ts | 2 +- server/routers/siteProvisioning/types.ts | 3 + .../settings/provisioning/keys/page.tsx | 29 +++++- .../settings/provisioning/pending/page.tsx | 48 ++++++++-- src/app/[orgId]/settings/sites/page.tsx | 2 +- .../CreateSiteProvisioningKeyCredenza.tsx | 93 ++++++++++++------- .../EditSiteProvisioningKeyCredenza.tsx | 45 ++++++++- src/components/PendingSitesTable.tsx | 4 +- 19 files changed, 219 insertions(+), 71 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ad64cb5e2..b53c61ebe 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -365,9 +365,17 @@ "provisioningKeysNeverUsed": "Never", "provisioningKeysEdit": "Edit Provisioning Key", "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", + "provisioningKeysApproveNewSites": "Approve new sites", + "provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.", "provisioningKeysUpdateError": "Error updating provisioning key", "provisioningKeysUpdated": "Provisioning key updated", "provisioningKeysUpdatedDescription": "Your changes have been saved.", + "provisioningKeysBannerTitle": "Site Provisioning Keys", + "provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.", + "provisioningKeysBannerButtonText": "Learn More", + "pendingSitesBannerTitle": "Pending Sites", + "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.", + "pendingSitesBannerButtonText": "Learn More", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index bb1e866c4..c83d420a2 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -391,7 +391,8 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", { lastUsed: varchar("lastUsed", { length: 255 }), maxBatchSize: integer("maxBatchSize"), // null = no limit numUsed: integer("numUsed").notNull().default(0), - validUntil: varchar("validUntil", { length: 255 }) + validUntil: varchar("validUntil", { length: 255 }), + approveNewSites: boolean("approveNewSites").notNull().default(true) }); export const siteProvisioningKeyOrg = pgTable( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index a64aad2ef..29e25bbdd 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -101,7 +101,7 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - status: varchar("status").$type<"pending" | "accepted">() + status: varchar("status").$type<"pending" | "approved">() }); export const resources = pgTable("resources", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 5913497b3..287c53884 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -375,7 +375,10 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", { lastUsed: text("lastUsed"), maxBatchSize: integer("maxBatchSize"), // null = no limit numUsed: integer("numUsed").notNull().default(0), - validUntil: text("validUntil") + validUntil: text("validUntil"), + approveNewSites: integer("approveNewSites", { mode: "boolean" }) + .notNull() + .default(true) }); export const siteProvisioningKeyOrg = sqliteTable( diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 52969d183..880fab3fd 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -111,7 +111,7 @@ export const sites = sqliteTable("sites", { dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true), - status: text("status").$type<"pending" | "accepted">() + status: text("status").$type<"pending" | "approved">() }); export const resources = sqliteTable("resources", { diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index abed27550..e521eaa22 100644 --- a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -38,7 +38,8 @@ const bodySchema = z z.null(), z.coerce.number().int().positive().max(1_000_000) ]), - validUntil: z.string().max(255).optional() + validUntil: z.string().max(255).optional(), + approveNewSites: z.boolean().optional().default(true) }) .superRefine((data, ctx) => { const v = data.validUntil; @@ -82,7 +83,7 @@ export async function createSiteProvisioningKey( } const { orgId } = parsedParams.data; - const { name, maxBatchSize } = parsedBody.data; + const { name, maxBatchSize, approveNewSites } = parsedBody.data; const vuRaw = parsedBody.data.validUntil; const validUntil = vuRaw == null || vuRaw.trim() === "" @@ -106,7 +107,8 @@ export async function createSiteProvisioningKey( lastUsed: null, maxBatchSize, numUsed: 0, - validUntil + validUntil, + approveNewSites }); await trx.insert(siteProvisioningKeyOrg).values({ @@ -127,7 +129,8 @@ export async function createSiteProvisioningKey( lastUsed: null, maxBatchSize, numUsed: 0, - validUntil + validUntil, + approveNewSites }, success: true, error: false, diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index 5f7531a2c..dd51179d3 100644 --- a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -57,7 +57,8 @@ function querySiteProvisioningKeys(orgId: string) { lastUsed: siteProvisioningKeys.lastUsed, maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, - validUntil: siteProvisioningKeys.validUntil + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites }) .from(siteProvisioningKeyOrg) .innerJoin( diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts index 526d8bfb8..2f4dafbdf 100644 --- a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -39,16 +39,18 @@ const bodySchema = z z.coerce.number().int().positive().max(1_000_000) ]) .optional(), - validUntil: z.string().max(255).optional() + validUntil: z.string().max(255).optional(), + approveNewSites: z.boolean().optional() }) .superRefine((data, ctx) => { if ( data.maxBatchSize === undefined && - data.validUntil === undefined + data.validUntil === undefined && + data.approveNewSites === undefined ) { ctx.addIssue({ code: "custom", - message: "Provide maxBatchSize and/or validUntil", + message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", path: ["maxBatchSize"] }); } @@ -129,6 +131,7 @@ export async function updateSiteProvisioningKey( const setValues: { maxBatchSize?: number | null; validUntil?: string | null; + approveNewSites?: boolean; } = {}; if (body.maxBatchSize !== undefined) { setValues.maxBatchSize = body.maxBatchSize; @@ -139,6 +142,9 @@ export async function updateSiteProvisioningKey( ? null : new Date(Date.parse(body.validUntil)).toISOString(); } + if (body.approveNewSites !== undefined) { + setValues.approveNewSites = body.approveNewSites; + } await db .update(siteProvisioningKeys) @@ -160,7 +166,8 @@ export async function updateSiteProvisioningKey( lastUsed: siteProvisioningKeys.lastUsed, maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, - validUntil: siteProvisioningKeys.validUntil + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites }) .from(siteProvisioningKeys) .where( diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 4923fb88e..6ad7c30a8 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -82,7 +82,8 @@ export async function registerNewt( orgId: siteProvisioningKeyOrg.orgId, maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, - validUntil: siteProvisioningKeys.validUntil + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites, }) .from(siteProvisioningKeys) .innerJoin( @@ -197,7 +198,7 @@ export async function registerNewt( niceId, type: "newt", dockerSocketEnabled: true, - status: "pending" + status: keyRecord.approveNewSites ? "approved" : "pending", }) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index bf62e93cd..d397b2784 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -299,7 +299,7 @@ export async function createSite( address: updatedAddress || null, type, dockerSocketEnabled: true, - status: "accepted" + status: "approved" }) .returning(); } else if (type == "wireguard") { @@ -357,7 +357,7 @@ export async function createSite( subnet, type, pubKey: pubKey || null, - status: "accepted" + status: "approved" }) .returning(); } else if (type == "local") { @@ -373,7 +373,7 @@ export async function createSite( dockerSocketEnabled: false, online: true, subnet: "0.0.0.0/32", - status: "accepted" + status: "approved" }) .returning(); } else { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 1d7e99042..6f085d74d 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -137,12 +137,12 @@ const listSitesSchema = z.object({ description: "Filter by online status" }), status: z - .enum(["pending", "accepted"]) + .enum(["pending", "approved"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["pending", "accepted"], + enum: ["pending", "approved"], description: "Filter by site status" }) }); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 244adf7b8..34d1341d7 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -20,7 +20,7 @@ const updateSiteBodySchema = z name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), - status: z.enum(["pending", "accepted"]).optional(), + status: z.enum(["pending", "approved"]).optional(), // remoteSubnets: z.string().optional() // subdomain: z // .string() diff --git a/server/routers/siteProvisioning/types.ts b/server/routers/siteProvisioning/types.ts index d06c1fe26..785d9dfff 100644 --- a/server/routers/siteProvisioning/types.ts +++ b/server/routers/siteProvisioning/types.ts @@ -8,6 +8,7 @@ export type SiteProvisioningKeyListItem = { maxBatchSize: number | null; numUsed: number; validUntil: string | null; + approveNewSites: boolean; }; export type ListSiteProvisioningKeysResponse = { @@ -26,6 +27,7 @@ export type CreateSiteProvisioningKeyResponse = { maxBatchSize: number | null; numUsed: number; validUntil: string | null; + approveNewSites: boolean; }; export type UpdateSiteProvisioningKeyResponse = { @@ -38,4 +40,5 @@ export type UpdateSiteProvisioningKeyResponse = { maxBatchSize: number | null; numUsed: number; validUntil: string | null; + approveNewSites: boolean; }; diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx index 1cfbf5b05..021bb97b7 100644 --- a/src/app/[orgId]/settings/provisioning/keys/page.tsx +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -8,6 +8,10 @@ import SiteProvisioningKeysTable, { import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; import { getTranslations } from "next-intl/server"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import DismissableBanner from "@app/components/DismissableBanner"; +import Link from "next/link"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, Plug } from "lucide-react"; type ProvisioningKeysPageProps = { params: Promise<{ orgId: string }>; @@ -46,6 +50,29 @@ export default async function ProvisioningKeysPage( return ( <> + } + description={t("provisioningKeysBannerDescription")} + > + + + + + @@ -53,4 +80,4 @@ export default async function ProvisioningKeysPage( ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index 78178cb14..637f828b8 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -5,6 +5,10 @@ import { AxiosResponse } from "axios"; import { SiteRow } from "@app/components/SitesTable"; import PendingSitesTable from "@app/components/PendingSitesTable"; import { getTranslations } from "next-intl/server"; +import DismissableBanner from "@app/components/DismissableBanner"; +import Link from "next/link"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, Plug } from "lucide-react"; type PendingSitesPageProps = { params: Promise<{ orgId: string }>; @@ -69,14 +73,38 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { })); return ( - + <> + } + description={t("pendingSitesBannerDescription")} + > + + + + + + ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 5839ba2be..38083325b 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -18,7 +18,7 @@ export default async function SitesPage(props: SitesPageProps) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); - searchParams.set("status", "accepted"); + searchParams.set("status", "approved"); let sites: ListSitesResponse["sites"] = []; let pagination: ListSitesResponse["pagination"] = { diff --git a/src/components/CreateSiteProvisioningKeyCredenza.tsx b/src/components/CreateSiteProvisioningKeyCredenza.tsx index 3a1c7c372..f6b80a964 100644 --- a/src/components/CreateSiteProvisioningKeyCredenza.tsx +++ b/src/components/CreateSiteProvisioningKeyCredenza.tsx @@ -79,7 +79,8 @@ export default function CreateSiteProvisioningKeyCredenza({ .max(1_000_000, { message: t("provisioningKeysMaxBatchSizeInvalid") }), - validUntil: z.string().optional() + validUntil: z.string().optional(), + approveNewSites: z.boolean() }) .superRefine((data, ctx) => { const v = data.validUntil; @@ -103,7 +104,8 @@ export default function CreateSiteProvisioningKeyCredenza({ name: "", unlimitedBatchSize: false, maxBatchSize: 100, - validUntil: "" + validUntil: "", + approveNewSites: true } }); @@ -114,7 +116,8 @@ export default function CreateSiteProvisioningKeyCredenza({ name: "", unlimitedBatchSize: false, maxBatchSize: 100, - validUntil: "" + validUntil: "", + approveNewSites: true }); } }, [open, form]); @@ -123,18 +126,21 @@ export default function CreateSiteProvisioningKeyCredenza({ 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 - }) + .put>( + `/org/${orgId}/site-provisioning-key`, + { + name: data.name, + maxBatchSize: data.unlimitedBatchSize + ? null + : data.maxBatchSize, + validUntil: + data.validUntil == null || + data.validUntil.trim() === "" + ? undefined + : data.validUntil, + approveNewSites: data.approveNewSites + } + ) .catch((e) => { toast({ variant: "destructive", @@ -152,9 +158,7 @@ export default function CreateSiteProvisioningKeyCredenza({ } } - const credential = - created && - created.siteProvisioningKey; + const credential = created && created.siteProvisioningKey; const unlimitedBatchSize = form.watch("unlimitedBatchSize"); @@ -213,15 +217,12 @@ export default function CreateSiteProvisioningKeyCredenza({ min={1} max={1_000_000} autoComplete="off" - disabled={ - unlimitedBatchSize - } + disabled={unlimitedBatchSize} name={field.name} ref={field.ref} onBlur={field.onBlur} onChange={(e) => { - const v = - e.target.value; + const v = e.target.value; field.onChange( v === "" ? 100 @@ -269,9 +270,7 @@ export default function CreateSiteProvisioningKeyCredenza({ const dateTimeValue: DateTimeValue = (() => { if (!field.value) return {}; - const d = new Date( - field.value - ); + const d = new Date(field.value); if (isNaN(d.getTime())) return {}; const hours = d @@ -313,11 +312,7 @@ export default function CreateSiteProvisioningKeyCredenza({ value.date ); if (value.time) { - const [ - h, - m, - s - ] = + const [h, m, s] = value.time.split( ":" ); @@ -352,6 +347,40 @@ export default function CreateSiteProvisioningKeyCredenza({ ); }} /> + ( + + + + field.onChange( + c === true + ) + } + /> + +
+ + {t( + "provisioningKeysApproveNewSites" + )} + + + {t( + "provisioningKeysApproveNewSitesDescription" + )} + +
+
+ )} + /> )} @@ -395,4 +424,4 @@ export default function CreateSiteProvisioningKeyCredenza({ ); -} +} \ No newline at end of file diff --git a/src/components/EditSiteProvisioningKeyCredenza.tsx b/src/components/EditSiteProvisioningKeyCredenza.tsx index 138190edc..e0e9cdde0 100644 --- a/src/components/EditSiteProvisioningKeyCredenza.tsx +++ b/src/components/EditSiteProvisioningKeyCredenza.tsx @@ -45,6 +45,7 @@ export type EditableSiteProvisioningKey = { name: string; maxBatchSize: number | null; validUntil: string | null; + approveNewSites: boolean; }; type EditSiteProvisioningKeyCredenzaProps = { @@ -76,7 +77,8 @@ export default function EditSiteProvisioningKeyCredenza({ .max(1_000_000, { message: t("provisioningKeysMaxBatchSizeInvalid") }), - validUntil: z.string().optional() + validUntil: z.string().optional(), + approveNewSites: z.boolean() }) .superRefine((data, ctx) => { const v = data.validUntil; @@ -100,7 +102,8 @@ export default function EditSiteProvisioningKeyCredenza({ name: "", unlimitedBatchSize: false, maxBatchSize: 100, - validUntil: "" + validUntil: "", + approveNewSites: true } }); @@ -112,7 +115,8 @@ export default function EditSiteProvisioningKeyCredenza({ name: provisioningKey.name, unlimitedBatchSize: provisioningKey.maxBatchSize == null, maxBatchSize: provisioningKey.maxBatchSize ?? 100, - validUntil: provisioningKey.validUntil ?? "" + validUntil: provisioningKey.validUntil ?? "", + approveNewSites: provisioningKey.approveNewSites }); }, [open, provisioningKey, form]); @@ -135,7 +139,8 @@ export default function EditSiteProvisioningKeyCredenza({ data.validUntil == null || data.validUntil.trim() === "" ? "" - : data.validUntil + : data.validUntil, + approveNewSites: data.approveNewSites } ) .catch((e) => { @@ -255,6 +260,38 @@ export default function EditSiteProvisioningKeyCredenza({ )} /> + ( + + + + field.onChange(c === true) + } + /> + +
+ + {t( + "provisioningKeysApproveNewSites" + )} + + + {t( + "provisioningKeysApproveNewSitesDescription" + )} + +
+
+ )} + /> new Set(prev).add(siteId)); try { - await api.post(`/site/${siteId}`, { status: "accepted" }); + await api.post(`/site/${siteId}`, { status: "approved" }); toast({ title: t("success"), description: t("siteApproveSuccess"), @@ -437,4 +437,4 @@ export default function PendingSitesTable({ stickyRightColumn="actions" /> ); -} \ No newline at end of file +} From 1e9544af0773307ed1fceae9730648cf86290270 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 20:29:31 -0700 Subject: [PATCH 3/3] Customize table a little more --- src/components/PendingSitesTable.tsx | 129 ++++++++++++++++----------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index ae22c1bc1..2d1ac8769 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -2,6 +2,12 @@ import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; @@ -15,7 +21,8 @@ import { ArrowUp10Icon, ArrowUpRight, Check, - ChevronsUpDownIcon + ChevronsUpDownIcon, + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -93,7 +100,7 @@ export default function PendingSitesTable({ async function approveSite(siteId: number) { setApprovingIds((prev) => new Set(prev).add(siteId)); try { - await api.post(`/site/${siteId}`, { status: "approved" }); + await api.post(`/site/${siteId}`, { status: "accepted" }); toast({ title: t("success"), description: t("siteApproveSuccess"), @@ -201,56 +208,56 @@ export default function PendingSitesTable({ } } }, - { - accessorKey: "mbIn", - friendlyName: t("dataIn"), - header: () => { - const dataInOrder = getSortDirection( - "megabytesIn", - searchParams - ); - const Icon = - dataInOrder === "asc" - ? ArrowDown01Icon - : dataInOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; - return ( - - ); - } - }, - { - accessorKey: "mbOut", - friendlyName: t("dataOut"), - header: () => { - const dataOutOrder = getSortDirection( - "megabytesOut", - searchParams - ); - const Icon = - dataOutOrder === "asc" - ? ArrowDown01Icon - : dataOutOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; - return ( - - ); - } - }, + // { + // accessorKey: "mbIn", + // friendlyName: t("dataIn"), + // header: () => { + // const dataInOrder = getSortDirection( + // "megabytesIn", + // searchParams + // ); + // const Icon = + // dataInOrder === "asc" + // ? ArrowDown01Icon + // : dataInOrder === "desc" + // ? ArrowUp10Icon + // : ChevronsUpDownIcon; + // return ( + // + // ); + // } + // }, + // { + // accessorKey: "mbOut", + // friendlyName: t("dataOut"), + // header: () => { + // const dataOutOrder = getSortDirection( + // "megabytesOut", + // searchParams + // ); + // const Icon = + // dataOutOrder === "asc" + // ? ArrowDown01Icon + // : dataOutOrder === "desc" + // ? ArrowUp10Icon + // : ChevronsUpDownIcon; + // return ( + // + // ); + // } + // }, { accessorKey: "type", friendlyName: t("type"), @@ -375,6 +382,24 @@ export default function PendingSitesTable({ const isApproving = approvingIds.has(siteRow.id); return (
+ + + + + + + + {t("viewSettings")} + + + +