Feat: Multi Tenant ZeroTier Config
This commit is contained in:
@@ -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>} />
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user