diff --git a/messages/en-US.json b/messages/en-US.json index 5ccc7c230..768796c12 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", @@ -360,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 1f0de4e7d..9d5955d51 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -392,7 +392,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 23a7d1891..b0c0d49a9 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" | "approved">() }); export const resources = pgTable("resources", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index d651c1a38..809c0c45d 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -376,7 +376,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 7ade6364c..65ff144a4 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" | "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 427ac173f..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( @@ -196,7 +197,8 @@ export async function registerNewt( name: niceId, niceId, type: "newt", - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: keyRecord.approveNewSites ? "approved" : "pending", }) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 4edebb080..d397b2784 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: "approved" }) .returning(); } else if (type == "wireguard") { @@ -355,7 +356,8 @@ export async function createSite( niceId, subnet, type, - pubKey: pubKey || null + pubKey: pubKey || null, + status: "approved" }) .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: "approved" }) .returning(); } else { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index a244c650c..6f085d74d 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", "approved"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "approved"], + 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..34d1341d7 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", "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 new file mode 100644 index 000000000..021bb97b7 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -0,0 +1,83 @@ +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"; +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 }>; +}; + +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 ( + <> + } + description={t("provisioningKeysBannerDescription")} + > + + + + + + + + + + ); +} 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..637f828b8 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -0,0 +1,110 @@ +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"; +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 }>; + 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 ( + <> + } + description={t("pendingSitesBannerDescription")} + > + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 161c757f6..38083325b 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", "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()); + + 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 ( +
+ + + + + + + + {t("viewSettings")} + + + + + +
+ ); + } + } + ]; + + 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 ( + + ); +}