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 NetworksPage from "@/pages/org/NetworksPage";
|
||||||
import DevicesPage from "@/pages/org/DevicesPage";
|
import DevicesPage from "@/pages/org/DevicesPage";
|
||||||
import AccessPage from "@/pages/org/AccessPage";
|
import AccessPage from "@/pages/org/AccessPage";
|
||||||
|
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
|
||||||
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
||||||
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
||||||
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
||||||
@@ -195,6 +196,7 @@ function AppRoutes() {
|
|||||||
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
|
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
|
||||||
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
|
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
|
||||||
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></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 */}
|
{/* Admin routes — org admin/owner only */}
|
||||||
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
|
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const orgAdminNavItems = [
|
|||||||
{ title: "Policies", url: "/org/policies", icon: Settings },
|
{ title: "Policies", url: "/org/policies", icon: Settings },
|
||||||
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
|
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
|
||||||
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
|
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
|
||||||
|
{ title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
|
|||||||
+75
-9
@@ -1370,6 +1370,14 @@ export const api = {
|
|||||||
{ method: "DELETE" }, true, requestConfig,
|
{ 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) =>
|
getNetworkMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ memberships: DeviceNetworkMembership[]; count: number }>(
|
request<{ memberships: DeviceNetworkMembership[]; count: number }>(
|
||||||
`/organizations/${orgId}/networks/${networkId}/members`,
|
`/organizations/${orgId}/networks/${networkId}/members`,
|
||||||
@@ -1442,6 +1450,12 @@ export const api = {
|
|||||||
`/organizations/${orgId}/approvals`, {}, true, requestConfig,
|
`/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) =>
|
listPendingApprovals: (orgId: string, networkId?: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ approvals: UserNetworkApproval[]; count: number }>(
|
request<{ approvals: UserNetworkApproval[]; count: number }>(
|
||||||
`/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`,
|
`/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`,
|
||||||
@@ -1558,25 +1572,25 @@ export const api = {
|
|||||||
true, requestConfig,
|
true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── ZeroTier Controller (admin) ──────────────────────────────────────────
|
// ── ZeroTier Controller (org-scoped admin) ─────────────────────────────────
|
||||||
getZtStatus: (requestConfig?: RequestConfig) =>
|
getZtStatus: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ status: Record<string, unknown> }>(
|
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 }>(
|
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 }>(
|
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 }>(
|
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) =>
|
triggerReconciliation: (requestConfig?: RequestConfig) =>
|
||||||
@@ -1584,6 +1598,26 @@ export const api = {
|
|||||||
"/admin/zerotier/reconcile",
|
"/admin/zerotier/reconcile",
|
||||||
{ method: "POST" }, true, requestConfig,
|
{ 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;
|
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 {
|
export interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -2038,4 +2087,21 @@ export interface ZeroTierNetwork {
|
|||||||
ip_assignment_pools: Record<string, unknown>[];
|
ip_assignment_pools: Record<string, unknown>[];
|
||||||
routes: 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 {
|
try {
|
||||||
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
||||||
api.zerotier.listPendingApprovals(orgId),
|
api.zerotier.listPendingApprovals(orgId),
|
||||||
api.zerotier.listMyApprovals(orgId),
|
api.zerotier.adminListAllApprovals(orgId),
|
||||||
api.zerotier.listSessions(orgId),
|
api.zerotier.listSessions(orgId),
|
||||||
api.zerotier.listNetworks(orgId),
|
api.zerotier.listNetworks(orgId),
|
||||||
api.organizations.getMembers(orgId),
|
api.organizations.getMembers(orgId),
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Ban,
|
Ban,
|
||||||
Zap,
|
Zap,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -53,10 +56,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
AvailableZtNetwork,
|
||||||
PortalNetwork,
|
PortalNetwork,
|
||||||
DeviceNetworkMembership,
|
DeviceNetworkMembership,
|
||||||
UserNetworkApproval,
|
UserNetworkApproval,
|
||||||
@@ -149,6 +154,13 @@ export default function NetworksPage() {
|
|||||||
const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(null);
|
const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
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 () => {
|
const fetchNetworks = useCallback(async () => {
|
||||||
if (!orgId) { setIsLoading(false); return; }
|
if (!orgId) { setIsLoading(false); return; }
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -193,6 +205,37 @@ export default function NetworksPage() {
|
|||||||
setNetworkRequests([]);
|
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 () => {
|
const handleCreate = async () => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
@@ -297,6 +340,9 @@ export default function NetworksPage() {
|
|||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<Button onClick={() => setShowCreate(true)} className="gap-2">
|
||||||
<Plus className="w-4 h-4" /> Add Network
|
<Plus className="w-4 h-4" /> Add Network
|
||||||
</Button>
|
</Button>
|
||||||
@@ -387,6 +433,128 @@ export default function NetworksPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Create Network Dialog */}
|
||||||
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
|
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<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