From fcf92d4e2c910efbe24ed650222f218eba6c18d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 16:28:51 -0700 Subject: [PATCH] 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 +}