diff --git a/messages/en-US.json b/messages/en-US.json index 7a3fde1d4..ad64cb5e2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -331,6 +331,11 @@ "provisioningKeysTitle": "Provisioning Key", "provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", + "provisioningManage": "Provisioning", + "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", + "pendingSites": "Pending Sites", + "siteApproveSuccess": "Site approved successfully", + "siteApproveError": "Error approving site", "provisioningKeys": "Provisioning Keys", "searchProvisioningKeys": "Search provisioning keys...", "provisioningKeysAdd": "Generate Provisioning Key", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bb05ca358..a64aad2ef 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -100,7 +100,8 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + status: varchar("status").$type<"pending" | "accepted">() }); export const resources = pgTable("resources", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5d7c01377..52969d183 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -110,7 +110,8 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + status: text("status").$type<"pending" | "accepted">() }); export const resources = sqliteTable("resources", { diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 427ac173f..4923fb88e 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -196,7 +196,8 @@ export async function registerNewt( name: niceId, niceId, type: "newt", - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "pending" }) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 4edebb080..bf62e93cd 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -298,7 +298,8 @@ export async function createSite( niceId, address: updatedAddress || null, type, - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "accepted" }) .returning(); } else if (type == "wireguard") { @@ -355,7 +356,8 @@ export async function createSite( niceId, subnet, type, - pubKey: pubKey || null + pubKey: pubKey || null, + status: "accepted" }) .returning(); } else if (type == "local") { @@ -370,7 +372,8 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/32" + subnet: "0.0.0.0/32", + status: "accepted" }) .returning(); } else { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index a244c650c..1d7e99042 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -135,6 +135,15 @@ const listSitesSchema = z.object({ .openapi({ type: "boolean", description: "Filter by online status" + }), + status: z + .enum(["pending", "accepted"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "accepted"], + description: "Filter by site status" }) }); @@ -156,7 +165,8 @@ function querySitesBase() { exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, - remoteExitNodeId: remoteExitNodes.remoteExitNodeId + remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + status: sites.status }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) @@ -245,7 +255,7 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const { pageSize, page, query, sort_by, order, online } = + const { pageSize, page, query, sort_by, order, online, status } = parsedQuery.data; const accessibleSiteIds = accessibleSites.map((site) => site.siteId); @@ -273,6 +283,9 @@ export async function listSites( if (typeof online !== "undefined") { conditions.push(eq(sites.online, online)); } + if (typeof status !== "undefined") { + conditions.push(eq(sites.status, status)); + } const baseQuery = querySitesBase().where(and(...conditions)); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index ca0f76783..244adf7b8 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -19,7 +19,8 @@ const updateSiteBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + status: z.enum(["pending", "accepted"]).optional(), // remoteSubnets: z.string().optional() // subdomain: z // .string() diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx new file mode 100644 index 000000000..1cfbf5b05 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -0,0 +1,56 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import SiteProvisioningKeysTable, { + SiteProvisioningKeyRow +} from "../../../../../components/SiteProvisioningKeysTable"; +import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; +import { getTranslations } from "next-intl/server"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; + +type ProvisioningKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ProvisioningKeysPage( + props: ProvisioningKeysPageProps +) { + const params = await props.params; + const t = await getTranslations(); + + let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = + []; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/site-provisioning-keys`, + await authCookieHeader() + ); + siteProvisioningKeys = res.data.data.siteProvisioningKeys; + } catch (e) {} + + const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ + name: k.name, + id: k.siteProvisioningKeyId, + key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, + createdAt: k.createdAt, + lastUsed: k.lastUsed, + maxBatchSize: k.maxBatchSize, + numUsed: k.numUsed, + validUntil: k.validUntil + })); + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/layout.tsx b/src/app/[orgId]/settings/provisioning/layout.tsx new file mode 100644 index 000000000..bd2da7812 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +interface ProvisioningLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +} + +export default async function ProvisioningLayout({ + children, + params +}: ProvisioningLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("provisioningKeys"), + href: `/${orgId}/settings/provisioning/keys` + }, + { + title: t("pendingSites"), + href: `/${orgId}/settings/provisioning/pending` + } + ]; + + return ( + <> + + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx index e8b53104f..51db66c2d 100644 --- a/src/app/[orgId]/settings/provisioning/page.tsx +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -1,60 +1,10 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import SiteProvisioningKeysTable, { - SiteProvisioningKeyRow -} from "../../../../components/SiteProvisioningKeysTable"; -import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; -import { getTranslations } from "next-intl/server"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { redirect } from "next/navigation"; type ProvisioningPageProps = { params: Promise<{ orgId: string }>; }; -export const dynamic = "force-dynamic"; - export default async function ProvisioningPage(props: ProvisioningPageProps) { const params = await props.params; - const t = await getTranslations(); - - let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = - []; - try { - const res = await internal.get< - AxiosResponse - >( - `/org/${params.orgId}/site-provisioning-keys`, - await authCookieHeader() - ); - siteProvisioningKeys = res.data.data.siteProvisioningKeys; - } catch (e) {} - - const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ - name: k.name, - id: k.siteProvisioningKeyId, - key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, - createdAt: k.createdAt, - lastUsed: k.lastUsed, - maxBatchSize: k.maxBatchSize, - numUsed: k.numUsed, - validUntil: k.validUntil - })); - - return ( - <> - - - - - - - ); -} + redirect(`/${params.orgId}/settings/provisioning/keys`); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx new file mode 100644 index 000000000..78178cb14 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -0,0 +1,82 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListSitesResponse } from "@server/routers/site"; +import { AxiosResponse } from "axios"; +import { SiteRow } from "@app/components/SitesTable"; +import PendingSitesTable from "@app/components/PendingSitesTable"; +import { getTranslations } from "next-intl/server"; + +type PendingSitesPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +export default async function PendingSitesPage(props: PendingSitesPageProps) { + const params = await props.params; + + const incomingSearchParams = new URLSearchParams(await props.searchParams); + incomingSearchParams.set("status", "pending"); + + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${params.orgId}/sites?${incomingSearchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; + } catch (e) {} + + const t = await getTranslations(); + + function formatSize(mb: number, type: string): string { + if (type === "local") { + return "-"; + } + if (mb >= 1024 * 1024) { + return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) }); + } else if (mb >= 1024) { + return t("gigabytes", { count: (mb / 1024).toFixed(2) }); + } else { + return t("megabytes", { count: mb.toFixed(2) }); + } + } + + const siteRows: SiteRow[] = sites.map((site) => ({ + name: site.name, + id: site.siteId, + nice: site.niceId.toString(), + address: site.address?.split("/")[0], + mbIn: formatSize(site.megabytesIn || 0, site.type), + mbOut: formatSize(site.megabytesOut || 0, site.type), + orgId: params.orgId, + type: site.type as any, + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, + exitNodeName: site.exitNodeName || undefined, + exitNodeEndpoint: site.exitNodeEndpoint || undefined, + remoteExitNodeId: (site as any).remoteExitNodeId || undefined + })); + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 161c757f6..5839ba2be 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -18,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "accepted"); let sites: ListSitesResponse["sites"] = []; let pagination: ListSitesResponse["pagination"] = { diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx new file mode 100644 index 000000000..f9126a091 --- /dev/null +++ b/src/components/PendingSitesTable.tsx @@ -0,0 +1,440 @@ +"use client"; + +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { build } from "@server/build"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ArrowUpRight, + Check, + ChevronsUpDownIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; +import { SiteRow } from "./SitesTable"; + +type PendingSitesTableProps = { + sites: SiteRow[]; + pagination: PaginationState; + orgId: string; + rowCount: number; +}; + +export default function PendingSitesTable({ + sites, + orgId, + pagination, + rowCount +}: PendingSitesTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [isRefreshing, startTransition] = useTransition(); + const [approvingIds, setApprovingIds] = useState>(new Set()); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function refreshData() { + startTransition(async () => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + async function approveSite(siteId: number) { + setApprovingIds((prev) => new Set(prev).add(siteId)); + try { + await api.post(`/site/${siteId}`, { status: "accepted" }); + toast({ + title: t("success"), + description: t("siteApproveSuccess"), + variant: "default" + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteApproveError"), + description: formatAxiosError(e, t("siteApproveError")) + }); + } finally { + setApprovingIds((prev) => { + const next = new Set(prev); + next.delete(siteId); + return next; + }); + } + } + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + } + }, + { + id: "niceId", + accessorKey: "nice", + friendlyName: t("identifier"), + enableHiding: true, + header: () => { + return {t("identifier")}; + }, + cell: ({ row }) => { + return {row.original.nice || "-"}; + } + }, + { + accessorKey: "online", + friendlyName: t("online"), + header: () => { + return ( + + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if ( + originalRow.type == "newt" || + originalRow.type == "wireguard" + ) { + if (originalRow.online) { + return ( + +
+ {t("online")} +
+ ); + } else { + return ( + +
+ {t("offline")} +
+ ); + } + } else { + return -; + } + } + }, + { + accessorKey: "mbIn", + friendlyName: t("dataIn"), + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: t("dataOut"), + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + } + }, + { + accessorKey: "type", + friendlyName: t("type"), + header: () => { + return {t("type")}; + }, + cell: ({ row }) => { + const originalRow = row.original; + + if (originalRow.type === "newt") { + return ( +
+ +
+ Newt + {originalRow.newtVersion && ( + v{originalRow.newtVersion} + )} +
+
+ {originalRow.newtUpdateAvailable && ( + + )} +
+ ); + } + + if (originalRow.type === "wireguard") { + return ( +
+ WireGuard +
+ ); + } + + if (originalRow.type === "local") { + return ( +
+ Local +
+ ); + } + } + }, + { + accessorKey: "exitNode", + friendlyName: t("exitNode"), + header: () => { + return {t("exitNode")}; + }, + cell: ({ row }) => { + const originalRow = row.original; + if (!originalRow.exitNodeName) { + return "-"; + } + + const isCloudNode = + build == "saas" && + originalRow.exitNodeName && + [ + "mercury", + "venus", + "earth", + "mars", + "jupiter", + "saturn", + "uranus", + "neptune" + ].includes(originalRow.exitNodeName.toLowerCase()); + + if (isCloudNode) { + const capitalizedName = + originalRow.exitNodeName.charAt(0).toUpperCase() + + originalRow.exitNodeName.slice(1).toLowerCase(); + return ( + + Pangolin {capitalizedName} + + ); + } + + if (originalRow.remoteExitNodeId) { + return ( + + + + ); + } + + return {originalRow.exitNodeName}; + } + }, + { + accessorKey: "address", + header: () => { + return {t("address")}; + }, + cell: ({ row }: { row: any }) => { + const originalRow = row.original; + return originalRow.address ? ( +
+ {originalRow.address} +
+ ) : ( + "-" + ); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const siteRow = row.original; + const isApproving = approvingIds.has(siteRow.id); + return ( +
+ +
+ ); + } + } + ]; + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + + ); +} \ No newline at end of file