From 4e669160eb6fcf0f600818feb303eb535208ad09 Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Fri, 20 Mar 2026 21:52:52 +1030 Subject: [PATCH] feat(zerotier): add ZeroTier network access management UI Add comprehensive ZeroTier integration and access control: - NetworksPage for managing ZeroTier portal networks - DevicesPage for device registration and membership management - AccessPage for approval workflows, session management, and kill switch - Complete API client with TypeScript types for ZeroTier entities - Navigation updates with ZeroTier section in sidebar --- package-lock.json | 18 +- src/App.tsx | 29 +- src/components/layouts/MarketingLayout.tsx | 204 ++++ src/components/navigation/AppSidebar.tsx | 6 + src/lib/api.ts | 444 ++++++++ src/pages/org/AccessPage.tsx | 840 ++++++++++++++ src/pages/org/DevicesPage.tsx | 1159 ++++++++++++++++++++ src/pages/org/NetworksPage.tsx | 645 +++++++++++ 8 files changed, 3324 insertions(+), 21 deletions(-) create mode 100644 src/components/layouts/MarketingLayout.tsx create mode 100644 src/pages/org/AccessPage.tsx create mode 100644 src/pages/org/DevicesPage.tsx create mode 100644 src/pages/org/NetworksPage.tsx diff --git a/package-lock.json b/package-lock.json index d706b79..e1e8e54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2859,7 +2859,6 @@ "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2877,7 +2876,6 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2889,7 +2887,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2940,7 +2937,6 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -3173,7 +3169,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3378,7 +3373,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3712,7 +3706,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3794,8 +3787,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -3893,7 +3885,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5415,7 +5406,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5602,7 +5592,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5629,7 +5618,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5643,7 +5631,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6197,7 +6184,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6322,7 +6308,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6502,7 +6487,6 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/App.tsx b/src/App.tsx index 0596091..3d68c6b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,17 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; // Layouts import PublicLayout from "@/components/layouts/PublicLayout"; import ProtectedLayout from "@/components/layouts/ProtectedLayout"; +import MarketingLayout from "@/components/layouts/MarketingLayout"; + +// Marketing pages +import HomePage from "@/pages/marketing/HomePage"; +import FeaturesPage from "@/pages/marketing/FeaturesPage"; +import PricingPage from "@/pages/marketing/PricingPage"; +import SecurityPage from "@/pages/marketing/SecurityPage"; +import SSHCertificatesPage from "@/pages/marketing/SSHCertificatesPage"; +import DemoPage from "@/pages/marketing/DemoPage"; // Public pages -import Index from "@/pages/Index"; import LoginPage from "@/pages/auth/LoginPage"; import RegisterPage from "@/pages/auth/RegisterPage"; import VerifyEmailPage from "@/pages/auth/VerifyEmailPage"; @@ -24,7 +32,7 @@ import ActivatePage from "@/pages/auth/ActivatePage"; // User pages import ProfilePage from "@/pages/user/ProfilePage"; -import SecurityPage from "@/pages/user/SecurityPage"; +import UserSecurityPage from "@/pages/user/SecurityPage"; import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage"; import ActivityPage from "@/pages/user/ActivityPage"; import SSHKeysPage from "@/pages/user/SSHKeysPage"; @@ -40,6 +48,9 @@ import CAsPage from "@/pages/org/CAsPage"; import DepartmentsPage from "@/pages/org/DepartmentsPage"; import PrincipalsPage from "@/pages/org/PrincipalsPage"; 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 SystemAuditPage from "@/pages/admin/SystemAuditPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OrgSetupPage from "@/pages/auth/OrgSetupPage"; @@ -127,8 +138,15 @@ function AppRoutes() { - {/* Index redirect */} - } /> + {/* Marketing pages */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Public routes */} }> @@ -160,6 +178,7 @@ function AppRoutes() { {/* Organization routes — org members: overview + own memberships only */} } /> } /> + } /> {/* Organization management routes — org admins/owners only */} } /> @@ -170,6 +189,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> {/* Admin routes — org admin/owner only */} } /> diff --git a/src/components/layouts/MarketingLayout.tsx b/src/components/layouts/MarketingLayout.tsx new file mode 100644 index 0000000..a460d11 --- /dev/null +++ b/src/components/layouts/MarketingLayout.tsx @@ -0,0 +1,204 @@ +import { Link, Outlet, useLocation } from "react-router-dom"; +import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { +Shield, +Key, +CreditCard, +Play, +Lock, +Menu, +X +} from "lucide-react"; +import { useState } from "react"; + +const navigation = [ +{ name: "Features", href: "/features" }, +{ name: "Security", href: "/security" }, +{ name: "SSH Certificates", href: "/ssh-certificates" }, +{ name: "Pricing", href: "/pricing" }, +{ name: "Demo", href: "/demo" }, +]; + +export default function MarketingLayout() { +const location = useLocation(); +const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + +return ( +
+ {/* Header */} +
+ +
+ + {/* Main Content */} +
+ +
+ + {/* Footer */} +
+
+
+ {/* Brand */} +
+ + + Secuird + +

+ Enterprise identity and access management. Secure by design, simple by choice. +

+
+ + {/* Product */} +
+

Product

+
    +
  • Features
  • +
  • Security
  • +
  • SSH Certificates
  • +
  • Pricing
  • +
+
+ + {/* Resources */} +
+

Resources

+ +
+ + {/* Company */} +
+

Company

+ +
+ + {/* Legal */} +
+

Legal

+ +
+
+ +
+

+ © {new Date().getFullYear()} Secuird. All rights reserved. +

+ +
+
+
+
+); +} \ No newline at end of file diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 714830f..f3ac838 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -14,6 +14,9 @@ import { Terminal, ShieldCheck, Key, + Network, + Monitor, + ShieldAlert, } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; @@ -45,6 +48,7 @@ const userNavItems = [ const orgMemberNavItems = [ { title: "Overview", url: "/org", icon: Building2 }, { title: "My Memberships", url: "/org/my-memberships", icon: Layers }, + { title: "ZeroTier Devices", url: "/org/zerotier/devices", icon: Monitor }, ]; // Visible to org admins/owners only (management) @@ -54,6 +58,8 @@ const orgAdminNavItems = [ { title: "Departments", url: "/org/departments", icon: Layers }, { title: "Principals", url: "/org/principals", icon: GitBranch }, { 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 }, ]; const adminNavItems = [ diff --git a/src/lib/api.ts b/src/lib/api.ts index b3bb250..f7aaa48 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1258,6 +1258,264 @@ export const api = { method: 'DELETE', }, true, requestConfig), }, + + zerotier: { + // ── Portal Networks ───────────────────────────────────────────────────────── + listNetworks: (orgId: string, includeInactive = false, requestConfig?: RequestConfig) => + request<{ networks: PortalNetwork[]; count: number }>( + `/organizations/${orgId}/networks${includeInactive ? "?include_inactive=true" : ""}`, + {}, true, requestConfig, + ), + + createNetwork: (orgId: string, data: { + name: string; + zerotier_network_id: string; + description?: string; + environment?: string; + request_mode?: string; + default_activation_lifetime_minutes?: number; + max_activation_lifetime_minutes?: number; + }, requestConfig?: RequestConfig) => + request<{ network: PortalNetwork }>( + `/organizations/${orgId}/networks`, + { method: "POST", body: JSON.stringify(data) }, + true, requestConfig, + ), + + getNetwork: (orgId: string, networkId: string, requestConfig?: RequestConfig) => + request<{ network: PortalNetwork }>( + `/organizations/${orgId}/networks/${networkId}`, + {}, true, requestConfig, + ), + + updateNetwork: (orgId: string, networkId: string, data: Record, requestConfig?: RequestConfig) => + request<{ network: PortalNetwork }>( + `/organizations/${orgId}/networks/${networkId}`, + { method: "PATCH", body: JSON.stringify(data) }, + true, requestConfig, + ), + + deleteNetwork: (orgId: string, networkId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/networks/${networkId}`, + { method: "DELETE" }, true, requestConfig, + ), + + getNetworkMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) => + request<{ memberships: DeviceNetworkMembership[]; count: number }>( + `/organizations/${orgId}/networks/${networkId}/members`, + {}, true, requestConfig, + ), + + getNetworkPendingRequests: (orgId: string, networkId: string, requestConfig?: RequestConfig) => + request<{ requests: UserNetworkApproval[]; count: number }>( + `/organizations/${orgId}/networks/${networkId}/requests`, + {}, true, requestConfig, + ), + + // ── Devices ─────────────────────────────────────────────────────────────── + listDevices: (orgId: string, requestConfig?: RequestConfig) => + request<{ devices: Device[]; count: number }>( + `/organizations/${orgId}/devices`, {}, true, requestConfig, + ), + + registerDevice: (orgId: string, data: { + node_id: string; + nickname?: string; + hostname?: string; + asset_tag?: string; + serial_number?: string; + }, requestConfig?: RequestConfig) => + request<{ device: Device; memberships_created: number }>( + `/organizations/${orgId}/devices`, + { method: "POST", body: JSON.stringify(data) }, + true, requestConfig, + ), + + getDevice: (orgId: string, deviceId: string, requestConfig?: RequestConfig) => + request<{ device: Device }>( + `/organizations/${orgId}/devices/${deviceId}`, + {}, true, requestConfig, + ), + + updateDevice: (orgId: string, deviceId: string, data: { + nickname?: string; + hostname?: string; + asset_tag?: string; + serial_number?: string; + }, requestConfig?: RequestConfig) => + request<{ device: Device }>( + `/organizations/${orgId}/devices/${deviceId}`, + { method: "PATCH", body: JSON.stringify(data) }, + true, requestConfig, + ), + + removeDevice: (orgId: string, deviceId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/devices/${deviceId}`, + { method: "DELETE" }, true, requestConfig, + ), + + // ── Approvals ───────────────────────────────────────────────────────────── + requestAccess: (orgId: string, data: { + portal_network_id: string; + device_id: string; + justification?: string; + }, requestConfig?: RequestConfig) => + request<{ approval: UserNetworkApproval }>( + `/organizations/${orgId}/approvals`, + { method: "POST", body: JSON.stringify(data) }, + true, requestConfig, + ), + + listMyApprovals: (orgId: string, requestConfig?: RequestConfig) => + request<{ approvals: UserNetworkApproval[]; count: number }>( + `/organizations/${orgId}/approvals`, {}, true, requestConfig, + ), + + listPendingApprovals: (orgId: string, networkId?: string, requestConfig?: RequestConfig) => + request<{ approvals: UserNetworkApproval[]; count: number }>( + `/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`, + {}, true, requestConfig, + ), + + approveRequest: (orgId: string, approvalId: string, requestConfig?: RequestConfig) => + request<{ approval: UserNetworkApproval }>( + `/organizations/${orgId}/approvals/${approvalId}/approve`, + { method: "POST" }, true, requestConfig, + ), + + rejectRequest: (orgId: string, approvalId: string, requestConfig?: RequestConfig) => + request<{ approval: UserNetworkApproval }>( + `/organizations/${orgId}/approvals/${approvalId}/reject`, + { method: "POST" }, true, requestConfig, + ), + + revokeApproval: (orgId: string, approvalId: string, requestConfig?: RequestConfig) => + request<{ approval: UserNetworkApproval }>( + `/organizations/${orgId}/approvals/${approvalId}/revoke`, + { method: "POST" }, true, requestConfig, + ), + + assignAccess: (orgId: string, data: { + target_user_id: string; + portal_network_id: string; + justification?: string; + }, requestConfig?: RequestConfig) => + request<{ approval: UserNetworkApproval }>( + `/organizations/${orgId}/approvals/assign`, + { method: "POST", body: JSON.stringify(data) }, + true, requestConfig, + ), + + // ── Memberships ──────────────────────────────────────────────────────────── + listMemberships: (orgId: string, requestConfig?: RequestConfig) => + request<{ memberships: DeviceNetworkMembership[]; count: number }>( + `/organizations/${orgId}/memberships`, {}, true, requestConfig, + ), + + activateMembership: (orgId: string, membershipId: string, lifetimeMinutes?: number, requestConfig?: RequestConfig) => + request<{ session: ActivationSession; membership: DeviceNetworkMembership }>( + `/organizations/${orgId}/memberships/${membershipId}/activate`, + { method: "POST", body: JSON.stringify({ lifetime_minutes: lifetimeMinutes }) }, + true, requestConfig, + ), + + deactivateMembership: (orgId: string, membershipId: string, requestConfig?: RequestConfig) => + request<{ membership: DeviceNetworkMembership }>( + `/organizations/${orgId}/memberships/${membershipId}/deactivate`, + { method: "POST" }, true, requestConfig, + ), + + activateAllMemberships: (orgId: string, lifetimeMinutes?: number, requestConfig?: RequestConfig) => + request<{ sessions: ActivationSession[]; count: number }>( + `/organizations/${orgId}/memberships/activate-all`, + { method: "POST", body: JSON.stringify({ lifetime_minutes: lifetimeMinutes }) }, + true, requestConfig, + ), + + joinNetworkForDevice: (orgId: string, deviceId: string, networkId: string, requestConfig?: RequestConfig) => + request<{ membership: DeviceNetworkMembership }>( + `/organizations/${orgId}/devices/${deviceId}/join-network/${networkId}`, + { method: "POST" }, + true, requestConfig, + ), + + deleteMembership: (orgId: string, membershipId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/memberships/${membershipId}`, + { method: "DELETE" }, + true, requestConfig, + ), + + // ── Admin ───────────────────────────────────────────────────────────────── + adminListAllMemberships: (orgId: string, requestConfig?: RequestConfig) => + request<{ memberships: EnrichedMembership[]; count: number }>( + `/organizations/${orgId}/admin/memberships`, + {}, + true, + requestConfig, + ), + + adminDeleteMembership: (orgId: string, membershipId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/admin/memberships/${membershipId}`, + { method: "DELETE" }, + true, requestConfig, + ), + + // ── Sessions ────────────────────────────────────────────────────────────── + listSessions: (orgId: string, requestConfig?: RequestConfig) => + request<{ sessions: ActivationSession[]; count: number }>( + `/organizations/${orgId}/sessions`, {}, true, requestConfig, + ), + + endSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/sessions/${sessionId}`, + { method: "DELETE" }, true, requestConfig, + ), + + // ── Kill Switch ─────────────────────────────────────────────────────────── + triggerKillSwitch: (orgId: string, data: { + target_user_id: string; + scope?: string; + reason?: string; + network_ids?: string[]; + }, requestConfig?: RequestConfig) => + request<{ event: KillSwitchEvent }>( + `/organizations/${orgId}/kill-switch`, + { method: "POST", body: JSON.stringify(data) }, + true, requestConfig, + ), + + // ── ZeroTier Controller (admin) ────────────────────────────────────────── + getZtStatus: (requestConfig?: RequestConfig) => + request<{ status: Record }>( + "/admin/zerotier/status", {}, true, requestConfig, + ), + + listZtNetworks: (requestConfig?: RequestConfig) => + request<{ networks: ZeroTierNetwork[]; count: number }>( + "/admin/zerotier/networks", {}, true, requestConfig, + ), + + getZtNetwork: (networkId: string, requestConfig?: RequestConfig) => + request<{ network: ZeroTierNetwork }>( + `/admin/zerotier/networks/${networkId}`, {}, true, requestConfig, + ), + + listZtMembers: (networkId: string, requestConfig?: RequestConfig) => + request<{ members: ZeroTierMember[]; count: number }>( + `/admin/zerotier/networks/${networkId}/members`, {}, true, requestConfig, + ), + + triggerReconciliation: (requestConfig?: RequestConfig) => + request<{ networks_processed: number; errors: number }>( + "/admin/zerotier/reconcile", + { method: "POST" }, true, requestConfig, + ), + }, }; // Organization types @@ -1524,4 +1782,190 @@ export function create403Handler(toastFn: (options: { title: string; description variant: "destructive", }); }; +} + +// ── ZeroTier / Portal Network Types ────────────────────────────────────────── + +export type NetworkEnvironment = "production" | "staging" | "development" | "lab"; +export type NetworkRequestMode = "open" | "approval_required" | "invite_only"; +export type ApprovalGrantType = "requested" | "assigned"; +export type ApprovalState = "pending" | "approved" | "rejected" | "revoked" | "suspended"; +export type MembershipState = + | "pending_device_registration" + | "pending_request" + | "pending_manager_approval" + | "approved_inactive" + | "joined_deauthorized" + | "active_authorized" + | "activation_expired" + | "suspended" + | "revoked" + | "rejected"; +export type ActivationEndReason = "expired" | "logout" | "kill_switch" | "manual_revoke" | "approval_revoked" | "admin_action"; +export type KillSwitchScope = "organization" | "global" | "selected_networks"; +export type DeviceStatus = "active" | "inactive"; + +export interface PortalNetwork { + id: string; + organization_id: string; + name: string; + description: string | null; + owner_user_id: string; + zerotier_network_id: string; + environment: NetworkEnvironment; + request_mode: NetworkRequestMode; + default_activation_lifetime_minutes: number; + max_activation_lifetime_minutes: number | null; + is_active: boolean; + created_at: string; + updated_at: string; + deleted_at: string | null; + approved_user_count?: number; + active_membership_count?: number; +} + +export interface Device { + id: string; + user_id: string; + organization_id: string; + node_id: string; + device_nickname: string | null; + hostname: string | null; + asset_tag: string | null; + serial_number: string | null; + status: DeviceStatus; + created_at: string; + updated_at: string; + deleted_at: string | null; + display_name?: string; + active_membership_count?: number; +} + +export interface UserNetworkApproval { + id: string; + organization_id: string; + user_id: string; + portal_network_id: string; + granted_by_user_id: string | null; + grant_type: ApprovalGrantType; + state: ApprovalState; + justification: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; + active_membership_count?: number; +} + +export interface DeviceNetworkMembership { + id: string; + organization_id: string; + user_id: string; + device_id: string; + portal_network_id: string; + user_network_approval_id: string | null; + state: MembershipState; + join_seen: boolean; + currently_authorized: boolean; + approved_for_activation: boolean; + created_at: string; + updated_at: string; + deleted_at: string | null; + active_session: ActivationSession | null; +} + +export interface EnrichedMembership { + id: string; + user_id: string; + user_email: string | null; + user_full_name: string | null; + device_id: string; + device_nickname: string | null; + device_hostname: string | null; + device_node_id: string | null; + device_status: DeviceStatus | null; + portal_network_id: string; + network_name: string | null; + network_environment: NetworkEnvironment | null; + state: MembershipState | null; + join_seen: boolean; + currently_authorized: boolean; + approved_for_activation: boolean; + user_network_approval_id: string | null; + approval_state: ApprovalState | null; + active_session: ActivationSession | null; + created_at: string | null; + updated_at: string | null; +} + +export interface ActivationSession { + id: string; + organization_id: string; + user_id: string; + device_network_membership_id: string; + authenticated_at: string; + expires_at: string; + ended_at: string | null; + end_reason: ActivationEndReason | null; + created_by: string; + created_at: string; + updated_at: string; + deleted_at: string | null; + is_expired: boolean; + is_active: boolean; +} + +export interface KillSwitchEvent { + id: string; + organization_id: string; + target_user_id: string; + scope: KillSwitchScope; + triggered_by_user_id: string; + reason: string | null; + network_ids: string[] | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface ZeroTierMember { + id: string; + network_id: string; + node_id: string; + name: string | null; + description: string | null; + hidden: boolean; + is_authorized: boolean; + display_name: string; + ip_list: string; + last_online: number | null; + last_seen: number | null; + last_seen_str: string; + client_version: string | null; + controller_id: string | null; + config: { + authorized: boolean; + active_bridge: boolean; + ip_assignments: string[]; + creation_time: number | null; + last_authorized_time: number | null; + last_deauthorized_time: number | null; + version_string: string; + }; +} + +export interface ZeroTierNetwork { + id: string; + name: string; + description: string | null; + owner_id: string | null; + online_member_count: number; + authorized_member_count: number; + total_member_count: number; + config: { + name: string; + private: boolean; + creation_time: number | null; + ip_assignment_pools: Record[]; + routes: Record[]; + }; } \ No newline at end of file diff --git a/src/pages/org/AccessPage.tsx b/src/pages/org/AccessPage.tsx new file mode 100644 index 0000000..3b26731 --- /dev/null +++ b/src/pages/org/AccessPage.tsx @@ -0,0 +1,840 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Shield, + CheckCircle, + XCircle, + Clock, + Users, + Zap, + ZapOff, + AlertTriangle, + Loader2, + Search, + MoreHorizontal, + UserPlus, + Trash2, + RefreshCw, + Skull, + Activity, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useToast } from "@/hooks/use-toast"; +import { + api, + ApiError, + UserNetworkApproval, + ActivationSession, + KillSwitchEvent, + PortalNetwork, + OrganizationMember, + ApprovalState, + MembershipState, + EnrichedMembership, + DeviceStatus, +} from "@/lib/api"; +import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; + +function cn(...classes: (string | boolean | undefined | null)[]) { + return classes.filter(Boolean).join(" "); +} + +function formatDate(d: string | null | undefined) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatExpiry(d: string | null | undefined) { + if (!d) return "—"; + const date = new Date(d); + const now = new Date(); + if (date < now) return "Expired"; + const diff = Math.floor((date.getTime() - now.getTime()) / 1000 / 60); + if (diff < 60) return `${diff}m left`; + if (diff < 1440) return `${Math.floor(diff / 60)}h ${diff % 60}m left`; + return `${Math.floor(diff / 1440)}d ${Math.floor((diff % 1440) / 60)}h left`; +} + +function ApprovalStateBadge({ state }: { state: ApprovalState }) { + const config: Record = { + pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: "Pending" }, + approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: , label: "Approved" }, + rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Rejected" }, + revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Revoked" }, + suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: , label: "Suspended" }, + }; + const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state }; + return ( + + {icon}{label} + + ); +} + +export default function AccessPage() { + const { orgId } = useCurrentOrganizationId(); + const { toast } = useToast(); + + const [approvals, setApprovals] = useState([]); + const [pendingApprovals, setPendingApprovals] = useState([]); + const [sessions, setSessions] = useState([]); + const [killSwitchEvents, setKillSwitchEvents] = useState([]); + const [networks, setNetworks] = useState([]); + const [orgMembers, setOrgMembers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [selectedNetworkFilter, setSelectedNetworkFilter] = useState("all"); + + const [approveId, setApproveId] = useState(null); + const [rejectId, setRejectId] = useState(null); + const [revokeId, setRevokeId] = useState(null); + const [isApproving, setIsApproving] = useState(false); + + const [showAssign, setShowAssign] = useState(false); + const [assignUserId, setAssignUserId] = useState(""); + const [assignNetworkId, setAssignNetworkId] = useState(""); + const [assignJustification, setAssignJustification] = useState(""); + const [isAssigning, setIsAssigning] = useState(false); + const [assignError, setAssignError] = useState(null); + + const [showKillSwitch, setShowKillSwitch] = useState(false); + const [killTargetUserId, setKillTargetUserId] = useState(""); + const [killScope, setKillScope] = useState<"organization" | "global">("organization"); + const [killReason, setKillReason] = useState(""); + const [isKilling, setIsKilling] = useState(false); + const [killError, setKillError] = useState(null); + + const [endSessionId, setEndSessionId] = useState(null); + const [isEndingSession, setIsEndingSession] = useState(false); + + const [selectedApproval, setSelectedApproval] = useState(null); + const [allMemberships, setAllMemberships] = useState([]); + const [isAllMembersLoading, setIsAllMembersLoading] = useState(false); + const [allMembersSearch, setAllMembersSearch] = useState(""); + const [allMembersNetworkFilter, setAllMembersNetworkFilter] = useState("all"); + const [allMembersStateFilter, setAllMembersStateFilter] = useState("all"); + const [selectedMembership, setSelectedMembership] = useState(null); + const [adminActivatingId, setAdminActivatingId] = useState(null); + const [adminDeactivatingId, setAdminDeactivatingId] = useState(null); + const [adminDeletingId, setAdminDeletingId] = useState(null); + + const fetchData = useCallback(async () => { + if (!orgId) { setIsLoading(false); return; } + setIsLoading(true); + setError(null); + try { + const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([ + api.zerotier.listPendingApprovals(orgId), + api.zerotier.listMyApprovals(orgId), + api.zerotier.listSessions(orgId), + api.zerotier.listNetworks(orgId), + api.organizations.getMembers(orgId), + api.zerotier.adminListAllMemberships(orgId), + ]); + if (pendingRes.status === "fulfilled") setPendingApprovals(pendingRes.value.approvals || []); + if (allApprovalsRes.status === "fulfilled") setApprovals(allApprovalsRes.value.approvals || []); + if (sessionsRes.status === "fulfilled") setSessions(sessionsRes.value.sessions || []); + if (networksRes.status === "fulfilled") setNetworks(networksRes.value.networks || []); + if (membersRes.status === "fulfilled") setOrgMembers(membersRes.value.members || []); + if (allMemsRes.status === "fulfilled") setAllMemberships(allMemsRes.value.memberships || []); + } catch { + setError("Failed to load access data. Please try again."); + } finally { + setIsLoading(false); + } + }, [orgId]); + + useEffect(() => { + setApprovals([]); + setPendingApprovals([]); + fetchData(); + }, [fetchData]); + + const handleApprove = async (approvalId: string) => { + if (!orgId) return; + setApproveId(approvalId); + setIsApproving(true); + try { + await api.zerotier.approveRequest(orgId, approvalId); + toast({ title: "Request approved" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to approve", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setApproveId(null); + } + }; + + const handleReject = async (approvalId: string) => { + if (!orgId) return; + setRejectId(approvalId); + setIsApproving(true); + try { + await api.zerotier.rejectRequest(orgId, approvalId); + toast({ title: "Request rejected" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to reject", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setRejectId(null); + } + }; + + const handleRevoke = async (approvalId: string) => { + if (!orgId) return; + setRevokeId(approvalId); + setIsApproving(true); + try { + await api.zerotier.revokeApproval(orgId, approvalId); + toast({ title: "Approval revoked" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to revoke", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setRevokeId(null); + } + }; + + const handleAssign = async () => { + if (!orgId) return; + setAssignError(null); + if (!assignUserId) { setAssignError("Please select a user."); return; } + if (!assignNetworkId) { setAssignError("Please select a network."); return; } + setIsAssigning(true); + try { + await api.zerotier.assignAccess(orgId, { + target_user_id: assignUserId, + portal_network_id: assignNetworkId, + justification: assignJustification.trim() || undefined, + }); + toast({ title: "Access assigned", description: "The user can now register devices for this network." }); + setShowAssign(false); + setAssignUserId(""); setAssignNetworkId(""); setAssignJustification(""); + fetchData(); + } catch (err) { + setAssignError(err instanceof ApiError ? err.message : "Failed to assign access."); + } finally { + setIsAssigning(false); + } + }; + + const handleKillSwitch = async () => { + if (!orgId) return; + setKillError(null); + if (!killTargetUserId) { setKillError("Please select a user."); return; } + setIsKilling(true); + try { + await api.zerotier.triggerKillSwitch(orgId, { + target_user_id: killTargetUserId, + scope: killScope, + reason: killReason.trim() || undefined, + }); + toast({ title: "Kill switch triggered", description: "All active sessions have been terminated." }); + setShowKillSwitch(false); + setKillTargetUserId(""); setKillScope("organization"); setKillReason(""); + fetchData(); + } catch (err) { + setKillError(err instanceof ApiError ? err.message : "Failed to trigger kill switch."); + } finally { + setIsKilling(false); + } + }; + + const handleEndSession = async (sessionId: string) => { + if (!orgId) return; + setEndSessionId(sessionId); + setIsEndingSession(true); + try { + await api.zerotier.endSession(orgId, sessionId); + toast({ title: "Session ended" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to end session", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setEndSessionId(null); + } + }; + + const handleAdminActivate = async (membershipId: string) => { + if (!orgId) return; + setAdminActivatingId(membershipId); + try { + await api.zerotier.activateMembership(orgId, membershipId); + toast({ title: "Membership activated" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to activate", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setAdminActivatingId(null); + } + }; + + const handleAdminDeactivate = async (membershipId: string) => { + if (!orgId) return; + setAdminDeactivatingId(membershipId); + try { + await api.zerotier.deactivateMembership(orgId, membershipId); + toast({ title: "Membership deactivated" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to deactivate", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setAdminDeactivatingId(null); + } + }; + + const handleAdminDelete = async (membershipId: string) => { + if (!orgId) return; + setAdminDeletingId(membershipId); + try { + await api.zerotier.adminDeleteMembership(orgId, membershipId); + toast({ title: "Membership permanently deleted" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to delete membership", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setAdminDeletingId(null); + } + }; + + const filteredPending = pendingApprovals.filter((a) => { + if (selectedNetworkFilter !== "all" && a.portal_network_id !== selectedNetworkFilter) return false; + if (search) { + const q = search.toLowerCase(); + if (!a.user_id.toLowerCase().includes(q)) return false; + } + return true; + }); + + const filteredSessions = sessions.filter((s) => s.is_active); + const activeSessions = filteredSessions; + + const getNetworkName = (networkId: string) => { + return networks.find((n) => n.id === networkId)?.name ?? networkId; + }; + + const getUserDisplay = (userId: string) => { + const member = orgMembers.find((m) => m.user_id === userId); + return member?.user?.email ?? member?.user?.full_name ?? userId; + }; + + return ( +
+
+

Access Control

+

Manage network access requests, approvals, and active sessions

+
+ +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ + + +
+ + + + + Pending Requests + {filteredPending.length > 0 && ( + + {filteredPending.length} + + )} + + + Active Sessions + {activeSessions.length > 0 && ( + + {activeSessions.length} + + )} + + + All Approvals ({approvals.length}) + + + All Members ({allMemberships.length}) + + + + {/* Pending Requests */} + + + + + + Pending Access Requests + + Review and approve or reject network access requests + + + {isLoading ? ( +
+ + Loading… +
+ ) : filteredPending.length === 0 ? ( +
+ {search || selectedNetworkFilter !== "all" ? "No pending requests match your filters." : "No pending requests at this time."} +
+ ) : ( +
+ {filteredPending.map((approval) => ( +
+
+
+

{getUserDisplay(approval.user_id)}

+ {getNetworkName(approval.portal_network_id)} + +
+

+ {approval.grant_type === "requested" ? "User request" : "Manager assignment"} + {approval.justification && ` — "${approval.justification}"`} +

+

+ {formatDate(approval.created_at)} +

+
+
+ + +
+
+ ))} +
+ )} +
+
+
+ + {/* Active Sessions */} + + + + + + Active Sessions + + Temporarily activated memberships currently in use + + + {isLoading ? ( +
+ + Loading… +
+ ) : activeSessions.length === 0 ? ( +
No active sessions.
+ ) : ( +
+ {activeSessions.map((session) => ( +
+
+ +
+
+

{session.device_network_membership_id}

+
+ Activated: {formatDate(session.authenticated_at)} + + + {formatExpiry(session.expires_at)} + +
+
+ +
+ ))} +
+ )} +
+
+
+ + {/* All Approvals */} + + + + + + All Approvals + + Complete history of network access grants + + + {isLoading ? ( +
+ + Loading… +
+ ) : approvals.length === 0 ? ( +
No approvals found.
+ ) : ( +
+ {approvals.map((approval) => ( +
+
+
+

{getUserDisplay(approval.user_id)}

+ {getNetworkName(approval.portal_network_id)} + +
+

+ {approval.grant_type === "requested" ? "User request" : "Manager assignment"} + {approval.justification && ` — "${approval.justification}"`} +

+

+ {formatDate(approval.created_at)} + {approval.granted_by_user_id && ` · Granted by: ${getUserDisplay(approval.granted_by_user_id)}`} +

+
+ {(approval.state === "approved" || approval.state === "suspended") && ( + + )} +
+ ))} +
+ )} +
+
+
+ + {/* All Members */} + + + + + + All Members + + Every device membership across all users and networks + + + {isAllMembersLoading ? ( +
+ + Loading… +
+ ) : allMemberships.length === 0 ? ( +
No memberships found.
+ ) : ( +
+ + + + + + + + + + + + + {allMemberships.map((m) => ( + + + + + + + + + ))} + +
UserDeviceNetworkStateActive SessionActions
+
+

{m.user_full_name || "—"}

+

{m.user_email || m.user_id}

+
+
+
+

{m.device_node_id || "—"}

+

{m.device_nickname || m.device_hostname || "—"}

+
+
+
+

{m.network_name || m.portal_network_id}

+ {m.network_environment && ( + {m.network_environment} + )} +
+
+ {m.state ? ( + + {m.state} + + ) : "—"} + + {m.active_session ? ( + + + {formatExpiry(m.active_session.expires_at)} + + ) : ( + + )} + +
+ {m.approved_for_activation && !m.currently_authorized && ( + + )} + {m.currently_authorized && ( + + )} + +
+
+
+ )} +
+
+
+
+ + {/* Assign Access Dialog */} + { if (!open) setShowAssign(false); }}> + + + Assign Network Access + Grant a user direct access to a network without a request. + +
+
+ + +
+
+ + +
+
+ + setAssignJustification(e.target.value)} + /> +
+ {assignError &&

{assignError}

} +
+ + + + +
+
+ + {/* Kill Switch Dialog */} + { if (!open) setShowKillSwitch(false); }}> + + + + + Kill Switch + + + Instantly deactivate all active sessions for a user across all managed networks. This cannot be undone. + + +
+
+
+ +

+ This will immediately de-authorize all ZeroTier memberships for the selected user across all networks. +

+
+
+
+ + +
+
+ + +
+
+ + setKillReason(e.target.value)} + /> +
+ {killError &&

{killError}

} +
+ + + + +
+
+
+ ); +} diff --git a/src/pages/org/DevicesPage.tsx b/src/pages/org/DevicesPage.tsx new file mode 100644 index 0000000..7db8a39 --- /dev/null +++ b/src/pages/org/DevicesPage.tsx @@ -0,0 +1,1159 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Monitor, + Plus, + Loader2, + Search, + MoreHorizontal, + ChevronRight, + Zap, + ZapOff, + Clock, + Trash2, + Pencil, + Laptop, + Smartphone, + Server, + CheckCircle, + XCircle, + AlertCircle, + Globe, + Users, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useToast } from "@/hooks/use-toast"; +import { + api, + ApiError, + Device, + DeviceNetworkMembership, + ActivationSession, + MembershipState, + PortalNetwork, + UserNetworkApproval, + ApprovalState, +} from "@/lib/api"; +import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; + +function cn(...classes: (string | boolean | undefined | null)[]) { + return classes.filter(Boolean).join(" "); +} + +function formatDate(d: string | null | undefined) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatExpiry(d: string | null | undefined) { + if (!d) return "—"; + const date = new Date(d); + const now = new Date(); + if (date < now) return "Expired"; + const diff = Math.floor((date.getTime() - now.getTime()) / 1000 / 60); + if (diff < 60) return `${diff}m left`; + if (diff < 1440) return `${Math.floor(diff / 60)}h ${diff % 60}m left`; + return `${Math.floor(diff / 1440)}d ${Math.floor((diff % 1440) / 60)}h left`; +} + +function MembershipStateBadge({ state }: { state: MembershipState }) { + const config: Record = { + pending_device_registration: { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: , label: "Pending Registration" }, + pending_request: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: "Pending Request" }, + pending_manager_approval: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: "Pending Approval" }, + approved_inactive: { color: "bg-blue-500/10 text-blue-600 border-blue-200", icon: , label: "Approved (Inactive)" }, + joined_deauthorized: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: , label: "Joined (Deauth)" }, + active_authorized: { color: "bg-green-500/10 text-green-600 border-green-200", icon: , label: "Active" }, + activation_expired: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Expired" }, + suspended: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Suspended" }, + revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Revoked" }, + rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Rejected" }, + }; + const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state }; + return ( + + {icon}{label} + + ); +} + +function ApprovalStateBadge({ state }: { state: ApprovalState }) { + const config: Record = { + pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: "Pending" }, + approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: , label: "Approved" }, + rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Rejected" }, + revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Revoked" }, + suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: , label: "Suspended" }, + }; + const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state }; + return ( + + {icon}{label} + + ); +} + +function DeviceTypeIcon({ nickname, hostname }: { nickname: string | null; hostname: string | null }) { + const text = (nickname || hostname || "").toLowerCase(); + if (text.includes("server") || text.includes("host") || text.includes("node")) return ; + if (text.includes("phone") || text.includes("mobile")) return ; + return ; +} + +export default function DevicesPage() { + const { orgId } = useCurrentOrganizationId(); + const { toast } = useToast(); + + const [devices, setDevices] = useState([]); + const [memberships, setMemberships] = useState([]); + const [sessions, setSessions] = useState([]); + const [networks, setNetworks] = useState([]); + const [myApprovals, setMyApprovals] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + + const [showRegister, setShowRegister] = useState(false); + const [regNodeId, setRegNodeId] = useState(""); + const [regNickname, setRegNickname] = useState(""); + const [regHostname, setRegHostname] = useState(""); + const [regAssetTag, setRegAssetTag] = useState(""); + const [regSerial, setRegSerial] = useState(""); + const [isRegistering, setIsRegistering] = useState(false); + const [regError, setRegError] = useState(null); + + const [selectedDevice, setSelectedDevice] = useState(null); + const [deviceMemberships, setDeviceMemberships] = useState([]); + const [isDrawerLoading, setIsDrawerLoading] = useState(false); + + const [editDevice, setEditDevice] = useState(null); + const [editNickname, setEditNickname] = useState(""); + const [editHostname, setEditHostname] = useState(""); + const [isEditing, setIsEditing] = useState(false); + + const [deleteDevice, setDeleteDevice] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const [activatingId, setActivatingId] = useState(null); + const [deactivatingId, setDeactivatingId] = useState(null); + const [activateLifetime, setActivateLifetime] = useState("480"); + const [showActivateDialog, setShowActivateDialog] = useState(null); + + const [showJoinDialog, setShowJoinDialog] = useState(null); + const [joinDeviceId, setJoinDeviceId] = useState(""); + const [isJoining, setIsJoining] = useState(false); + + const [showRequestDialog, setShowRequestDialog] = useState(null); + const [requestDeviceId, setRequestDeviceId] = useState(""); + const [requestJustification, setRequestJustification] = useState(""); + const [isRequesting, setIsRequesting] = useState(false); + const [requestError, setRequestError] = useState(null); + + const [deletingMembershipId, setDeletingMembershipId] = useState(null); + + const fetchData = useCallback(async () => { + if (!orgId) { setIsLoading(false); return; } + setIsLoading(true); + setError(null); + try { + const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([ + api.zerotier.listDevices(orgId), + api.zerotier.listMemberships(orgId), + api.zerotier.listSessions(orgId), + api.zerotier.listNetworks(orgId), + api.zerotier.listMyApprovals(orgId), + ]); + if (devicesRes.status === "fulfilled") setDevices(devicesRes.value.devices || []); + if (membershipsRes.status === "fulfilled") setMemberships(membershipsRes.value.memberships || []); + if (sessionsRes.status === "fulfilled") setSessions(sessionsRes.value.sessions || []); + if (networksRes.status === "fulfilled") setNetworks(networksRes.value.networks || []); + if (approvalsRes.status === "fulfilled") setMyApprovals(approvalsRes.value.approvals || []); + } catch { + setError("Failed to load data. Please try again."); + } finally { + setIsLoading(false); + } + }, [orgId]); + + useEffect(() => { + setDevices([]); + setMemberships([]); + fetchData(); + }, [fetchData]); + + const openDeviceDrawer = async (device: Device) => { + setSelectedDevice(device); + setIsDrawerLoading(true); + setDeviceMemberships([]); + try { + const deviceMem = memberships.filter((m) => m.device_id === device.id); + setDeviceMemberships(deviceMem); + } catch { + // non-fatal + } finally { + setIsDrawerLoading(false); + } + }; + + const closeDrawer = () => { + setSelectedDevice(null); + setDeviceMemberships([]); + }; + + const handleRegister = async () => { + if (!orgId) return; + setRegError(null); + if (!regNodeId.trim()) { setRegError("Node ID is required."); return; } + if (regNodeId.trim().length !== 10) { setRegError("Node ID must be exactly 10 characters."); return; } + setIsRegistering(true); + try { + await api.zerotier.registerDevice(orgId, { + node_id: regNodeId.trim(), + nickname: regNickname.trim() || undefined, + hostname: regHostname.trim() || undefined, + asset_tag: regAssetTag.trim() || undefined, + serial_number: regSerial.trim() || undefined, + }); + toast({ title: "Device registered", description: `Node ${regNodeId} has been registered.` }); + setShowRegister(false); + setRegNodeId(""); setRegNickname(""); setRegHostname(""); + setRegAssetTag(""); setRegSerial(""); + fetchData(); + } catch (err) { + setRegError(err instanceof ApiError ? err.message : "Failed to register device."); + } finally { + setIsRegistering(false); + } + }; + + const handleEdit = async () => { + if (!orgId || !editDevice) return; + setIsEditing(true); + try { + await api.zerotier.updateDevice(orgId, editDevice.id, { + nickname: editNickname.trim() || undefined, + hostname: editHostname.trim() || undefined, + }); + toast({ title: "Device updated" }); + setEditDevice(null); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to update device", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setIsEditing(false); + } + }; + + const handleDelete = async () => { + if (!orgId || !deleteDevice) return; + setIsDeleting(true); + try { + await api.zerotier.removeDevice(orgId, deleteDevice.id); + toast({ title: "Device removed", description: `${deleteDevice.node_id} has been removed.` }); + setDeleteDevice(null); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to remove device", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setIsDeleting(false); + } + }; + + const handleActivate = async (membershipId: string) => { + if (!orgId) return; + setActivatingId(membershipId); + try { + const lifetime = parseInt(activateLifetime); + await api.zerotier.activateMembership(orgId, membershipId, lifetime); + toast({ title: "Membership activated", description: `Active for ${lifetime} minutes.` }); + setShowActivateDialog(null); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to activate", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setActivatingId(null); + } + }; + + const handleDeactivate = async (membershipId: string) => { + if (!orgId) return; + setDeactivatingId(membershipId); + try { + await api.zerotier.deactivateMembership(orgId, membershipId); + toast({ title: "Membership deactivated" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to deactivate", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setDeactivatingId(null); + } + }; + + const handleActivateAll = async () => { + if (!orgId) return; + setActivatingId("all"); + try { + const lifetime = parseInt(activateLifetime); + const res = await api.zerotier.activateAllMemberships(orgId, lifetime); + toast({ title: "All memberships activated", description: `${res.count} memberships activated for ${lifetime} minutes.` }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to activate all", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setActivatingId(null); + } + }; + + const handleJoinNetwork = async () => { + if (!orgId || !showJoinDialog || !joinDeviceId) return; + setIsJoining(true); + setRequestError(null); + try { + await api.zerotier.joinNetworkForDevice(orgId, joinDeviceId, showJoinDialog.id); + toast({ title: "Joined network", description: `Device is now a member of ${showJoinDialog.name}.` }); + setShowJoinDialog(null); + setJoinDeviceId(""); + fetchData(); + } catch (err) { + setRequestError(err instanceof ApiError ? err.message : "Failed to join network."); + } finally { + setIsJoining(false); + } + }; + + const handleRequestAccess = async () => { + if (!orgId || !showRequestDialog || !requestDeviceId) return; + setIsRequesting(true); + setRequestError(null); + try { + await api.zerotier.requestAccess(orgId, { + portal_network_id: showRequestDialog.id, + device_id: requestDeviceId, + justification: requestJustification.trim() || undefined, + }); + toast({ title: "Access requested", description: `Request sent for ${showRequestDialog.name}.` }); + setShowRequestDialog(null); + setRequestDeviceId(""); + setRequestJustification(""); + fetchData(); + } catch (err) { + setRequestError(err instanceof ApiError ? err.message : "Failed to request access."); + } finally { + setIsRequesting(false); + } + }; + + const handleDeleteMembership = async (membershipId: string) => { + if (!orgId) return; + setDeletingMembershipId(membershipId); + try { + await api.zerotier.deleteMembership(orgId, membershipId); + toast({ title: "Membership removed" }); + fetchData(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to remove membership", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setDeletingMembershipId(null); + } + }; + + const filteredDevices = devices.filter((d) => { + const q = search.toLowerCase(); + return ( + d.node_id.toLowerCase().includes(q) || + (d.device_nickname?.toLowerCase().includes(q) ?? false) || + (d.hostname?.toLowerCase().includes(q) ?? false) + ); + }); + + const getActiveSession = (membershipId: string): ActivationSession | null => { + return sessions.find((s) => s.device_network_membership_id === membershipId && s.is_active) ?? null; + }; + + const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => { + return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? null; + }; + + const getApprovalForNetwork = (networkId: string): UserNetworkApproval | null => { + return myApprovals.find((a) => a.portal_network_id === networkId) ?? null; + }; + + const filteredApprovals = myApprovals.filter((a) => { + if (search) { + const q = search.toLowerCase(); + const network = networks.find((n) => n.id === a.portal_network_id); + if (!network?.name.toLowerCase().includes(q) && !a.portal_network_id.toLowerCase().includes(q)) return false; + } + return true; + }); + + return ( +
+
+

ZeroTier Access

+

Manage your devices, networks, and access requests

+
+ + + + + My Devices + {!isLoading && devices.length > 0 && ( + {devices.length} + )} + + + My Networks + {!isLoading && networks.length > 0 && ( + {networks.length} + )} + + + My Requests + {!isLoading && myApprovals.filter((a) => a.state === "pending").length > 0 && ( + {myApprovals.filter((a) => a.state === "pending").length} + )} + + + + {/* ── Tab A: My Devices ── */} + +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ + + + + + Registered Devices + {!isLoading && {devices.length}} + + Click a device to view memberships and activation status + + + {isLoading ? ( +
+ + Loading devices… +
+ ) : error ? ( +
{error}
+ ) : filteredDevices.length === 0 ? ( +
+ {search ? "No devices match your search." : "No devices registered. Register your first ZeroTier node."} +
+ ) : ( +
+ {filteredDevices.map((device) => { + const activeCount = memberships.filter( + (m) => m.device_id === device.id && m.currently_authorized + ).length; + return ( + + + + { e.stopPropagation(); openDeviceDrawer(device); }}> + View memberships + + { + e.stopPropagation(); + setEditDevice(device); + setEditNickname(device.device_nickname || ""); + setEditHostname(device.hostname || ""); + }}> + Edit + + + { e.stopPropagation(); setDeleteDevice(device); }} + > + Remove + + + + + + ); + })} +
+ )} +
+
+ + {sessions.filter((s) => s.is_active).length > 0 && ( + + +
+
+ + {sessions.filter((s) => s.is_active).length} active session(s) +
+ +
+
+ {sessions.filter((s) => s.is_active).map((session) => ( +
+ {session.device_network_membership_id} +
+ Expires: {formatExpiry(session.expires_at)} + +
+
+ ))} +
+
+
+ )} +
+ + {/* ── Tab B: My Networks ── */} + +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+
+ + + + + + Available Networks + {!isLoading && {networks.length}} + + Join open networks or request access to approval-required networks + + + {isLoading ? ( +
+ + Loading networks… +
+ ) : error ? ( +
{error}
+ ) : networks.length === 0 ? ( +
No networks available in this organization.
+ ) : ( +
+ {networks.filter((n) => { + if (!n.is_active) return false; + const q = search.toLowerCase(); + if (q && !n.name.toLowerCase().includes(q) && !n.zerotier_network_id.toLowerCase().includes(q)) return false; + return true; + }).map((network) => { + const approval = getApprovalForNetwork(network.id); + const hasMembership = memberships.some((m) => m.portal_network_id === network.id && !m.deleted_at); + const myDeviceMemberships = devices.map((d) => getMembershipForDeviceAndNetwork(d.id, network.id)).filter(Boolean) as DeviceNetworkMembership[]; + return ( +
+
+
+
+

{network.name}

+ {network.environment} + + {network.request_mode === "open" ? "Open" : "Approval Required"} + + {hasMembership && Member} +
+

{network.zerotier_network_id}

+ {approval && ( +
+ + {approval.justification && ( + "{approval.justification}" + )} +
+ )} +
+
+ {network.request_mode === "open" && !hasMembership && ( + + )} + {network.request_mode === "approval_required" && !hasMembership && ( + + )} + {hasMembership && ( +
+ Member +
+ )} +
+
+ + {myDeviceMemberships.length > 0 && ( +
+ {myDeviceMemberships.map((m) => ( +
+
+ d.id === m.device_id)?.device_nickname || null} + hostname={devices.find((d) => d.id === m.device_id)?.hostname || null} + /> + + {devices.find((d) => d.id === m.device_id)?.device_nickname || + devices.find((d) => d.id === m.device_id)?.node_id} + + +
+
+ {m.approved_for_activation && !m.currently_authorized && ( + + )} + {m.currently_authorized && ( + + )} +
+
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+
+
+ + {/* ── Tab C: My Requests ── */} + +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+
+ + + + + + My Access Requests + {!isLoading && {myApprovals.length}} + + Track your network access requests and approvals + + + {isLoading ? ( +
+ + Loading requests… +
+ ) : error ? ( +
{error}
+ ) : filteredApprovals.length === 0 ? ( +
+ {search ? "No requests match your search." : "No access requests yet. Browse networks to request access."} +
+ ) : ( +
+ {filteredApprovals.map((approval) => { + const network = networks.find((n) => n.id === approval.portal_network_id); + const relatedMemberships = memberships.filter((m) => m.portal_network_id === approval.portal_network_id); + return ( +
+
+
+

{network?.name || approval.portal_network_id}

+ {network?.environment} + +
+

+ {approval.grant_type === "requested" ? "You requested" : "Assigned by admin"} + {approval.justification && ` — "${approval.justification}"`} +

+

+ {formatDate(approval.created_at)} + {approval.granted_by_user_id && ` · Granted by manager`} +

+ {relatedMemberships.length > 0 && ( +
+ {relatedMemberships.map((m) => { + const dev = devices.find((d) => d.id === m.device_id); + return ( + + {dev?.device_nickname || dev?.node_id}: + + ); + })} +
+ )} +
+ {approval.state === "pending" && ( + + )} +
+ ); + })} +
+ )} +
+
+
+
+ + {/* Register Device Dialog */} + { if (!open) setShowRegister(false); }}> + + + Register Device + Add a ZeroTier node to your account. Find your Node ID in the ZeroTier client. + +
+
+ + setRegNodeId(e.target.value.toLowerCase())} className="font-mono" /> +

10-character ZeroTier Node ID from your client.

+
+
+ + setRegNickname(e.target.value)} /> +
+
+ + setRegHostname(e.target.value)} /> +
+
+
+ + setRegAssetTag(e.target.value)} /> +
+
+ + setRegSerial(e.target.value)} /> +
+
+ {regError &&

{regError}

} +
+ + + + +
+
+ + {/* Edit Device Dialog */} + { if (!open) setEditDevice(null); }}> + + + Edit Device + Update device nickname or hostname. + + {editDevice && ( +
+
+ + +
+
+ + setEditNickname(e.target.value)} /> +
+
+ + setEditHostname(e.target.value)} /> +
+
+ )} + + + + +
+
+ + {/* Delete Device Confirmation */} + { if (!open) setDeleteDevice(null); }}> + + + Remove Device + + Remove "{deleteDevice?.node_id}" from your account? Active sessions will be terminated. + + + + + + + + + + {/* Activate Lifetime Dialog */} + { if (!open) setShowActivateDialog(null); }}> + + + Set Activation Duration + How long should this membership be active? + +
+
+ + setActivateLifetime(e.target.value)} placeholder="480" /> +

e.g. 480 = 8 hours, 60 = 1 hour

+
+
+ + + + +
+
+ + {/* Join Network Dialog */} + { if (!open) { setShowJoinDialog(null); setJoinDeviceId(""); } }}> + + + Join Network + Select a registered device to join {showJoinDialog?.name}. + +
+
+ + +
+ {requestError &&

{requestError}

} +
+ + + + +
+
+ + {/* Request Access Dialog */} + { if (!open) { setShowRequestDialog(null); setRequestDeviceId(""); setRequestJustification(""); setRequestError(null); } }}> + + + Request Network Access + Request access to {showRequestDialog?.name}. A manager will review your request. + +
+
+ + +
+
+ + setRequestJustification(e.target.value)} + /> +
+ {requestError &&

{requestError}

} +
+ + + + +
+
+ + {/* Device Detail Drawer */} + { if (!open) closeDrawer(); }}> + + {selectedDevice && ( + <> + + +
+ +
+ {selectedDevice.device_nickname || selectedDevice.node_id} +
+ {selectedDevice.node_id} +
+ +
+
+ {selectedDevice.hostname && ( + <> + Hostname + {selectedDevice.hostname} + + )} + {selectedDevice.asset_tag && ( + <> + Asset Tag + {selectedDevice.asset_tag} + + )} + {selectedDevice.serial_number && ( + <> + Serial + {selectedDevice.serial_number} + + )} + Registered + {formatDate(selectedDevice.created_at)} + Status + {selectedDevice.status} +
+
+ +

+ + Network Memberships ({deviceMemberships.length}) +

+ + {isDrawerLoading ? ( +
+ +
+ ) : deviceMemberships.length === 0 ? ( +
+ No memberships found. Request network access to get started. +
+ ) : ( +
+ {deviceMemberships.map((m) => { + const session = getActiveSession(m.id); + const network = networks.find((n) => n.id === m.portal_network_id); + return ( +
+
+
+ {network?.name || m.portal_network_id} + +
+
+ {m.approved_for_activation && !m.currently_authorized && ( + + )} + {m.currently_authorized && ( + + )} +
+
+ {session && ( +
+ + Session expires: {formatExpiry(session.expires_at)} +
+ )} +
+ {m.join_seen ? ( + <> Joined network + ) : ( + <> Not yet joined + )} +
+
+ ); + })} +
+ )} + + )} +
+
+
+ ); +} diff --git a/src/pages/org/NetworksPage.tsx b/src/pages/org/NetworksPage.tsx new file mode 100644 index 0000000..c50f215 --- /dev/null +++ b/src/pages/org/NetworksPage.tsx @@ -0,0 +1,645 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Network, + Plus, + Loader2, + Search, + MoreHorizontal, + ChevronRight, + Users, + Monitor, + Clock, + Shield, + Trash2, + Pencil, + Eye, + CheckCircle, + XCircle, + Ban, + Zap, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useToast } from "@/hooks/use-toast"; +import { + api, + ApiError, + PortalNetwork, + DeviceNetworkMembership, + UserNetworkApproval, + NetworkEnvironment, + NetworkRequestMode, +} from "@/lib/api"; +import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; + +const ENVIRONMENTS: { value: NetworkEnvironment; label: string }[] = [ + { value: "production", label: "Production" }, + { value: "staging", label: "Staging" }, + { value: "development", label: "Development" }, + { value: "lab", label: "Lab" }, +]; + +const REQUEST_MODES: { value: NetworkRequestMode; label: string }[] = [ + { value: "open", label: "Open — anyone can join" }, + { value: "approval_required", label: "Approval Required" }, + { value: "invite_only", label: "Invite Only" }, +]; + +function formatDate(d: string | null | undefined) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function EnvironmentBadge({ env }: { env: NetworkEnvironment }) { + const colors: Record = { + production: "bg-red-500/10 text-red-600 border-red-200", + staging: "bg-yellow-500/10 text-yellow-600 border-yellow-200", + development: "bg-green-500/10 text-green-600 border-green-200", + lab: "bg-blue-500/10 text-blue-600 border-blue-200", + }; + return ( + + {env.charAt(0).toUpperCase() + env.slice(1)} + + ); +} + +function RequestModeBadge({ mode }: { mode: NetworkRequestMode }) { + if (mode === "open") return Open; + if (mode === "approval_required") return Approval Required; + return Invite Only; +} + +function cn(...classes: (string | boolean | undefined | null)[]) { + return classes.filter(Boolean).join(" "); +} + +export default function NetworksPage() { + const { orgId } = useCurrentOrganizationId(); + const { toast } = useToast(); + + const [networks, setNetworks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + + const [showCreate, setShowCreate] = useState(false); + const [createName, setCreateName] = useState(""); + const [createZtId, setCreateZtId] = useState(""); + const [createDesc, setCreateDesc] = useState(""); + const [createEnv, setCreateEnv] = useState("development"); + const [createMode, setCreateMode] = useState("approval_required"); + const [createDefaultLifetime, setCreateDefaultLifetime] = useState("480"); + const [createMaxLifetime, setCreateMaxLifetime] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [selectedNetwork, setSelectedNetwork] = useState(null); + const [networkMembers, setNetworkMembers] = useState([]); + const [networkRequests, setNetworkRequests] = useState([]); + const [isDrawerLoading, setIsDrawerLoading] = useState(false); + + const [editingNetwork, setEditingNetwork] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(""); + const [editDesc, setEditDesc] = useState(""); + const [editEnv, setEditEnv] = useState("development"); + const [editMode, setEditMode] = useState("approval_required"); + const [editDefaultLifetime, setEditDefaultLifetime] = useState("480"); + const [editMaxLifetime, setEditMaxLifetime] = useState(""); + const [editError, setEditError] = useState(null); + + const [deleteNetwork, setDeleteNetwork] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchNetworks = useCallback(async () => { + if (!orgId) { setIsLoading(false); return; } + setIsLoading(true); + setError(null); + try { + const res = await api.zerotier.listNetworks(orgId); + setNetworks(res.networks || []); + } catch (err) { + setError("Failed to load networks. Please try again."); + } finally { + setIsLoading(false); + } + }, [orgId]); + + useEffect(() => { + setNetworks([]); + fetchNetworks(); + }, [fetchNetworks]); + + const openNetworkDrawer = async (network: PortalNetwork) => { + setSelectedNetwork(network); + setIsDrawerLoading(true); + setNetworkMembers([]); + setNetworkRequests([]); + try { + const [membersRes, requestsRes] = await Promise.allSettled([ + api.zerotier.getNetworkMembers(orgId!, network.id), + api.zerotier.getNetworkPendingRequests(orgId!, network.id), + ]); + if (membersRes.status === "fulfilled") setNetworkMembers(membersRes.value.memberships || []); + if (requestsRes.status === "fulfilled") setNetworkRequests(requestsRes.value.requests || []); + } catch { + // non-fatal + } finally { + setIsDrawerLoading(false); + } + }; + + const closeDrawer = () => { + setSelectedNetwork(null); + setNetworkMembers([]); + setNetworkRequests([]); + }; + + const handleCreate = async () => { + if (!orgId) return; + setCreateError(null); + if (!createName.trim()) { setCreateError("Network name is required."); return; } + if (!createZtId.trim()) { setCreateError("ZeroTier Network ID is required."); return; } + setIsCreating(true); + try { + await api.zerotier.createNetwork(orgId, { + name: createName.trim(), + zerotier_network_id: createZtId.trim(), + description: createDesc.trim() || undefined, + environment: createEnv, + request_mode: createMode, + default_activation_lifetime_minutes: parseInt(createDefaultLifetime) || 480, + max_activation_lifetime_minutes: createMaxLifetime ? parseInt(createMaxLifetime) : undefined, + }); + toast({ title: "Network created", description: `${createName} has been added.` }); + setShowCreate(false); + setCreateName(""); setCreateZtId(""); setCreateDesc(""); + setCreateEnv("development"); setCreateMode("approval_required"); + setCreateDefaultLifetime("480"); setCreateMaxLifetime(""); + fetchNetworks(); + } catch (err) { + setCreateError(err instanceof ApiError ? err.message : "Failed to create network."); + } finally { + setIsCreating(false); + } + }; + + const openEditDialog = (network: PortalNetwork) => { + setEditingNetwork(network); + setEditName(network.name); + setEditDesc(network.description || ""); + setEditEnv(network.environment); + setEditMode(network.request_mode); + setEditDefaultLifetime(String(network.default_activation_lifetime_minutes)); + setEditMaxLifetime(network.max_activation_lifetime_minutes ? String(network.max_activation_lifetime_minutes) : ""); + setEditError(null); + }; + + const handleEdit = async () => { + if (!orgId || !editingNetwork) return; + setEditError(null); + setIsEditing(true); + try { + await api.zerotier.updateNetwork(orgId, editingNetwork.id, { + name: editName.trim(), + description: editDesc.trim() || undefined, + environment: editEnv, + request_mode: editMode, + default_activation_lifetime_minutes: parseInt(editDefaultLifetime) || 480, + max_activation_lifetime_minutes: editMaxLifetime ? parseInt(editMaxLifetime) : undefined, + }); + toast({ title: "Network updated", description: `${editName} has been updated.` }); + setEditingNetwork(null); + fetchNetworks(); + } catch (err) { + setEditError(err instanceof ApiError ? err.message : "Failed to update network."); + } finally { + setIsEditing(false); + } + }; + + const handleDelete = async () => { + if (!orgId || !deleteNetwork) return; + setIsDeleting(true); + try { + await api.zerotier.deleteNetwork(orgId, deleteNetwork.id); + toast({ title: "Network deleted", description: `${deleteNetwork.name} has been removed.` }); + setDeleteNetwork(null); + fetchNetworks(); + } catch (err) { + toast({ variant: "destructive", title: "Failed to delete network", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setIsDeleting(false); + } + }; + + const filteredNetworks = networks.filter((n) => { + const q = search.toLowerCase(); + return ( + n.name.toLowerCase().includes(q) || + n.zerotier_network_id.toLowerCase().includes(q) || + (n.description?.toLowerCase().includes(q) ?? false) + ); + }); + + return ( +
+
+

Networks

+

Manage ZeroTier portal networks and monitor access

+
+ +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ + + + + + Portal Networks + {!isLoading && {networks.length}} + + Click a network to view members, requests, and manage access + + + {isLoading ? ( +
+ + Loading networks… +
+ ) : error ? ( +
{error}
+ ) : filteredNetworks.length === 0 ? ( +
+ {search ? "No networks match your search." : "No networks configured yet. Add one to get started."} +
+ ) : ( +
+ {filteredNetworks.map((network) => ( + + + + { e.stopPropagation(); openNetworkDrawer(network); }}> + View details + + { e.stopPropagation(); openEditDialog(network); }}> + Edit + + + { e.stopPropagation(); setDeleteNetwork(network); }} + > + Delete + + + + + + ))} +
+ )} +
+
+ + {/* Create Network Dialog */} + { if (!open) setShowCreate(false); }}> + + + Add Portal Network + Link a ZeroTier network to your organization. + +
+
+ + setCreateName(e.target.value)} /> +
+
+ + setCreateZtId(e.target.value)} /> +

16-character hexadecimal network ID from your ZeroTier controller.

+
+
+ + setCreateDesc(e.target.value)} /> +
+
+
+ + +
+
+ + +
+
+
+
+ + setCreateDefaultLifetime(e.target.value)} /> +
+
+ + setCreateMaxLifetime(e.target.value)} /> +
+
+ {createError &&

{createError}

} +
+ + + + +
+
+ + {/* Edit Network Dialog */} + { if (!open) setEditingNetwork(null); }}> + + + Edit Network + Update network settings. + + {editingNetwork && ( +
+
+ + setEditName(e.target.value)} /> +
+
+ + setEditDesc(e.target.value)} /> +
+
+
+ + +
+
+ + +
+
+
+
+ + setEditDefaultLifetime(e.target.value)} /> +
+
+ + setEditMaxLifetime(e.target.value)} /> +
+
+ {editError &&

{editError}

} +
+ )} + + + + +
+
+ + {/* Delete Confirmation */} + { if (!open) setDeleteNetwork(null); }}> + + + Delete Network + + Are you sure you want to remove "{deleteNetwork?.name}"? This does not affect the ZeroTier network itself. + + + + + + + + + + {/* Network Detail Drawer */} + { if (!open) closeDrawer(); }}> + + {selectedNetwork && ( + <> + + +
+ +
+ {selectedNetwork.name} +
+ {selectedNetwork.zerotier_network_id} +
+ +
+
+ + +
+ {selectedNetwork.description && ( +

{selectedNetwork.description}

+ )} +
+
+ Default activation +

{selectedNetwork.default_activation_lifetime_minutes} min

+
+
+ Max activation +

{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}

+
+
+ Approved users +

{selectedNetwork.approved_user_count ?? 0}

+
+
+ Active devices +

{selectedNetwork.active_membership_count ?? 0}

+
+
+
+ + {isDrawerLoading ? ( +
+ +
+ ) : ( + + + + Members ({networkMembers.length}) + + + Requests ({networkRequests.length}) + + + + + {networkMembers.length === 0 ? ( +
No members yet.
+ ) : ( +
+ {networkMembers.map((m) => ( +
+ +
+

{m.device_id}

+

+ State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"} +

+
+
+ {m.currently_authorized ? ( + <>Authorized + ) : ( + <>Inactive + )} +
+
+ ))} +
+ )} +
+ + + {networkRequests.length === 0 ? ( +
No pending requests.
+ ) : ( +
+ {networkRequests.map((r) => ( +
+ +
+

{r.user_id}

+

+ {r.grant_type} · {r.state} +

+ {r.justification &&

"{r.justification}"

} +
+
+ ))} +
+ )} +
+
+ )} + + )} +
+
+
+ ); +}