From a0532ba010dfb8d34b83b0cc9db06e1a5022e190 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Sun, 29 Mar 2026 21:33:37 +0545 Subject: [PATCH] Feat: Multi Tenant ZeroTier Config --- src/App.tsx | 2 + src/components/navigation/AppSidebar.tsx | 1 + src/lib/api.ts | 84 ++++- src/pages/org/AccessPage.tsx | 2 +- src/pages/org/NetworksPage.tsx | 168 +++++++++ src/pages/org/ZeroTierConfigPage.tsx | 414 +++++++++++++++++++++++ 6 files changed, 661 insertions(+), 10 deletions(-) create mode 100644 src/pages/org/ZeroTierConfigPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8bce14c..01e0de9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import NetworksPage from "@/pages/org/NetworksPage"; import DevicesPage from "@/pages/org/DevicesPage"; import AccessPage from "@/pages/org/AccessPage"; +import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OrgSetupPage from "@/pages/auth/OrgSetupPage"; @@ -195,6 +196,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Admin routes — org admin/owner only */} } /> diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 06b2685..ed9a78d 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -63,6 +63,7 @@ const orgAdminNavItems = [ { title: "Policies", url: "/org/policies", icon: Settings }, { title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network }, { title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert }, + { title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings }, ]; const adminNavItems = [ diff --git a/src/lib/api.ts b/src/lib/api.ts index 9322886..87887ef 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1370,6 +1370,14 @@ export const api = { { method: "DELETE" }, true, requestConfig, ), + /** List all ZeroTier networks from the org's controller/account, annotated + * with whether each is already managed as a portal network. */ + listAvailableZtNetworks: (orgId: string, requestConfig?: RequestConfig) => + request<{ networks: AvailableZtNetwork[]; count: number; zt_error?: string }>( + `/organizations/${orgId}/zerotier/available-networks`, + {}, true, requestConfig, + ), + getNetworkMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) => request<{ memberships: DeviceNetworkMembership[]; count: number }>( `/organizations/${orgId}/networks/${networkId}/members`, @@ -1442,6 +1450,12 @@ export const api = { `/organizations/${orgId}/approvals`, {}, true, requestConfig, ), + adminListAllApprovals: (orgId: string, networkId?: string, state?: string, requestConfig?: RequestConfig) => + request<{ approvals: UserNetworkApproval[]; count: number }>( + `/organizations/${orgId}/admin/approvals${networkId || state ? `?${new URLSearchParams(Object.fromEntries(Object.entries({ network_id: networkId, state }).filter(([, v]) => v != null) as [string, string][]))}` : ""}`, + {}, true, requestConfig, + ), + listPendingApprovals: (orgId: string, networkId?: string, requestConfig?: RequestConfig) => request<{ approvals: UserNetworkApproval[]; count: number }>( `/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`, @@ -1558,25 +1572,25 @@ export const api = { true, requestConfig, ), - // ── ZeroTier Controller (admin) ────────────────────────────────────────── - getZtStatus: (requestConfig?: RequestConfig) => + // ── ZeroTier Controller (org-scoped admin) ───────────────────────────────── + getZtStatus: (orgId: string, requestConfig?: RequestConfig) => request<{ status: Record }>( - "/admin/zerotier/status", {}, true, requestConfig, + `/admin/zerotier/status?org_id=${orgId}`, {}, true, requestConfig, ), - listZtNetworks: (requestConfig?: RequestConfig) => + listZtNetworks: (orgId: string, requestConfig?: RequestConfig) => request<{ networks: ZeroTierNetwork[]; count: number }>( - "/admin/zerotier/networks", {}, true, requestConfig, + `/admin/zerotier/networks?org_id=${orgId}`, {}, true, requestConfig, ), - getZtNetwork: (networkId: string, requestConfig?: RequestConfig) => + getZtNetwork: (orgId: string, networkId: string, requestConfig?: RequestConfig) => request<{ network: ZeroTierNetwork }>( - `/admin/zerotier/networks/${networkId}`, {}, true, requestConfig, + `/admin/zerotier/networks/${networkId}?org_id=${orgId}`, {}, true, requestConfig, ), - listZtMembers: (networkId: string, requestConfig?: RequestConfig) => + listZtMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) => request<{ members: ZeroTierMember[]; count: number }>( - `/admin/zerotier/networks/${networkId}/members`, {}, true, requestConfig, + `/admin/zerotier/networks/${networkId}/members?org_id=${orgId}`, {}, true, requestConfig, ), triggerReconciliation: (requestConfig?: RequestConfig) => @@ -1584,6 +1598,26 @@ export const api = { "/admin/zerotier/reconcile", { method: "POST" }, true, requestConfig, ), + + // ── Per-org ZeroTier config ────────────────────────────────────────────── + getOrgZtConfig: (orgId: string, requestConfig?: RequestConfig) => + request<{ zerotier_config: ZeroTierOrgConfig }>( + `/organizations/${orgId}/zerotier-config`, + {}, true, requestConfig, + ), + + setOrgZtConfig: (orgId: string, data: ZeroTierOrgConfigInput, requestConfig?: RequestConfig) => + request<{ zerotier_config: ZeroTierOrgConfig; connectivity_test: { ok: boolean; error: string | null } }>( + `/organizations/${orgId}/zerotier-config`, + { method: "PUT", body: JSON.stringify(data) }, + true, requestConfig, + ), + + deleteOrgZtConfig: (orgId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/zerotier-config`, + { method: "DELETE" }, true, requestConfig, + ), }, }; @@ -1894,6 +1928,21 @@ export interface PortalNetwork { active_membership_count?: number; } +/** A ZeroTier network returned from the controller, annotated with whether + * it is already managed as a portal network in Secuird. */ +export interface AvailableZtNetwork { + id: string; + name: string; + description: string | null; + owner_id: string | null; + online_member_count: number; + authorized_member_count: number; + total_member_count: number; + already_managed: boolean; + portal_network_id: string | null; + portal_network_name: string | null; +} + export interface Device { id: string; user_id: string; @@ -2038,4 +2087,21 @@ export interface ZeroTierNetwork { ip_assignment_pools: Record[]; routes: Record[]; }; +} + +/** Current per-org ZeroTier config as returned by GET /organizations/:id/zerotier-config */ +export interface ZeroTierOrgConfig { + /** Whether an API token has been saved (the actual value is never returned). */ + zt_api_token_set: boolean; + /** Custom controller / Central base URL, or null when server default is used. */ + zt_api_url: string | null; + /** "central" | "controller", or null when server default is used. */ + zt_api_mode: "central" | "controller" | null; +} + +/** Body for PUT /organizations/:id/zerotier-config */ +export interface ZeroTierOrgConfigInput { + zt_api_token: string; + zt_api_url: string; + zt_api_mode: "central" | "controller"; } \ No newline at end of file diff --git a/src/pages/org/AccessPage.tsx b/src/pages/org/AccessPage.tsx index 3b26731..269b2c0 100644 --- a/src/pages/org/AccessPage.tsx +++ b/src/pages/org/AccessPage.tsx @@ -163,7 +163,7 @@ export default function AccessPage() { try { const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([ api.zerotier.listPendingApprovals(orgId), - api.zerotier.listMyApprovals(orgId), + api.zerotier.adminListAllApprovals(orgId), api.zerotier.listSessions(orgId), api.zerotier.listNetworks(orgId), api.organizations.getMembers(orgId), diff --git a/src/pages/org/NetworksPage.tsx b/src/pages/org/NetworksPage.tsx index c50f215..5c92e7d 100644 --- a/src/pages/org/NetworksPage.tsx +++ b/src/pages/org/NetworksPage.tsx @@ -17,6 +17,9 @@ import { XCircle, Ban, Zap, + Download, + RefreshCw, + AlertCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -53,10 +56,12 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/hooks/use-toast"; import { api, ApiError, + AvailableZtNetwork, PortalNetwork, DeviceNetworkMembership, UserNetworkApproval, @@ -149,6 +154,13 @@ export default function NetworksPage() { const [deleteNetwork, setDeleteNetwork] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + // ZeroTier network picker + const [showZtPicker, setShowZtPicker] = useState(false); + const [ztNetworks, setZtNetworks] = useState([]); + const [isLoadingZtNetworks, setIsLoadingZtNetworks] = useState(false); + const [ztNetworksError, setZtNetworksError] = useState(null); + const [ztPickerSearch, setZtPickerSearch] = useState(""); + const fetchNetworks = useCallback(async () => { if (!orgId) { setIsLoading(false); return; } setIsLoading(true); @@ -193,6 +205,37 @@ export default function NetworksPage() { setNetworkRequests([]); }; + const openZtPicker = async () => { + if (!orgId) return; + setShowZtPicker(true); + setZtPickerSearch(""); + setZtNetworksError(null); + setIsLoadingZtNetworks(true); + try { + const res = await api.zerotier.listAvailableZtNetworks(orgId); + setZtNetworks(res.networks || []); + if (res.zt_error) { + setZtNetworksError(`ZeroTier API error: ${res.zt_error}`); + } + } catch (err) { + setZtNetworksError( + err instanceof ApiError ? err.message : "Failed to load ZeroTier networks.", + ); + setZtNetworks([]); + } finally { + setIsLoadingZtNetworks(false); + } + }; + + /** Pre-fill the Create Network dialog with data from a ZT network and close the picker. */ + const importZtNetwork = (ztNet: AvailableZtNetwork) => { + setCreateZtId(ztNet.id); + setCreateName(ztNet.name && ztNet.name !== ztNet.id ? ztNet.name : ""); + setCreateDesc(ztNet.description ?? ""); + setShowZtPicker(false); + setShowCreate(true); + }; + const handleCreate = async () => { if (!orgId) return; setCreateError(null); @@ -297,6 +340,9 @@ export default function NetworksPage() { className="pl-10" /> + @@ -387,6 +433,128 @@ export default function NetworksPage() { + {/* ZeroTier Network Picker */} + { if (!open) setShowZtPicker(false); }}> + + + + + Import from ZeroTier + + + Networks found in your ZeroTier account. Click one to import it into Secuird. + + + + {/* Search + refresh */} +
+
+ + setZtPickerSearch(e.target.value)} + className="pl-10" + /> +
+ + + + + + Refresh list + + +
+ + {isLoadingZtNetworks ? ( +
+ + Loading ZeroTier networks… +
+ ) : ztNetworksError ? ( +
+ +

Could not load ZeroTier networks

+

{ztNetworksError}

+

+ Make sure your ZeroTier credentials are configured under{" "} + Settings → ZeroTier Configuration. +

+
+ ) : ztNetworks.length === 0 ? ( +
+ +

No ZeroTier networks found

+

Your ZeroTier account has no networks yet.

+
+ ) : ( +
+ {ztNetworks + .filter((n) => { + const q = ztPickerSearch.toLowerCase(); + return !q || n.name.toLowerCase().includes(q) || n.id.toLowerCase().includes(q); + }) + .map((ztNet) => ( +
!ztNet.already_managed && importZtNetwork(ztNet)} + role={ztNet.already_managed ? undefined : "button"} + tabIndex={ztNet.already_managed ? undefined : 0} + onKeyDown={(e) => { + if (!ztNet.already_managed && (e.key === "Enter" || e.key === " ")) { + importZtNetwork(ztNet); + } + }} + > +
+ +
+
+
+

{ztNet.name}

+ {ztNet.already_managed && ( + + + {ztNet.portal_network_name + ? `Managed as "${ztNet.portal_network_name}"` + : "Already managed"} + + )} +
+

{ztNet.id}

+ {(ztNet.online_member_count > 0 || ztNet.total_member_count > 0) && ( +

+ {ztNet.online_member_count} online · {ztNet.total_member_count} total members +

+ )} +
+ {!ztNet.already_managed && ( + + )} +
+ ))} +
+ )} +
+
+ {/* Create Network Dialog */} { if (!open) setShowCreate(false); }}> diff --git a/src/pages/org/ZeroTierConfigPage.tsx b/src/pages/org/ZeroTierConfigPage.tsx new file mode 100644 index 0000000..d250143 --- /dev/null +++ b/src/pages/org/ZeroTierConfigPage.tsx @@ -0,0 +1,414 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Loader2, + CheckCircle2, + XCircle, + Eye, + EyeOff, + Trash2, + Save, + Info, + Lock, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useToast } from "@/hooks/use-toast"; +import { api, ApiError, ZeroTierOrgConfig } from "@/lib/api"; +import { useOrg } from "@/contexts/OrgContext"; + +type Mode = "central" | "controller"; + +const MODE_HELP: Record = { + central: { + label: "ZeroTier Central (SaaS)", + defaultUrl: "https://api.zerotier.com/api/v1", + description: + "Managed by ZeroTier Inc. Get your API token at my.zerotier.com → Account → API Tokens.", + }, + controller: { + label: "Self-hosted Controller", + defaultUrl: "http://localhost:9994", + description: + "Your own zerotier-one daemon. Find the token in /var/lib/zerotier-one/authtoken.secret on the controller host.", + }, +}; + +export default function ZeroTierConfigPage() { + const { selectedOrg } = useOrg(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const orgId = selectedOrg?.id ?? ""; + + // ── form state ────────────────────────────────────────────────────────────── + const [token, setToken] = useState(""); + const [showToken, setShowToken] = useState(false); + const [mode, setMode] = useState(""); + const [url, setUrl] = useState(""); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + // ── query: load current config ────────────────────────────────────────────── + const { data, isLoading, isError } = useQuery({ + queryKey: ["org", orgId, "ztConfig"], + queryFn: () => api.zerotier.getOrgZtConfig(orgId), + enabled: !!orgId, + // Pre-populate form fields once data arrives + select: (resp) => resp.zerotier_config, + }); + + const cfg: ZeroTierOrgConfig | undefined = data; + + // ── mutation: save ────────────────────────────────────────────────────────── + const saveMutation = useMutation({ + mutationFn: () => { + const resolvedMode = mode || cfg?.zt_api_mode; + const resolvedUrl = + resolvedMode === "central" + ? MODE_HELP.central.defaultUrl + : url.trim(); + + return api.zerotier.setOrgZtConfig(orgId, { + zt_api_token: token, + zt_api_mode: resolvedMode as "central" | "controller", + zt_api_url: resolvedUrl, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org", orgId, "ztConfig"] }); + setToken(""); + toast({ + title: "ZeroTier config saved", + description: "Credentials saved and connectivity verified ✓", + }); + }, + onError: (err: Error) => { + let title = "Save failed"; + let description = err.message; + + if (err instanceof ApiError && err.details?.connectivity_test) { + const conn = err.details.connectivity_test as { ok: boolean; error: string | null }; + const raw = conn.error ?? ""; + + if (raw.includes("401") || raw.includes("403")) { + title = "Authentication failed"; + description = + "The ZeroTier controller rejected the token (HTTP 401). " + + "Make sure you're using the controller's authtoken.secret — " + + "ztnet / Central API keys are different from the controller token.\n\n" + + "Credentials were NOT saved."; + } else if (raw.includes("Connection") || raw.includes("timed out")) { + title = "Controller unreachable"; + description = + `Could not connect to the controller URL. ${raw}\n\n` + + "Credentials were NOT saved."; + } else { + title = "Connectivity test failed"; + description = `${raw || "Unknown error"}\n\nCredentials were NOT saved.`; + } + } + + toast({ title, description, variant: "destructive" }); + }, + }); + + // ── mutation: delete ──────────────────────────────────────────────────────── + const deleteMutation = useMutation({ + mutationFn: () => api.zerotier.deleteOrgZtConfig(orgId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org", orgId, "ztConfig"] }); + setToken(""); + setMode(""); + setUrl(""); + setDeleteDialogOpen(false); + toast({ + title: "ZeroTier config removed", + description: "ZeroTier features are disabled until new credentials are configured.", + }); + }, + onError: (err: Error) => { + toast({ title: "Failed to remove", description: err.message, variant: "destructive" }); + }, + }); + + // ── helpers ───────────────────────────────────────────────────────────────── + const handleSave = () => { + const resolvedMode = mode || cfg?.zt_api_mode; + if (!resolvedMode) { + toast({ title: "Mode required", description: "Please select Central or Controller mode.", variant: "destructive" }); + return; + } + if (!token) { + toast({ title: "Token required", description: "Please enter a ZeroTier API token.", variant: "destructive" }); + return; + } + if (resolvedMode !== "central" && !url.trim()) { + toast({ title: "Controller URL required", description: "Please enter the URL for your self-hosted ZeroTier controller (e.g. http://host:9993).", variant: "destructive" }); + return; + } + saveMutation.mutate(); + }; + + const selectedMode = (mode || cfg?.zt_api_mode || null) as Mode | null; + const modeHelp = selectedMode ? MODE_HELP[selectedMode] : null; + const canSave = !!token && !!selectedMode && (selectedMode === "central" || !!url.trim()); + + // ── render ────────────────────────────────────────────────────────────────── + return ( +
+
+

ZeroTier Configuration

+

+ Configure your organization's ZeroTier credentials. +

+
+ + + + {/* Configure form */} + + + + {cfg?.zt_api_token_set ? "Update Credentials" : "Set Credentials"} + + + {cfg?.zt_api_token_set + ? "Enter a new token to replace the existing one. Leave token blank to cancel." + : "Configure a ZeroTier API token for this organization."} + + + + + {/* Mode */} +
+ + + {modeHelp && ( +

{modeHelp.description}

+ )} +
+ + {/* Token */} +
+ +
+ setToken(e.target.value)} + placeholder={ + cfg?.zt_api_token_set + ? "•••••••• (enter new token to replace)" + : selectedMode === "central" + ? "zts1…" + : selectedMode === "controller" + ? "authtoken.secret contents" + : "Enter ZeroTier API token" + } + className="pr-10 font-mono text-sm" + autoComplete="off" + /> + +
+
+ + {/* Controller URL */} +
+ +
+ { + if (selectedMode !== "central") setUrl(e.target.value); + }} + readOnly={selectedMode === "central"} + disabled={selectedMode === "central"} + placeholder={modeHelp?.defaultUrl ?? "https://api.zerotier.com/api/v1"} + className={`font-mono text-sm pr-10 ${ + selectedMode === "central" + ? "bg-muted text-muted-foreground cursor-not-allowed select-all" + : "" + }`} + /> + {selectedMode === "central" && ( + + )} +
+ {selectedMode === "central" && ( +

+ ZeroTier Central always uses this fixed endpoint — it cannot be changed. +

+ )} +
+ + {/* Info alert */} + + + + A connectivity test runs automatically when you save. Credentials are only persisted + if the test passes — bad tokens or unreachable URLs will be rejected. + + + + {/* Connectivity test result from last save */} + {saveMutation.isSuccess && ( + + )} + + {/* Persistent inline error after a failed save */} + {saveMutation.isError && ( +
+ +
+

Save failed — credentials were NOT saved

+

{(() => { + const err = saveMutation.error; + if (err instanceof ApiError && err.details?.connectivity_test) { + const conn = err.details.connectivity_test as { ok: boolean; error: string | null }; + const raw = conn.error ?? ""; + if (raw.includes("401") || raw.includes("403")) + return "The controller rejected the API token (401 Unauthorized). Make sure you are using the controller's authtoken.secret, not a ztnet or Central API key."; + if (raw.includes("Connection") || raw.includes("timed out")) + return `Could not reach the controller at the specified URL. ${raw}`; + return raw || "Connectivity test failed."; + } + return err?.message ?? "Unknown error"; + })()}

+
+
+ )} + +
+ + + {cfg?.zt_api_token_set && ( + + )} +
+
+
+ + + {/* Delete confirm */} + + + + Remove ZeroTier config for {selectedOrg?.name}? + + This will clear all ZeroTier credentials for this organization. All ZeroTier + network operations will be disabled until new credentials are configured. + Existing networks and device memberships are not deleted but will stop working. + + + + Cancel + deleteMutation.mutate()} + disabled={deleteMutation.isPending} + > + {deleteMutation.isPending ? ( + + ) : ( + "Remove" + )} + + + + +
+ ); +} + +// ── sub-component ───────────────────────────────────────────────────────────── + +function ConnectivityResult({ ok, error }: { ok: boolean; error: string | null }) { + if (!ok) return null; // failures are shown via the error toast — don't double-display + return ( +
+ + Connectivity verified — ZeroTier is reachable with these credentials. +
+ ); +}