Feat: Multi Tenant ZeroTier Config

This commit is contained in:
2026-03-29 21:33:37 +05:45
parent 6ab4b8c2a5
commit a0532ba010
6 changed files with 661 additions and 10 deletions
+2
View File
@@ -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() {
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} />
<Route path="/org/zerotier/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
{/* Admin routes — org admin/owner only */}
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
+1
View File
@@ -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 = [
+75 -9
View File
@@ -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<string, unknown> }>(
"/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<string, unknown>[];
routes: Record<string, unknown>[];
};
}
/** 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";
}
+1 -1
View File
@@ -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),
+168
View File
@@ -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<PortalNetwork | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// ZeroTier network picker
const [showZtPicker, setShowZtPicker] = useState(false);
const [ztNetworks, setZtNetworks] = useState<AvailableZtNetwork[]>([]);
const [isLoadingZtNetworks, setIsLoadingZtNetworks] = useState(false);
const [ztNetworksError, setZtNetworksError] = useState<string | null>(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"
/>
</div>
<Button variant="outline" onClick={openZtPicker} className="gap-2">
<Download className="w-4 h-4" /> Import from ZeroTier
</Button>
<Button onClick={() => setShowCreate(true)} className="gap-2">
<Plus className="w-4 h-4" /> Add Network
</Button>
@@ -387,6 +433,128 @@ export default function NetworksPage() {
</CardContent>
</Card>
{/* ZeroTier Network Picker */}
<Sheet open={showZtPicker} onOpenChange={(open) => { if (!open) setShowZtPicker(false); }}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto flex flex-col">
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary" />
Import from ZeroTier
</SheetTitle>
<SheetDescription>
Networks found in your ZeroTier account. Click one to import it into Secuird.
</SheetDescription>
</SheetHeader>
{/* Search + refresh */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search ZeroTier networks…"
value={ztPickerSearch}
onChange={(e) => setZtPickerSearch(e.target.value)}
className="pl-10"
/>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={openZtPicker} disabled={isLoadingZtNetworks}>
<RefreshCw className={cn("w-4 h-4", isLoadingZtNetworks && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh list</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{isLoadingZtNetworks ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground mr-2" />
<span className="text-muted-foreground">Loading ZeroTier networks</span>
</div>
) : ztNetworksError ? (
<div className="flex flex-col items-center gap-3 py-12 text-center px-4">
<AlertCircle className="w-8 h-8 text-destructive" />
<p className="text-sm text-destructive font-medium">Could not load ZeroTier networks</p>
<p className="text-xs text-muted-foreground">{ztNetworksError}</p>
<p className="text-xs text-muted-foreground mt-1">
Make sure your ZeroTier credentials are configured under{" "}
<strong>Settings ZeroTier Configuration</strong>.
</p>
</div>
) : ztNetworks.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-12 text-center text-muted-foreground">
<Network className="w-8 h-8" />
<p className="text-sm font-medium">No ZeroTier networks found</p>
<p className="text-xs">Your ZeroTier account has no networks yet.</p>
</div>
) : (
<div className="space-y-2 flex-1 overflow-y-auto">
{ztNetworks
.filter((n) => {
const q = ztPickerSearch.toLowerCase();
return !q || n.name.toLowerCase().includes(q) || n.id.toLowerCase().includes(q);
})
.map((ztNet) => (
<div
key={ztNet.id}
className={cn(
"flex items-center gap-3 p-3 border rounded-lg",
ztNet.already_managed
? "bg-muted/40 opacity-70"
: "hover:bg-accent/50 cursor-pointer transition-colors",
)}
onClick={() => !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);
}
}}
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Network className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm truncate">{ztNet.name}</p>
{ztNet.already_managed && (
<Badge className="text-xs bg-green-500/10 text-green-700 border-green-200">
<CheckCircle className="w-3 h-3 mr-1" />
{ztNet.portal_network_name
? `Managed as "${ztNet.portal_network_name}"`
: "Already managed"}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono">{ztNet.id}</p>
{(ztNet.online_member_count > 0 || ztNet.total_member_count > 0) && (
<p className="text-xs text-muted-foreground mt-0.5">
{ztNet.online_member_count} online · {ztNet.total_member_count} total members
</p>
)}
</div>
{!ztNet.already_managed && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 gap-1"
onClick={(e) => { e.stopPropagation(); importZtNetwork(ztNet); }}
>
<Plus className="w-3 h-3" />
Import
</Button>
)}
</div>
))}
</div>
)}
</SheetContent>
</Sheet>
{/* Create Network Dialog */}
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
<DialogContent className="sm:max-w-lg">
+414
View File
@@ -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<Mode, { label: string; defaultUrl: string; description: string }> = {
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<Mode | "">("");
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 (
<div className="container max-w-2xl py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">ZeroTier Configuration</h1>
<p className="text-sm text-muted-foreground mt-1">
Configure your organization's ZeroTier credentials.
</p>
</div>
{/* Configure form */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{cfg?.zt_api_token_set ? "Update Credentials" : "Set Credentials"}
</CardTitle>
<CardDescription>
{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."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* Mode */}
<div className="space-y-1.5">
<Label htmlFor="zt-mode">Mode <span className="text-xs text-destructive font-medium">(required)</span></Label>
<Select
value={mode || cfg?.zt_api_mode || ""}
onValueChange={(v) => {
const m = v as Mode;
setMode(m);
// Central always uses a fixed URL — lock it in.
// Controller: clear so the user can supply their own.
setUrl(m === "central" ? MODE_HELP.central.defaultUrl : "");
}}
>
<SelectTrigger id="zt-mode">
<SelectValue placeholder="Select mode (required)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="central">ZeroTier Central (SaaS)</SelectItem>
<SelectItem value="controller">Self-hosted Controller</SelectItem>
</SelectContent>
</Select>
{modeHelp && (
<p className="text-xs text-muted-foreground">{modeHelp.description}</p>
)}
</div>
{/* Token */}
<div className="space-y-1.5">
<Label htmlFor="zt-token">
API Token
{cfg?.zt_api_token_set && (
<span className="ml-2 text-xs text-muted-foreground">(leave blank to keep existing)</span>
)}
</Label>
<div className="relative">
<Input
id="zt-token"
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => 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"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowToken((v) => !v)}
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Controller URL */}
<div className="space-y-1.5">
<Label htmlFor="zt-url">
{selectedMode === "central" ? "API URL" : "Controller URL"}
{selectedMode !== "central" && (
<span className="ml-1.5 text-xs text-destructive font-medium">
(required)
</span>
)}
</Label>
<div className="relative">
<Input
id="zt-url"
type="url"
value={
selectedMode === "central"
? MODE_HELP.central.defaultUrl
: url
}
onChange={(e) => {
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" && (
<Lock className="absolute right-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
)}
</div>
{selectedMode === "central" && (
<p className="text-xs text-muted-foreground">
ZeroTier Central always uses this fixed endpoint — it cannot be changed.
</p>
)}
</div>
{/* Info alert */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-xs">
A connectivity test runs automatically when you save. Credentials are only persisted
if the test passes — bad tokens or unreachable URLs will be rejected.
</AlertDescription>
</Alert>
{/* Connectivity test result from last save */}
{saveMutation.isSuccess && (
<ConnectivityResult
ok={saveMutation.data.connectivity_test.ok}
error={saveMutation.data.connectivity_test.error}
/>
)}
{/* Persistent inline error after a failed save */}
{saveMutation.isError && (
<div className="flex items-start gap-2 text-sm text-red-800 bg-red-50 border border-red-200 rounded-md px-3 py-2">
<XCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">Save failed — credentials were NOT saved</p>
<p className="text-xs mt-0.5 opacity-80">{(() => {
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";
})()}</p>
</div>
</div>
)}
<div className="flex items-center justify-between pt-1">
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !canSave}
>
{saveMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving…</>
) : (
<><Save className="h-4 w-4 mr-2" /> Save & Test</>
)}
</Button>
{cfg?.zt_api_token_set && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
{/* Delete confirm */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove ZeroTier config for {selectedOrg?.name}?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Remove"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ── 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 (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span>Connectivity verified ZeroTier is reachable with these credentials.</span>
</div>
);
}