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
+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">