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