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
This commit is contained in:
Generated
+1
-17
@@ -2859,7 +2859,6 @@
|
|||||||
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
|
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2877,7 +2876,6 @@
|
|||||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -2889,7 +2887,6 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@@ -2940,7 +2937,6 @@
|
|||||||
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.38.0",
|
"@typescript-eslint/scope-manager": "8.38.0",
|
||||||
"@typescript-eslint/types": "8.38.0",
|
"@typescript-eslint/types": "8.38.0",
|
||||||
@@ -3173,7 +3169,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3378,7 +3373,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3712,7 +3706,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@@ -3794,8 +3787,7 @@
|
|||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel-react": {
|
"node_modules/embla-carousel-react": {
|
||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
@@ -3893,7 +3885,6 @@
|
|||||||
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
|
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5415,7 +5406,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5602,7 +5592,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5629,7 +5618,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5643,7 +5631,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
||||||
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
|
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -6197,7 +6184,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -6322,7 +6308,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6502,7 +6487,6 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
+25
-4
@@ -7,9 +7,17 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|||||||
// Layouts
|
// Layouts
|
||||||
import PublicLayout from "@/components/layouts/PublicLayout";
|
import PublicLayout from "@/components/layouts/PublicLayout";
|
||||||
import ProtectedLayout from "@/components/layouts/ProtectedLayout";
|
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
|
// Public pages
|
||||||
import Index from "@/pages/Index";
|
|
||||||
import LoginPage from "@/pages/auth/LoginPage";
|
import LoginPage from "@/pages/auth/LoginPage";
|
||||||
import RegisterPage from "@/pages/auth/RegisterPage";
|
import RegisterPage from "@/pages/auth/RegisterPage";
|
||||||
import VerifyEmailPage from "@/pages/auth/VerifyEmailPage";
|
import VerifyEmailPage from "@/pages/auth/VerifyEmailPage";
|
||||||
@@ -24,7 +32,7 @@ import ActivatePage from "@/pages/auth/ActivatePage";
|
|||||||
|
|
||||||
// User pages
|
// User pages
|
||||||
import ProfilePage from "@/pages/user/ProfilePage";
|
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 LinkedAccountsPage from "@/pages/user/LinkedAccountsPage";
|
||||||
import ActivityPage from "@/pages/user/ActivityPage";
|
import ActivityPage from "@/pages/user/ActivityPage";
|
||||||
import SSHKeysPage from "@/pages/user/SSHKeysPage";
|
import SSHKeysPage from "@/pages/user/SSHKeysPage";
|
||||||
@@ -40,6 +48,9 @@ import CAsPage from "@/pages/org/CAsPage";
|
|||||||
import DepartmentsPage from "@/pages/org/DepartmentsPage";
|
import DepartmentsPage from "@/pages/org/DepartmentsPage";
|
||||||
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
||||||
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
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 SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
||||||
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
||||||
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
||||||
@@ -127,8 +138,15 @@ function AppRoutes() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<OrgProvider>
|
<OrgProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Index redirect */}
|
{/* Marketing pages */}
|
||||||
<Route path="/" element={<Index />} />
|
<Route element={<MarketingLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/features" element={<FeaturesPage />} />
|
||||||
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
|
<Route path="/security" element={<SecurityPage />} />
|
||||||
|
<Route path="/ssh-certificates" element={<SSHCertificatesPage />} />
|
||||||
|
<Route path="/demo" element={<DemoPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
@@ -160,6 +178,7 @@ function AppRoutes() {
|
|||||||
{/* Organization routes — org members: overview + own memberships only */}
|
{/* Organization routes — org members: overview + own memberships only */}
|
||||||
<Route path="/org" element={<RequireOrgMember><OrgOverviewPage /></RequireOrgMember>} />
|
<Route path="/org" element={<RequireOrgMember><OrgOverviewPage /></RequireOrgMember>} />
|
||||||
<Route path="/org/my-memberships" element={<RequireOrgMember><MyMembershipsPage /></RequireOrgMember>} />
|
<Route path="/org/my-memberships" element={<RequireOrgMember><MyMembershipsPage /></RequireOrgMember>} />
|
||||||
|
<Route path="/org/zerotier/devices" element={<RequireOrgMember><DevicesPage /></RequireOrgMember>} />
|
||||||
|
|
||||||
{/* Organization management routes — org admins/owners only */}
|
{/* Organization management routes — org admins/owners only */}
|
||||||
<Route path="/org/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} />
|
<Route path="/org/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} />
|
||||||
@@ -170,6 +189,8 @@ function AppRoutes() {
|
|||||||
<Route path="/org/audit" element={<RequireAdmin><OrgAuditPage /></RequireAdmin>} />
|
<Route path="/org/audit" element={<RequireAdmin><OrgAuditPage /></RequireAdmin>} />
|
||||||
<Route path="/org/clients" element={<RequireAdmin><OIDCClientsPage /></RequireAdmin>} />
|
<Route path="/org/clients" element={<RequireAdmin><OIDCClientsPage /></RequireAdmin>} />
|
||||||
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
|
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
|
||||||
|
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
|
||||||
|
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} />
|
||||||
|
|
||||||
{/* Admin routes — org admin/owner only */}
|
{/* Admin routes — org admin/owner only */}
|
||||||
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
|
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<nav className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex h-16 items-center justify-between">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-2.5">
|
||||||
|
<GatehouseLogo size="md" />
|
||||||
|
<span className="text-xl font-semibold text-foreground tracking-tight">Secuird</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex md:items-center md:gap-1">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
||||||
|
location.pathname === item.href
|
||||||
|
? "bg-secondary text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-secondary/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<div className="hidden md:flex md:items-center md:gap-3">
|
||||||
|
<Link to="/login">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/register">
|
||||||
|
<Button size="sm">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="md:hidden p-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="md:hidden py-4 border-t">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
||||||
|
location.pathname === item.href
|
||||||
|
? "bg-secondary text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-secondary/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-col gap-2 mt-4 pt-4 border-t">
|
||||||
|
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button className="w-full">
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t bg-muted/30">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-8">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="col-span-2 lg:col-span-1">
|
||||||
|
<Link to="/" className="flex items-center gap-2.5">
|
||||||
|
<GatehouseLogo size="sm" />
|
||||||
|
<span className="text-lg font-semibold text-foreground tracking-tight">Secuird</span>
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground max-w-xs">
|
||||||
|
Enterprise identity and access management. Secure by design, simple by choice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3">Product</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><Link to="/features" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Features</Link></li>
|
||||||
|
<li><Link to="/security" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Security</Link></li>
|
||||||
|
<li><Link to="/ssh-certificates" className="text-sm text-muted-foreground hover:text-foreground transition-colors">SSH Certificates</Link></li>
|
||||||
|
<li><Link to="/pricing" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Pricing</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resources */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3">Resources</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><Link to="/demo" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Demo</Link></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Documentation</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">API Reference</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Status</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3">Company</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">About</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Blog</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Careers</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3">Legal</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Privacy</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Terms</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Security</a></li>
|
||||||
|
<li><a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">Compliance</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
© {new Date().getFullYear()} Secuird. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Key,
|
Key,
|
||||||
|
Network,
|
||||||
|
Monitor,
|
||||||
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
@@ -45,6 +48,7 @@ const userNavItems = [
|
|||||||
const orgMemberNavItems = [
|
const orgMemberNavItems = [
|
||||||
{ title: "Overview", url: "/org", icon: Building2 },
|
{ title: "Overview", url: "/org", icon: Building2 },
|
||||||
{ title: "My Memberships", url: "/org/my-memberships", icon: Layers },
|
{ 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)
|
// Visible to org admins/owners only (management)
|
||||||
@@ -54,6 +58,8 @@ const orgAdminNavItems = [
|
|||||||
{ title: "Departments", url: "/org/departments", icon: Layers },
|
{ title: "Departments", url: "/org/departments", icon: Layers },
|
||||||
{ title: "Principals", url: "/org/principals", icon: GitBranch },
|
{ title: "Principals", url: "/org/principals", icon: GitBranch },
|
||||||
{ title: "Policies", url: "/org/policies", icon: Settings },
|
{ title: "Policies", url: "/org/policies", icon: Settings },
|
||||||
|
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
|
||||||
|
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
|
|||||||
+444
@@ -1258,6 +1258,264 @@ export const api = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}, true, requestConfig),
|
}, 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<string, unknown>, 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<string, unknown> }>(
|
||||||
|
"/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
|
// Organization types
|
||||||
@@ -1525,3 +1783,189 @@ export function create403Handler(toastFn: (options: { title: string; description
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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<string, unknown>[];
|
||||||
|
routes: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
|
||||||
|
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending" },
|
||||||
|
approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: <CheckCircle className="w-3 h-3 mr-1" />, label: "Approved" },
|
||||||
|
rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Rejected" },
|
||||||
|
revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Revoked" },
|
||||||
|
suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: <AlertTriangle className="w-3 h-3 mr-1" />, label: "Suspended" },
|
||||||
|
};
|
||||||
|
const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state };
|
||||||
|
return (
|
||||||
|
<Badge className={cn("text-xs", color)}>
|
||||||
|
{icon}{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccessPage() {
|
||||||
|
const { orgId } = useCurrentOrganizationId();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
|
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
||||||
|
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
|
||||||
|
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||||
|
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
const [approveId, setApproveId] = useState<string | null>(null);
|
||||||
|
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||||
|
const [revokeId, setRevokeId] = useState<string | null>(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<string | null>(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<string | null>(null);
|
||||||
|
|
||||||
|
const [endSessionId, setEndSessionId] = useState<string | null>(null);
|
||||||
|
const [isEndingSession, setIsEndingSession] = useState(false);
|
||||||
|
|
||||||
|
const [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
|
||||||
|
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
|
||||||
|
const [isAllMembersLoading, setIsAllMembersLoading] = useState(false);
|
||||||
|
const [allMembersSearch, setAllMembersSearch] = useState("");
|
||||||
|
const [allMembersNetworkFilter, setAllMembersNetworkFilter] = useState<string>("all");
|
||||||
|
const [allMembersStateFilter, setAllMembersStateFilter] = useState<string>("all");
|
||||||
|
const [selectedMembership, setSelectedMembership] = useState<EnrichedMembership | null>(null);
|
||||||
|
const [adminActivatingId, setAdminActivatingId] = useState<string | null>(null);
|
||||||
|
const [adminDeactivatingId, setAdminDeactivatingId] = useState<string | null>(null);
|
||||||
|
const [adminDeletingId, setAdminDeletingId] = useState<string | null>(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 (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Access Control</h1>
|
||||||
|
<p className="page-description">Manage network access requests, approvals, and active sessions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by user…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={selectedNetworkFilter} onValueChange={setSelectedNetworkFilter}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="All networks" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Networks</SelectItem>
|
||||||
|
{networks.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" onClick={() => setShowAssign(true)} className="gap-2">
|
||||||
|
<UserPlus className="w-4 h-4" /> Assign Access
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setShowKillSwitch(true)} className="gap-2">
|
||||||
|
<Skull className="w-4 h-4" /> Kill Switch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="pending" className="w-full">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="pending">
|
||||||
|
Pending Requests
|
||||||
|
{filteredPending.length > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-white text-[10px] font-bold">
|
||||||
|
{filteredPending.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sessions">
|
||||||
|
Active Sessions
|
||||||
|
{activeSessions.length > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-green-500 text-white text-[10px] font-bold">
|
||||||
|
{activeSessions.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="approvals">
|
||||||
|
All Approvals ({approvals.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="allmembers">
|
||||||
|
All Members ({allMemberships.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Pending Requests */}
|
||||||
|
<TabsContent value="pending">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Pending Access Requests
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Review and approve or reject network access requests</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : filteredPending.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
{search || selectedNetworkFilter !== "all" ? "No pending requests match your filters." : "No pending requests at this time."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredPending.map((approval) => (
|
||||||
|
<div key={approval.id} className="flex items-center gap-4 p-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
||||||
|
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
||||||
|
<ApprovalStateBadge state={approval.state} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
||||||
|
{approval.justification && ` — "${approval.justification}"`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatDate(approval.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-green-600 border-green-300 hover:bg-green-50 gap-1"
|
||||||
|
onClick={() => handleApprove(approval.id)}
|
||||||
|
disabled={approveId === approval.id || isApproving}
|
||||||
|
>
|
||||||
|
{approveId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-600 border-red-300 hover:bg-red-50 gap-1"
|
||||||
|
onClick={() => handleReject(approval.id)}
|
||||||
|
disabled={rejectId === approval.id || isApproving}
|
||||||
|
>
|
||||||
|
{rejectId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Active Sessions */}
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-green-500" />
|
||||||
|
Active Sessions
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Temporarily activated memberships currently in use</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : activeSessions.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">No active sessions.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<div key={session.id} className="flex items-center gap-4 p-4">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Zap className="w-4 h-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium font-mono truncate">{session.device_network_membership_id}</p>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>Activated: {formatDate(session.authenticated_at)}</span>
|
||||||
|
<span className="text-green-600 font-medium flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatExpiry(session.expires_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
|
||||||
|
onClick={() => handleEndSession(session.id)}
|
||||||
|
disabled={endSessionId === session.id}
|
||||||
|
>
|
||||||
|
{endSessionId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||||
|
End Session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* All Approvals */}
|
||||||
|
<TabsContent value="approvals">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
All Approvals
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Complete history of network access grants</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : approvals.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">No approvals found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{approvals.map((approval) => (
|
||||||
|
<div key={approval.id} className="flex items-center gap-4 p-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
||||||
|
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
||||||
|
<ApprovalStateBadge state={approval.state} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
||||||
|
{approval.justification && ` — "${approval.justification}"`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatDate(approval.created_at)}
|
||||||
|
{approval.granted_by_user_id && ` · Granted by: ${getUserDisplay(approval.granted_by_user_id)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{(approval.state === "approved" || approval.state === "suspended") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-600 border-red-300 hover:bg-red-50 gap-1 flex-shrink-0"
|
||||||
|
onClick={() => handleRevoke(approval.id)}
|
||||||
|
disabled={revokeId === approval.id || isApproving}
|
||||||
|
>
|
||||||
|
{revokeId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* All Members */}
|
||||||
|
<TabsContent value="allmembers">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
All Members
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Every device membership across all users and networks</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isAllMembersLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : allMemberships.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">No memberships found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left p-3 font-medium">User</th>
|
||||||
|
<th className="text-left p-3 font-medium">Device</th>
|
||||||
|
<th className="text-left p-3 font-medium">Network</th>
|
||||||
|
<th className="text-left p-3 font-medium">State</th>
|
||||||
|
<th className="text-left p-3 font-medium">Active Session</th>
|
||||||
|
<th className="text-right p-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{allMemberships.map((m) => (
|
||||||
|
<tr key={m.id} className="hover:bg-accent/30">
|
||||||
|
<td className="p-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{m.user_full_name || "—"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{m.user_email || m.user_id}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs">{m.device_node_id || "—"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{m.device_nickname || m.device_hostname || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-xs">{m.network_name || m.portal_network_id}</p>
|
||||||
|
{m.network_environment && (
|
||||||
|
<Badge variant="outline" className="text-xs">{m.network_environment}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{m.state ? (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
m.state === "active_authorized" ? "default" :
|
||||||
|
m.state === "approved_inactive" ? "secondary" :
|
||||||
|
"outline"
|
||||||
|
}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{m.state}
|
||||||
|
</Badge>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{m.active_session ? (
|
||||||
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
{formatExpiry(m.active_session.expires_at)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{m.approved_for_activation && !m.currently_authorized && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAdminActivate(m.id)}
|
||||||
|
disabled={adminActivatingId === m.id}
|
||||||
|
className="gap-1 h-7 px-2"
|
||||||
|
>
|
||||||
|
{adminActivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{m.currently_authorized && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAdminDeactivate(m.id)}
|
||||||
|
disabled={adminDeactivatingId === m.id}
|
||||||
|
className="gap-1 h-7 px-2 text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
{adminDeactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleAdminDelete(m.id)}
|
||||||
|
disabled={adminDeletingId === m.id}
|
||||||
|
className="gap-1 h-7 px-2 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{adminDeletingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Assign Access Dialog */}
|
||||||
|
<Dialog open={showAssign} onOpenChange={(open) => { if (!open) setShowAssign(false); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assign Network Access</DialogTitle>
|
||||||
|
<DialogDescription>Grant a user direct access to a network without a request.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>User *</Label>
|
||||||
|
<Select value={assignUserId} onValueChange={setAssignUserId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a user…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{orgMembers.map((m) => (
|
||||||
|
<SelectItem key={m.user_id} value={m.user_id}>
|
||||||
|
{m.user?.full_name || m.user?.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Network *</Label>
|
||||||
|
<Select value={assignNetworkId} onValueChange={setAssignNetworkId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a network…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{networks.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Justification (optional)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Engineering team access"
|
||||||
|
value={assignJustification}
|
||||||
|
onChange={(e) => setAssignJustification(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{assignError && <p className="text-sm text-destructive">{assignError}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowAssign(false)} disabled={isAssigning}>Cancel</Button>
|
||||||
|
<Button onClick={handleAssign} disabled={isAssigning}>
|
||||||
|
{isAssigning && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Assign Access
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Kill Switch Dialog */}
|
||||||
|
<Dialog open={showKillSwitch} onOpenChange={(open) => { if (!open) setShowKillSwitch(false); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Skull className="w-5 h-5" />
|
||||||
|
Kill Switch
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Instantly deactivate all active sessions for a user across all managed networks. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="p-3 border border-destructive/30 rounded-lg bg-destructive/5">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Target User *</Label>
|
||||||
|
<Select value={killTargetUserId} onValueChange={setKillTargetUserId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a user…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{orgMembers.map((m) => (
|
||||||
|
<SelectItem key={m.user_id} value={m.user_id}>
|
||||||
|
{m.user?.full_name || m.user?.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Scope</Label>
|
||||||
|
<Select value={killScope} onValueChange={(v) => setKillScope(v as "organization" | "global")}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="organization">Organization only</SelectItem>
|
||||||
|
<SelectItem value="global">Global (all networks)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason (optional)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="User terminated / device lost"
|
||||||
|
value={killReason}
|
||||||
|
onChange={(e) => setKillReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{killError && <p className="text-sm text-destructive">{killError}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowKillSwitch(false)} disabled={isKilling}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleKillSwitch} disabled={isKilling}>
|
||||||
|
{isKilling && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Trigger Kill Switch
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<NetworkEnvironment, string> = {
|
||||||
|
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 (
|
||||||
|
<Badge className={cn("text-xs", colors[env])}>
|
||||||
|
{env.charAt(0).toUpperCase() + env.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestModeBadge({ mode }: { mode: NetworkRequestMode }) {
|
||||||
|
if (mode === "open") return <Badge variant="outline" className="text-xs text-green-600 border-green-300">Open</Badge>;
|
||||||
|
if (mode === "approval_required") return <Badge variant="outline" className="text-xs text-yellow-600 border-yellow-300">Approval Required</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-xs text-purple-600 border-purple-300">Invite Only</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PortalNetwork[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<NetworkEnvironment>("development");
|
||||||
|
const [createMode, setCreateMode] = useState<NetworkRequestMode>("approval_required");
|
||||||
|
const [createDefaultLifetime, setCreateDefaultLifetime] = useState("480");
|
||||||
|
const [createMaxLifetime, setCreateMaxLifetime] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [selectedNetwork, setSelectedNetwork] = useState<PortalNetwork | null>(null);
|
||||||
|
const [networkMembers, setNetworkMembers] = useState<DeviceNetworkMembership[]>([]);
|
||||||
|
const [networkRequests, setNetworkRequests] = useState<UserNetworkApproval[]>([]);
|
||||||
|
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||||
|
|
||||||
|
const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
const [editEnv, setEditEnv] = useState<NetworkEnvironment>("development");
|
||||||
|
const [editMode, setEditMode] = useState<NetworkRequestMode>("approval_required");
|
||||||
|
const [editDefaultLifetime, setEditDefaultLifetime] = useState("480");
|
||||||
|
const [editMaxLifetime, setEditMaxLifetime] = useState("");
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(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 (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Networks</h1>
|
||||||
|
<p className="page-description">Manage ZeroTier portal networks and monitor access</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search networks…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreate(true)} className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> Add Network
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Network className="w-4 h-4" />
|
||||||
|
Portal Networks
|
||||||
|
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Click a network to view members, requests, and manage access</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading networks…</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-destructive">{error}</div>
|
||||||
|
) : filteredNetworks.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
{search ? "No networks match your search." : "No networks configured yet. Add one to get started."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredNetworks.map((network) => (
|
||||||
|
<button
|
||||||
|
key={network.id}
|
||||||
|
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||||
|
onClick={() => openNetworkDrawer(network)}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Network className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium text-foreground truncate">{network.name}</p>
|
||||||
|
<EnvironmentBadge env={network.environment} />
|
||||||
|
<RequestModeBadge mode={network.request_mode} />
|
||||||
|
{!network.is_active && (
|
||||||
|
<Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50">
|
||||||
|
<Ban className="w-3 h-3 mr-1" />Inactive
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground font-mono">{network.zerotier_network_id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-1" title="Approved users">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>{network.approved_user_count ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1" title="Active devices">
|
||||||
|
<Zap className="w-4 h-4 text-green-500" />
|
||||||
|
<span>{network.active_membership_count ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
|
||||||
|
<Eye className="w-4 h-4 mr-2" /> View details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteNetwork(network); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Network Dialog */}
|
||||||
|
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Portal Network</DialogTitle>
|
||||||
|
<DialogDescription>Link a ZeroTier network to your organization.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Network Name *</Label>
|
||||||
|
<Input placeholder="Production VPN" value={createName} onChange={(e) => setCreateName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>ZeroTier Network ID *</Label>
|
||||||
|
<Input placeholder="d6578dd03c894448" value={createZtId} onChange={(e) => setCreateZtId(e.target.value)} />
|
||||||
|
<p className="text-xs text-muted-foreground">16-character hexadecimal network ID from your ZeroTier controller.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input placeholder="Production network for engineering" value={createDesc} onChange={(e) => setCreateDesc(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Environment</Label>
|
||||||
|
<Select value={createEnv} onValueChange={(v) => setCreateEnv(v as NetworkEnvironment)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENVIRONMENTS.map((e) => <SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Request Mode</Label>
|
||||||
|
<Select value={createMode} onValueChange={(v) => setCreateMode(v as NetworkRequestMode)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{REQUEST_MODES.map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Default Activation (minutes)</Label>
|
||||||
|
<Input type="number" placeholder="480" value={createDefaultLifetime} onChange={(e) => setCreateDefaultLifetime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Activation (minutes)</Label>
|
||||||
|
<Input type="number" placeholder="No limit" value={createMaxLifetime} onChange={(e) => setCreateMaxLifetime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{createError && <p className="text-sm text-destructive">{createError}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCreate(false)} disabled={isCreating}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={isCreating}>
|
||||||
|
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Create Network
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Network Dialog */}
|
||||||
|
<Dialog open={!!editingNetwork} onOpenChange={(open) => { if (!open) setEditingNetwork(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Network</DialogTitle>
|
||||||
|
<DialogDescription>Update network settings.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{editingNetwork && (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Network Name *</Label>
|
||||||
|
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input value={editDesc} onChange={(e) => setEditDesc(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Environment</Label>
|
||||||
|
<Select value={editEnv} onValueChange={(v) => setEditEnv(v as NetworkEnvironment)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENVIRONMENTS.map((e) => <SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Request Mode</Label>
|
||||||
|
<Select value={editMode} onValueChange={(v) => setEditMode(v as NetworkRequestMode)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{REQUEST_MODES.map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Default Activation (minutes)</Label>
|
||||||
|
<Input type="number" value={editDefaultLifetime} onChange={(e) => setEditDefaultLifetime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Activation (minutes)</Label>
|
||||||
|
<Input type="number" placeholder="No limit" value={editMaxLifetime} onChange={(e) => setEditMaxLifetime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editError && <p className="text-sm text-destructive">{editError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditingNetwork(null)} disabled={isEditing}>Cancel</Button>
|
||||||
|
<Button onClick={handleEdit} disabled={isEditing}>
|
||||||
|
{isEditing && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Dialog open={!!deleteNetwork} onOpenChange={(open) => { if (!open) setDeleteNetwork(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Network</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to remove "{deleteNetwork?.name}"? This does not affect the ZeroTier network itself.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteNetwork(null)} disabled={isDeleting}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
||||||
|
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Delete Network
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Network Detail Drawer */}
|
||||||
|
<Sheet open={!!selectedNetwork} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
|
||||||
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||||
|
{selectedNetwork && (
|
||||||
|
<>
|
||||||
|
<SheetHeader className="mb-4">
|
||||||
|
<SheetTitle className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Network className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
{selectedNetwork.name}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="font-mono">{selectedNetwork.zerotier_network_id}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<EnvironmentBadge env={selectedNetwork.environment} />
|
||||||
|
<RequestModeBadge mode={selectedNetwork.request_mode} />
|
||||||
|
</div>
|
||||||
|
{selectedNetwork.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedNetwork.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Default activation</span>
|
||||||
|
<p className="font-medium">{selectedNetwork.default_activation_lifetime_minutes} min</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Max activation</span>
|
||||||
|
<p className="font-medium">{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Approved users</span>
|
||||||
|
<p className="font-medium flex items-center gap-1"><Users className="w-3 h-3" />{selectedNetwork.approved_user_count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Active devices</span>
|
||||||
|
<p className="font-medium flex items-center gap-1 text-green-600"><Zap className="w-3 h-3" />{selectedNetwork.active_membership_count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDrawerLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="members" className="w-full">
|
||||||
|
<TabsList className="mb-3">
|
||||||
|
<TabsTrigger value="members">
|
||||||
|
Members ({networkMembers.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="requests">
|
||||||
|
Requests ({networkRequests.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="members">
|
||||||
|
{networkMembers.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground text-sm">No members yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{networkMembers.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
|
||||||
|
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{m.device_id}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{m.currently_authorized ? (
|
||||||
|
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-xs text-green-600">Authorized</span></>
|
||||||
|
) : (
|
||||||
|
<><XCircle className="w-4 h-4 text-muted-foreground" /><span className="text-xs text-muted-foreground">Inactive</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="requests">
|
||||||
|
{networkRequests.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground text-sm">No pending requests.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{networkRequests.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{r.user_id}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{r.grant_type} · {r.state}
|
||||||
|
</p>
|
||||||
|
{r.justification && <p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user