Compare commits

...

3 Commits

Author SHA1 Message Date
Owen
1e9544af07 Customize table a little more 2026-03-29 20:29:31 -07:00
Owen
fcf92d4e2c Add basic provisioning room v1 and update keys 2026-03-29 16:28:51 -07:00
Owen
77cef554be Provisioning room basics done 2026-03-29 14:20:58 -07:00
21 changed files with 875 additions and 110 deletions

View File

@@ -331,6 +331,11 @@
"provisioningKeysTitle": "Provisioning Key", "provisioningKeysTitle": "Provisioning Key",
"provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", "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", "provisioningKeys": "Provisioning Keys",
"searchProvisioningKeys": "Search provisioning keys...", "searchProvisioningKeys": "Search provisioning keys...",
"provisioningKeysAdd": "Generate Provisioning Key", "provisioningKeysAdd": "Generate Provisioning Key",
@@ -360,9 +365,17 @@
"provisioningKeysNeverUsed": "Never", "provisioningKeysNeverUsed": "Never",
"provisioningKeysEdit": "Edit Provisioning Key", "provisioningKeysEdit": "Edit Provisioning Key",
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this 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", "provisioningKeysUpdateError": "Error updating provisioning key",
"provisioningKeysUpdated": "Provisioning key updated", "provisioningKeysUpdated": "Provisioning key updated",
"provisioningKeysUpdatedDescription": "Your changes have been saved.", "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", "apiKeysSettings": "{apiKeyName} Settings",
"userTitle": "Manage All Users", "userTitle": "Manage All Users",
"userDescription": "View and manage all users in the system", "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 }), lastUsed: varchar("lastUsed", { length: 255 }),
maxBatchSize: integer("maxBatchSize"), // null = no limit maxBatchSize: integer("maxBatchSize"), // null = no limit
numUsed: integer("numUsed").notNull().default(0), 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( export const siteProvisioningKeyOrg = pgTable(

View File

@@ -100,7 +100,8 @@ export const sites = pgTable("sites", {
publicKey: varchar("publicKey"), publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), 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", { export const resources = pgTable("resources", {

View File

@@ -375,7 +375,10 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
lastUsed: text("lastUsed"), lastUsed: text("lastUsed"),
maxBatchSize: integer("maxBatchSize"), // null = no limit maxBatchSize: integer("maxBatchSize"), // null = no limit
numUsed: integer("numUsed").notNull().default(0), numUsed: integer("numUsed").notNull().default(0),
validUntil: text("validUntil") validUntil: text("validUntil"),
approveNewSites: integer("approveNewSites", { mode: "boolean" })
.notNull()
.default(true)
}); });
export const siteProvisioningKeyOrg = sqliteTable( export const siteProvisioningKeyOrg = sqliteTable(

View File

@@ -110,7 +110,8 @@ export const sites = sqliteTable("sites", {
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(true) .default(true),
status: text("status").$type<"pending" | "approved">()
}); });
export const resources = sqliteTable("resources", { export const resources = sqliteTable("resources", {

View File

@@ -38,7 +38,8 @@ const bodySchema = z
z.null(), z.null(),
z.coerce.number().int().positive().max(1_000_000) 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) => { .superRefine((data, ctx) => {
const v = data.validUntil; const v = data.validUntil;
@@ -82,7 +83,7 @@ export async function createSiteProvisioningKey(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { name, maxBatchSize } = parsedBody.data; const { name, maxBatchSize, approveNewSites } = parsedBody.data;
const vuRaw = parsedBody.data.validUntil; const vuRaw = parsedBody.data.validUntil;
const validUntil = const validUntil =
vuRaw == null || vuRaw.trim() === "" vuRaw == null || vuRaw.trim() === ""
@@ -106,7 +107,8 @@ export async function createSiteProvisioningKey(
lastUsed: null, lastUsed: null,
maxBatchSize, maxBatchSize,
numUsed: 0, numUsed: 0,
validUntil validUntil,
approveNewSites
}); });
await trx.insert(siteProvisioningKeyOrg).values({ await trx.insert(siteProvisioningKeyOrg).values({
@@ -127,7 +129,8 @@ export async function createSiteProvisioningKey(
lastUsed: null, lastUsed: null,
maxBatchSize, maxBatchSize,
numUsed: 0, numUsed: 0,
validUntil validUntil,
approveNewSites
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -57,7 +57,8 @@ function querySiteProvisioningKeys(orgId: string) {
lastUsed: siteProvisioningKeys.lastUsed, lastUsed: siteProvisioningKeys.lastUsed,
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil validUntil: siteProvisioningKeys.validUntil,
approveNewSites: siteProvisioningKeys.approveNewSites
}) })
.from(siteProvisioningKeyOrg) .from(siteProvisioningKeyOrg)
.innerJoin( .innerJoin(

View File

@@ -39,16 +39,18 @@ const bodySchema = z
z.coerce.number().int().positive().max(1_000_000) z.coerce.number().int().positive().max(1_000_000)
]) ])
.optional(), .optional(),
validUntil: z.string().max(255).optional() validUntil: z.string().max(255).optional(),
approveNewSites: z.boolean().optional()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if ( if (
data.maxBatchSize === undefined && data.maxBatchSize === undefined &&
data.validUntil === undefined data.validUntil === undefined &&
data.approveNewSites === undefined
) { ) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Provide maxBatchSize and/or validUntil", message: "Provide maxBatchSize and/or validUntil and/or approveNewSites",
path: ["maxBatchSize"] path: ["maxBatchSize"]
}); });
} }
@@ -129,6 +131,7 @@ export async function updateSiteProvisioningKey(
const setValues: { const setValues: {
maxBatchSize?: number | null; maxBatchSize?: number | null;
validUntil?: string | null; validUntil?: string | null;
approveNewSites?: boolean;
} = {}; } = {};
if (body.maxBatchSize !== undefined) { if (body.maxBatchSize !== undefined) {
setValues.maxBatchSize = body.maxBatchSize; setValues.maxBatchSize = body.maxBatchSize;
@@ -139,6 +142,9 @@ export async function updateSiteProvisioningKey(
? null ? null
: new Date(Date.parse(body.validUntil)).toISOString(); : new Date(Date.parse(body.validUntil)).toISOString();
} }
if (body.approveNewSites !== undefined) {
setValues.approveNewSites = body.approveNewSites;
}
await db await db
.update(siteProvisioningKeys) .update(siteProvisioningKeys)
@@ -160,7 +166,8 @@ export async function updateSiteProvisioningKey(
lastUsed: siteProvisioningKeys.lastUsed, lastUsed: siteProvisioningKeys.lastUsed,
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil validUntil: siteProvisioningKeys.validUntil,
approveNewSites: siteProvisioningKeys.approveNewSites
}) })
.from(siteProvisioningKeys) .from(siteProvisioningKeys)
.where( .where(

View File

@@ -82,7 +82,8 @@ export async function registerNewt(
orgId: siteProvisioningKeyOrg.orgId, orgId: siteProvisioningKeyOrg.orgId,
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil validUntil: siteProvisioningKeys.validUntil,
approveNewSites: siteProvisioningKeys.approveNewSites,
}) })
.from(siteProvisioningKeys) .from(siteProvisioningKeys)
.innerJoin( .innerJoin(
@@ -196,7 +197,8 @@ export async function registerNewt(
name: niceId, name: niceId,
niceId, niceId,
type: "newt", type: "newt",
dockerSocketEnabled: true dockerSocketEnabled: true,
status: keyRecord.approveNewSites ? "approved" : "pending",
}) })
.returning(); .returning();

View File

@@ -298,7 +298,8 @@ export async function createSite(
niceId, niceId,
address: updatedAddress || null, address: updatedAddress || null,
type, type,
dockerSocketEnabled: true dockerSocketEnabled: true,
status: "approved"
}) })
.returning(); .returning();
} else if (type == "wireguard") { } else if (type == "wireguard") {
@@ -355,7 +356,8 @@ export async function createSite(
niceId, niceId,
subnet, subnet,
type, type,
pubKey: pubKey || null pubKey: pubKey || null,
status: "approved"
}) })
.returning(); .returning();
} else if (type == "local") { } else if (type == "local") {
@@ -370,7 +372,8 @@ export async function createSite(
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
subnet: "0.0.0.0/32" subnet: "0.0.0.0/32",
status: "approved"
}) })
.returning(); .returning();
} else { } else {

View File

@@ -135,6 +135,15 @@ const listSitesSchema = z.object({
.openapi({ .openapi({
type: "boolean", type: "boolean",
description: "Filter by online status" 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, exitNodeId: sites.exitNodeId,
exitNodeName: exitNodes.name, exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint, exitNodeEndpoint: exitNodes.endpoint,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
status: sites.status
}) })
.from(sites) .from(sites)
.leftJoin(orgs, eq(sites.orgId, orgs.orgId)) .leftJoin(orgs, eq(sites.orgId, orgs.orgId))
@@ -245,7 +255,7 @@ export async function listSites(
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
} }
const { pageSize, page, query, sort_by, order, online } = const { pageSize, page, query, sort_by, order, online, status } =
parsedQuery.data; parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -273,6 +283,9 @@ export async function listSites(
if (typeof online !== "undefined") { if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online)); conditions.push(eq(sites.online, online));
} }
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
const baseQuery = querySitesBase().where(and(...conditions)); const baseQuery = querySitesBase().where(and(...conditions));

View File

@@ -19,7 +19,8 @@ const updateSiteBodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
niceId: 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() // remoteSubnets: z.string().optional()
// subdomain: z // subdomain: z
// .string() // .string()

View File

@@ -8,6 +8,7 @@ export type SiteProvisioningKeyListItem = {
maxBatchSize: number | null; maxBatchSize: number | null;
numUsed: number; numUsed: number;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };
export type ListSiteProvisioningKeysResponse = { export type ListSiteProvisioningKeysResponse = {
@@ -26,6 +27,7 @@ export type CreateSiteProvisioningKeyResponse = {
maxBatchSize: number | null; maxBatchSize: number | null;
numUsed: number; numUsed: number;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };
export type UpdateSiteProvisioningKeyResponse = { export type UpdateSiteProvisioningKeyResponse = {
@@ -38,4 +40,5 @@ export type UpdateSiteProvisioningKeyResponse = {
maxBatchSize: number | null; maxBatchSize: number | null;
numUsed: number; numUsed: number;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };

View File

@@ -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<ListSiteProvisioningKeysResponse>
>(
`/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 (
<>
<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]}
/>
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}

View File

@@ -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 (
<>
<SettingsSectionTitle
title={t("provisioningManage")}
description={t("provisioningDescription")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}

View File

@@ -1,60 +1,10 @@
import { internal } from "@app/lib/api"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "../../../../components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
type ProvisioningPageProps = { type ProvisioningPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function ProvisioningPage(props: ProvisioningPageProps) { export default async function ProvisioningPage(props: ProvisioningPageProps) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); redirect(`/${params.orgId}/settings/provisioning/keys`);
}
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
[];
try {
const res = await internal.get<
AxiosResponse<ListSiteProvisioningKeysResponse>
>(
`/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 (
<>
<SettingsSectionTitle
title={t("provisioningKeysManage")}
description={t("provisioningKeysDescription")}
/>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}

View File

@@ -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<Record<string, string>>;
};
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<AxiosResponse<ListSitesResponse>>(
`/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 (
<>
<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,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) {
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let sites: ListSitesResponse["sites"] = []; let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = { let pagination: ListSitesResponse["pagination"] = {

View File

@@ -79,7 +79,8 @@ export default function CreateSiteProvisioningKeyCredenza({
.max(1_000_000, { .max(1_000_000, {
message: t("provisioningKeysMaxBatchSizeInvalid") message: t("provisioningKeysMaxBatchSizeInvalid")
}), }),
validUntil: z.string().optional() validUntil: z.string().optional(),
approveNewSites: z.boolean()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const v = data.validUntil; const v = data.validUntil;
@@ -103,7 +104,8 @@ export default function CreateSiteProvisioningKeyCredenza({
name: "", name: "",
unlimitedBatchSize: false, unlimitedBatchSize: false,
maxBatchSize: 100, maxBatchSize: 100,
validUntil: "" validUntil: "",
approveNewSites: true
} }
}); });
@@ -114,7 +116,8 @@ export default function CreateSiteProvisioningKeyCredenza({
name: "", name: "",
unlimitedBatchSize: false, unlimitedBatchSize: false,
maxBatchSize: 100, maxBatchSize: 100,
validUntil: "" validUntil: "",
approveNewSites: true
}); });
} }
}, [open, form]); }, [open, form]);
@@ -123,18 +126,21 @@ export default function CreateSiteProvisioningKeyCredenza({
setLoading(true); setLoading(true);
try { try {
const res = await api const res = await api
.put< .put<AxiosResponse<CreateSiteProvisioningKeyResponse>>(
AxiosResponse<CreateSiteProvisioningKeyResponse> `/org/${orgId}/site-provisioning-key`,
>(`/org/${orgId}/site-provisioning-key`, { {
name: data.name, name: data.name,
maxBatchSize: data.unlimitedBatchSize maxBatchSize: data.unlimitedBatchSize
? null ? null
: data.maxBatchSize, : data.maxBatchSize,
validUntil: validUntil:
data.validUntil == null || data.validUntil.trim() === "" data.validUntil == null ||
? undefined data.validUntil.trim() === ""
: data.validUntil ? undefined
}) : data.validUntil,
approveNewSites: data.approveNewSites
}
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -152,9 +158,7 @@ export default function CreateSiteProvisioningKeyCredenza({
} }
} }
const credential = const credential = created && created.siteProvisioningKey;
created &&
created.siteProvisioningKey;
const unlimitedBatchSize = form.watch("unlimitedBatchSize"); const unlimitedBatchSize = form.watch("unlimitedBatchSize");
@@ -213,15 +217,12 @@ export default function CreateSiteProvisioningKeyCredenza({
min={1} min={1}
max={1_000_000} max={1_000_000}
autoComplete="off" autoComplete="off"
disabled={ disabled={unlimitedBatchSize}
unlimitedBatchSize
}
name={field.name} name={field.name}
ref={field.ref} ref={field.ref}
onBlur={field.onBlur} onBlur={field.onBlur}
onChange={(e) => { onChange={(e) => {
const v = const v = e.target.value;
e.target.value;
field.onChange( field.onChange(
v === "" v === ""
? 100 ? 100
@@ -269,9 +270,7 @@ export default function CreateSiteProvisioningKeyCredenza({
const dateTimeValue: DateTimeValue = const dateTimeValue: DateTimeValue =
(() => { (() => {
if (!field.value) return {}; if (!field.value) return {};
const d = new Date( const d = new Date(field.value);
field.value
);
if (isNaN(d.getTime())) if (isNaN(d.getTime()))
return {}; return {};
const hours = d const hours = d
@@ -313,11 +312,7 @@ export default function CreateSiteProvisioningKeyCredenza({
value.date value.date
); );
if (value.time) { if (value.time) {
const [ const [h, m, s] =
h,
m,
s
] =
value.time.split( 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>
</Form> </Form>
)} )}
@@ -395,4 +424,4 @@ export default function CreateSiteProvisioningKeyCredenza({
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
); );
} }

View File

@@ -45,6 +45,7 @@ export type EditableSiteProvisioningKey = {
name: string; name: string;
maxBatchSize: number | null; maxBatchSize: number | null;
validUntil: string | null; validUntil: string | null;
approveNewSites: boolean;
}; };
type EditSiteProvisioningKeyCredenzaProps = { type EditSiteProvisioningKeyCredenzaProps = {
@@ -76,7 +77,8 @@ export default function EditSiteProvisioningKeyCredenza({
.max(1_000_000, { .max(1_000_000, {
message: t("provisioningKeysMaxBatchSizeInvalid") message: t("provisioningKeysMaxBatchSizeInvalid")
}), }),
validUntil: z.string().optional() validUntil: z.string().optional(),
approveNewSites: z.boolean()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const v = data.validUntil; const v = data.validUntil;
@@ -100,7 +102,8 @@ export default function EditSiteProvisioningKeyCredenza({
name: "", name: "",
unlimitedBatchSize: false, unlimitedBatchSize: false,
maxBatchSize: 100, maxBatchSize: 100,
validUntil: "" validUntil: "",
approveNewSites: true
} }
}); });
@@ -112,7 +115,8 @@ export default function EditSiteProvisioningKeyCredenza({
name: provisioningKey.name, name: provisioningKey.name,
unlimitedBatchSize: provisioningKey.maxBatchSize == null, unlimitedBatchSize: provisioningKey.maxBatchSize == null,
maxBatchSize: provisioningKey.maxBatchSize ?? 100, maxBatchSize: provisioningKey.maxBatchSize ?? 100,
validUntil: provisioningKey.validUntil ?? "" validUntil: provisioningKey.validUntil ?? "",
approveNewSites: provisioningKey.approveNewSites
}); });
}, [open, provisioningKey, form]); }, [open, provisioningKey, form]);
@@ -135,7 +139,8 @@ export default function EditSiteProvisioningKeyCredenza({
data.validUntil == null || data.validUntil == null ||
data.validUntil.trim() === "" data.validUntil.trim() === ""
? "" ? ""
: data.validUntil : data.validUntil,
approveNewSites: data.approveNewSites
} }
) )
.catch((e) => { .catch((e) => {
@@ -255,6 +260,38 @@ export default function EditSiteProvisioningKeyCredenza({
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="validUntil" name="validUntil"

View File

@@ -0,0 +1,465 @@
"use client";
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";
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,
MoreHorizontal
} 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<Set<number>>(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<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => {
return <span className="p-3">{t("identifier")}</span>;
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "online",
friendlyName: t("online"),
header: () => {
return (
<ColumnFilterButton
options={[
{ value: "true", label: t("online") },
{ value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
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 (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>
);
}
} else {
return <span>-</span>;
}
}
},
// {
// accessorKey: "mbIn",
// friendlyName: t("dataIn"),
// header: () => {
// const dataInOrder = getSortDirection(
// "megabytesIn",
// searchParams
// );
// const Icon =
// dataInOrder === "asc"
// ? ArrowDown01Icon
// : dataInOrder === "desc"
// ? ArrowUp10Icon
// : ChevronsUpDownIcon;
// return (
// <Button
// variant="ghost"
// onClick={() => toggleSort("megabytesIn")}
// >
// {t("dataIn")}
// <Icon className="ml-2 h-4 w-4" />
// </Button>
// );
// }
// },
// {
// accessorKey: "mbOut",
// friendlyName: t("dataOut"),
// header: () => {
// const dataOutOrder = getSortDirection(
// "megabytesOut",
// searchParams
// );
// const Icon =
// dataOutOrder === "asc"
// ? ArrowDown01Icon
// : dataOutOrder === "desc"
// ? ArrowUp10Icon
// : ChevronsUpDownIcon;
// return (
// <Button
// variant="ghost"
// onClick={() => toggleSort("megabytesOut")}
// >
// {t("dataOut")}
// <Icon className="ml-2 h-4 w-4" />
// </Button>
// );
// }
// },
{
accessorKey: "type",
friendlyName: t("type"),
header: () => {
return <span className="p-3">{t("type")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type === "newt") {
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span>v{originalRow.newtVersion}</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
if (originalRow.type === "wireguard") {
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">WireGuard</Badge>
</div>
);
}
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">Local</Badge>
</div>
);
}
}
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: () => {
return <span className="p-3">{t("exitNode")}</span>;
},
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 (
<Badge variant="secondary">
Pangolin {capitalizedName}
</Badge>
);
}
if (originalRow.remoteExitNodeId) {
return (
<Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
>
<Button variant="outline">
{originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return <span>{originalRow.exitNodeName}</span>;
}
},
{
accessorKey: "address",
header: () => {
return <span className="p-3">{t("address")}</span>;
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
const isApproving = approvingIds.has(siteRow.id);
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={isApproving}
onClick={() => approveSite(siteRow.id)}
>
<Check className="mr-2 w-4 h-4" />
{t("approve")}
</Button>
</div>
);
}
}
];
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 (
<ControlledDataTable
columns={columns}
rows={sites}
tableId="pending-sites-table"
searchPlaceholder={t("searchSitesProgress")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
columnVisibility={{
niceId: false,
nice: false,
exitNode: false,
address: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}