Add basic provisioning room v1 and update keys

This commit is contained in:
Owen
2026-03-29 16:28:51 -07:00
parent 77cef554be
commit fcf92d4e2c
19 changed files with 219 additions and 71 deletions

View File

@@ -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",

View File

@@ -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(

View File

@@ -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", {

View File

@@ -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(

View File

@@ -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", {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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(

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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"
})
});

View File

@@ -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()

View File

@@ -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;
};

View File

@@ -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 (
<>
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("provisioningKeysBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("provisioningKeysBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("provisioningKeysBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
@@ -53,4 +80,4 @@ export default async function ProvisioningKeysPage(
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}
}

View File

@@ -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 (
<PendingSitesTable
sites={siteRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
<>
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("pendingSitesBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("pendingSitesBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("pendingSitesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
<PendingSitesTable
sites={siteRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
}

View File

@@ -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"] = {

View File

@@ -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<CreateSiteProvisioningKeyResponse>
>(`/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<AxiosResponse<CreateSiteProvisioningKeyResponse>>(
`/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({
);
}}
/>
<FormField
control={form.control}
name="approveNewSites"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
id="provisioning-approve-new-sites"
checked={field.value}
onCheckedChange={(c) =>
field.onChange(
c === true
)
}
/>
</FormControl>
<div className="flex flex-col gap-1">
<FormLabel
htmlFor="provisioning-approve-new-sites"
className="cursor-pointer font-normal !mt-0"
>
{t(
"provisioningKeysApproveNewSites"
)}
</FormLabel>
<FormDescription>
{t(
"provisioningKeysApproveNewSitesDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
</form>
</Form>
)}
@@ -395,4 +424,4 @@ export default function CreateSiteProvisioningKeyCredenza({
</CredenzaContent>
</Credenza>
);
}
}

View File

@@ -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({
</FormItem>
)}
/>
<FormField
control={form.control}
name="approveNewSites"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
id="provisioning-edit-approve-new-sites"
checked={field.value}
onCheckedChange={(c) =>
field.onChange(c === true)
}
/>
</FormControl>
<div className="flex flex-col gap-1">
<FormLabel
htmlFor="provisioning-edit-approve-new-sites"
className="cursor-pointer font-normal !mt-0"
>
{t(
"provisioningKeysApproveNewSites"
)}
</FormLabel>
<FormDescription>
{t(
"provisioningKeysApproveNewSitesDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="validUntil"

View File

@@ -93,7 +93,7 @@ export default function PendingSitesTable({
async function approveSite(siteId: number) {
setApprovingIds((prev) => 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"
/>
);
}
}