Compare commits
1 Commits
main
..
oidc-client
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e5427a262 |
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
# Secuird UI Configuration
|
# Secuird UI Configuration
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Copy this file to .env for your environment
|
# Copy this file to .env.local for local development
|
||||||
|
# or use mode-specific env files (.env.development, .env.staging, .env.production)
|
||||||
|
|
||||||
# API Configuration
|
# API Configuration
|
||||||
VITE_API_BASE_URL=https://api.gatehouse.local/api/v1
|
VITE_API_BASE_URL=https://api.gatehouse.local/api/v1
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -64,6 +64,43 @@ This project is built with:
|
|||||||
|
|
||||||
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
|
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
|
||||||
|
|
||||||
|
## Session Timeout Behavior
|
||||||
|
|
||||||
|
This application implements automatic session timeout to align with security best practices (OWASP Session Management Cheat Sheet, NIST 800-63B Section 7.1).
|
||||||
|
|
||||||
|
### Backend Configuration
|
||||||
|
|
||||||
|
The backend uses a sliding window session model with two independent timeouts:
|
||||||
|
|
||||||
|
| Timeout | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| Idle | 15 minutes | If no authenticated request is made within this window, the session expires |
|
||||||
|
| Absolute | 8 hours | Hard cap from session creation. Activity cannot extend past this point |
|
||||||
|
|
||||||
|
Both are configurable via environment variables: `SESSION_IDLE_TIMEOUT` and `SESSION_ABSOLUTE_TIMEOUT` (values in seconds).
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
- **Sliding Window**: Every authenticated request automatically resets the idle clock
|
||||||
|
- **Active User**: Session keeps extending up to the 8-hour absolute maximum
|
||||||
|
- **Idle User**: After 15 minutes of inactivity, the session expires and the next request returns 401
|
||||||
|
- **Heartbeat**: The frontend sends a periodic `GET /api/v1/auth/me` every 5 minutes to keep sessions alive during passive activities like reading long pages
|
||||||
|
|
||||||
|
### Frontend UX
|
||||||
|
|
||||||
|
- **Warning Dialog**: When the user is within 3 minutes of session expiry, a warning dialog appears with a countdown timer
|
||||||
|
- **Extend Session**: Users can click "Keep Me Signed In" to refresh the session via `POST /api/v1/auth/sessions/refresh`
|
||||||
|
- **Graceful Expiry**: When a session expires, the user is redirected to the login page with a gentle message: "Your session has expired due to inactivity"
|
||||||
|
- **No Hard Logouts**: The frontend never forcefully logs out an active user; expiry only occurs after API confirmation (401 response)
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `POST /api/v1/auth/sessions/refresh` | Explicitly refresh session (extends idle timeout) |
|
||||||
|
| `GET /api/v1/auth/sessions` | List all active sessions for the user |
|
||||||
|
| `DELETE /api/v1/auth/sessions/:id` | Revoke a specific session |
|
||||||
|
|
||||||
## Can I connect a custom domain to my Lovable project?
|
## Can I connect a custom domain to my Lovable project?
|
||||||
|
|
||||||
Yes, you can!
|
Yes, you can!
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
- VITE_API_BASE_URL=https://secuird.tech/api/v1
|
||||||
container_name: gatehouse-ui
|
container_name: gatehouse-ui
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
+26
-4
@@ -51,9 +51,9 @@ import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
|
|||||||
import CAsPage from "@/pages/org/CAsPage";
|
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 ApiKeysPage from "@/pages/org/ApiKeysPage";
|
||||||
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
||||||
import NetworksPage from "@/pages/org/NetworksPage";
|
import NetworksPage from "@/pages/org/NetworksPage";
|
||||||
import NetworkManagementPage from "@/pages/org/NetworkManagementPage";
|
|
||||||
import DevicesPage from "@/pages/org/DevicesPage";
|
import DevicesPage from "@/pages/org/DevicesPage";
|
||||||
import AccessPage from "@/pages/org/AccessPage";
|
import AccessPage from "@/pages/org/AccessPage";
|
||||||
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
|
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
|
||||||
@@ -61,7 +61,6 @@ 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";
|
||||||
|
|
||||||
import SessionTimeoutModal from "@/components/auth/SessionTimeoutModal";
|
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -105,6 +104,8 @@ function ScrollToTop() {
|
|||||||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||||
import { OrgProvider } from "@/contexts/OrgContext";
|
import { OrgProvider } from "@/contexts/OrgContext";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useSessionTimeout } from "@/hooks/useSessionTimeout";
|
||||||
|
import { SessionWarning } from "@/components/auth/SessionWarning";
|
||||||
|
|
||||||
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
|
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
|
||||||
function GuestRoute({ children }: { children: React.ReactNode }) {
|
function GuestRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -149,10 +150,31 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles session timeout detection, warning dialog, and heartbeat.
|
||||||
|
* Rendered inside AuthProvider so it can access auth state.
|
||||||
|
*/
|
||||||
|
function SessionTimeoutManager() {
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const { isWarningOpen, countdownSeconds, extendSession } = useSessionTimeout(
|
||||||
|
isAuthenticated,
|
||||||
|
logout
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionWarning
|
||||||
|
open={isWarningOpen}
|
||||||
|
countdownSeconds={countdownSeconds}
|
||||||
|
onExtend={extendSession}
|
||||||
|
onLogout={() => logout()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionTimeoutModal />
|
<SessionTimeoutManager />
|
||||||
<OrgProvider>
|
<OrgProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Marketing pages */}
|
{/* Marketing pages */}
|
||||||
@@ -204,13 +226,13 @@ function AppRoutes() {
|
|||||||
<Route path="/org/members/:userId" element={<RequireAdmin><UserManagementPage /></RequireAdmin>} />
|
<Route path="/org/members/:userId" element={<RequireAdmin><UserManagementPage /></RequireAdmin>} />
|
||||||
<Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></RequireAdmin>} />
|
<Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></RequireAdmin>} />
|
||||||
<Route path="/org/principals" element={<RequireAdmin><PrincipalsPage /></RequireAdmin>} />
|
<Route path="/org/principals" element={<RequireAdmin><PrincipalsPage /></RequireAdmin>} />
|
||||||
|
<Route path="/org/api-keys" element={<RequireAdmin><ApiKeysPage /></RequireAdmin>} />
|
||||||
<Route path="/org/policies" element={<RequireAdmin><PoliciesPage /></RequireAdmin>} />
|
<Route path="/org/policies" element={<RequireAdmin><PoliciesPage /></RequireAdmin>} />
|
||||||
<Route path="/org/policies/compliance" element={<RequireAdmin><CompliancePage /></RequireAdmin>} />
|
<Route path="/org/policies/compliance" element={<RequireAdmin><CompliancePage /></RequireAdmin>} />
|
||||||
<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/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
|
||||||
<Route path="/org/zerotier/networks/:networkId" element={<RequireAdmin><NetworkManagementPage /></RequireAdmin>} />
|
|
||||||
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} />
|
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} />
|
||||||
<Route path="/org/zerotier/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
|
<Route path="/org/zerotier/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { tokenManager } from '@/lib/api';
|
|
||||||
|
|
||||||
const AUTO_REDIRECT_SECONDS = 5;
|
|
||||||
|
|
||||||
export default function SessionTimeoutModal() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [secondsLeft, setSecondsLeft] = useState(AUTO_REDIRECT_SECONDS);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval>>();
|
|
||||||
const wasOpenRef = useRef(false);
|
|
||||||
|
|
||||||
const redirectToLogin = useCallback(() => {
|
|
||||||
tokenManager.clearToken();
|
|
||||||
window.location.href = '/login';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onSessionExpired = () => {
|
|
||||||
setOpen(true);
|
|
||||||
// Only reset the countdown when the modal transitions from closed → open
|
|
||||||
if (!wasOpenRef.current) {
|
|
||||||
setSecondsLeft(AUTO_REDIRECT_SECONDS);
|
|
||||||
}
|
|
||||||
wasOpenRef.current = true;
|
|
||||||
};
|
|
||||||
window.addEventListener('session:expired', onSessionExpired);
|
|
||||||
return () => window.removeEventListener('session:expired', onSessionExpired);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
timerRef.current = setInterval(() => {
|
|
||||||
setSecondsLeft((prev) => {
|
|
||||||
if (prev <= 1) {
|
|
||||||
clearInterval(timerRef.current);
|
|
||||||
redirectToLogin();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return prev - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
return () => clearInterval(timerRef.current);
|
|
||||||
}, [open, redirectToLogin]);
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
clearInterval(timerRef.current);
|
|
||||||
redirectToLogin();
|
|
||||||
}
|
|
||||||
}, [redirectToLogin]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
onPointerDownOutside={(e) => e.preventDefault()}
|
|
||||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Session Expired</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Your session has timed out. Please sign in again to continue.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Redirecting in {secondsLeft}s...
|
|
||||||
</p>
|
|
||||||
<Button onClick={redirectToLogin}>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SessionWarningProps {
|
||||||
|
open: boolean;
|
||||||
|
countdownSeconds: number;
|
||||||
|
onExtend: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warning dialog shown before automatic session timeout.
|
||||||
|
* Gives the user an opportunity to extend their session.
|
||||||
|
*/
|
||||||
|
export function SessionWarning({
|
||||||
|
open,
|
||||||
|
countdownSeconds,
|
||||||
|
onExtend,
|
||||||
|
onLogout,
|
||||||
|
}: SessionWarningProps) {
|
||||||
|
const minutes = Math.floor(countdownSeconds / 60);
|
||||||
|
const seconds = countdownSeconds % 60;
|
||||||
|
const timeDisplay = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={() => {}}>
|
||||||
|
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||||
|
Session Expiring Soon
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your session will expire in{' '}
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{timeDisplay}
|
||||||
|
</span>{' '}
|
||||||
|
due to inactivity.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-2 text-sm text-muted-foreground">
|
||||||
|
If you do not extend your session, you will be logged out automatically
|
||||||
|
and any unsaved work may be lost.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={onLogout}>
|
||||||
|
Log Out Now
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onExtend} autoFocus>
|
||||||
|
Keep Me Signed In
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,15 +46,13 @@ const userNavItems = [
|
|||||||
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
|
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
|
||||||
{ title: "Activity", url: "/activity", icon: Activity },
|
{ title: "Activity", url: "/activity", icon: Activity },
|
||||||
{ title: "CLI Guide", url: "/cli-guide", icon: BookOpen },
|
{ title: "CLI Guide", url: "/cli-guide", icon: BookOpen },
|
||||||
{ title: "ZeroTier Devices", url: "/org/zerotier/devices", icon: Monitor },
|
|
||||||
{ title: "My Memberships", url: "/org/my-memberships", icon: Layers },
|
|
||||||
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Visible to ALL org members
|
// Visible to ALL org members
|
||||||
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: "ZeroTier Devices", url: "/org/zerotier/devices", icon: Monitor },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Visible to org admins/owners only (management)
|
// Visible to org admins/owners only (management)
|
||||||
@@ -63,10 +61,12 @@ const orgAdminNavItems = [
|
|||||||
{ title: "Members", url: "/org/members", icon: Users },
|
{ title: "Members", url: "/org/members", icon: Users },
|
||||||
{ 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: "API Keys", url: "/org/api-keys", icon: Key },
|
||||||
{ title: "Policies", url: "/org/policies", icon: Settings },
|
{ title: "Policies", url: "/org/policies", icon: Settings },
|
||||||
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
|
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
|
||||||
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
|
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
|
||||||
{ title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings },
|
{ title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings },
|
||||||
|
{ title: "ZeroTier Devices", url: "/org/zerotier/devices", icon: Monitor },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
|
|||||||
@@ -21,14 +21,18 @@ interface AuthContextType {
|
|||||||
canViewSystemLogs: boolean;
|
canViewSystemLogs: boolean;
|
||||||
mfaCompliance: MfaComplianceSummary | null;
|
mfaCompliance: MfaComplianceSummary | null;
|
||||||
requiresMfaEnrollment: boolean;
|
requiresMfaEnrollment: boolean;
|
||||||
|
/** True when the previous session ended due to timeout/expiry rather than manual logout. */
|
||||||
|
sessionExpired: boolean;
|
||||||
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
|
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
|
||||||
verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise<void>;
|
verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise<void>;
|
||||||
verifyWebAuthn: () => Promise<void>;
|
verifyWebAuthn: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: (expired?: boolean) => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
refreshCompliance: () => Promise<void>;
|
refreshCompliance: () => Promise<void>;
|
||||||
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
|
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
|
||||||
checkOrgAdmin: () => Promise<void>;
|
checkOrgAdmin: () => Promise<void>;
|
||||||
|
/** Clear the sessionExpired flag (e.g., after showing the message to the user). */
|
||||||
|
clearSessionExpired: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
@@ -87,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(loadMfaCompliance);
|
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(loadMfaCompliance);
|
||||||
const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false);
|
const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Helper to check if user is admin/owner in any org
|
// Helper to check if user is admin/owner in any org
|
||||||
@@ -215,6 +220,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (response.user) {
|
if (response.user) {
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
|
setSessionExpired(false);
|
||||||
if (response.mfa_compliance) {
|
if (response.mfa_compliance) {
|
||||||
setMfaCompliance(response.mfa_compliance);
|
setMfaCompliance(response.mfa_compliance);
|
||||||
persistMfaCompliance(response.mfa_compliance);
|
persistMfaCompliance(response.mfa_compliance);
|
||||||
@@ -278,7 +284,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [navigate, checkOrgAdmin]);
|
}, [navigate, checkOrgAdmin]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async (expired = false) => {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -288,10 +294,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setMfaCompliance(null);
|
setMfaCompliance(null);
|
||||||
persistMfaCompliance(null);
|
persistMfaCompliance(null);
|
||||||
setRequiresMfaEnrollment(false);
|
setRequiresMfaEnrollment(false);
|
||||||
|
setSessionExpired(expired);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
const clearSessionExpired = useCallback(() => {
|
||||||
|
setSessionExpired(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -303,6 +314,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
canViewSystemLogs: user?.can_view_system_logs ?? false,
|
canViewSystemLogs: user?.can_view_system_logs ?? false,
|
||||||
mfaCompliance,
|
mfaCompliance,
|
||||||
requiresMfaEnrollment,
|
requiresMfaEnrollment,
|
||||||
|
sessionExpired,
|
||||||
login,
|
login,
|
||||||
verifyTotp,
|
verifyTotp,
|
||||||
verifyWebAuthn,
|
verifyWebAuthn,
|
||||||
@@ -310,6 +322,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
refreshUser,
|
refreshUser,
|
||||||
refreshCompliance,
|
refreshCompliance,
|
||||||
checkOrgAdmin,
|
checkOrgAdmin,
|
||||||
|
clearSessionExpired,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { api, tokenManager } from '@/lib/api';
|
||||||
|
|
||||||
|
// Configuration constants (in milliseconds)
|
||||||
|
const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const WARNING_BEFORE_MS = 3 * 60 * 1000; // Show warning 3 minutes before timeout
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // Heartbeat every 5 minutes
|
||||||
|
|
||||||
|
interface UseSessionTimeoutReturn {
|
||||||
|
isWarningOpen: boolean;
|
||||||
|
countdownSeconds: number;
|
||||||
|
extendSession: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages session timeout behavior:
|
||||||
|
* - Detects user activity to track idle time
|
||||||
|
* - Shows a warning dialog before the session expires
|
||||||
|
* - Sends periodic heartbeats to keep the session alive during passive activity
|
||||||
|
* - Automatically logs out when the session expires
|
||||||
|
*
|
||||||
|
* The backend sliding window is authoritative; this hook provides UX around it.
|
||||||
|
*/
|
||||||
|
export function useSessionTimeout(
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
onLogout: () => Promise<void>
|
||||||
|
): UseSessionTimeoutReturn {
|
||||||
|
const [isWarningOpen, setIsWarningOpen] = useState(false);
|
||||||
|
const [countdownSeconds, setCountdownSeconds] = useState(0);
|
||||||
|
|
||||||
|
// Use refs to avoid stale closures in event listeners and intervals
|
||||||
|
const lastActivityRef = useRef<number>(Date.now());
|
||||||
|
const warningShownRef = useRef<boolean>(false);
|
||||||
|
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const activityCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const isAuthenticatedRef = useRef(isAuthenticated);
|
||||||
|
const onLogoutRef = useRef(onLogout);
|
||||||
|
|
||||||
|
// Keep refs in sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
isAuthenticatedRef.current = isAuthenticated;
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLogoutRef.current = onLogout;
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
// Update last activity timestamp
|
||||||
|
const updateActivity = useCallback(() => {
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Perform logout when session expires
|
||||||
|
const performLogout = useCallback(async (reason: string) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`[SessionTimeout] Logging out: ${reason}`);
|
||||||
|
}
|
||||||
|
// Clear warning state
|
||||||
|
setIsWarningOpen(false);
|
||||||
|
warningShownRef.current = false;
|
||||||
|
// Perform logout
|
||||||
|
await onLogoutRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Extend the session by calling the refresh endpoint
|
||||||
|
const extendSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.auth.refreshSession();
|
||||||
|
// Reset activity and warning state
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
setIsWarningOpen(false);
|
||||||
|
warningShownRef.current = false;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('[SessionTimeout] Session extended successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SessionTimeout] Failed to extend session:', error);
|
||||||
|
// If refresh fails, perform logout
|
||||||
|
await performLogout('refresh failed');
|
||||||
|
}
|
||||||
|
}, [performLogout]);
|
||||||
|
|
||||||
|
// Setup activity listeners and timers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Clean up any existing state when logged out
|
||||||
|
setIsWarningOpen(false);
|
||||||
|
warningShownRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset activity timestamp on mount/auth change
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
warningShownRef.current = false;
|
||||||
|
|
||||||
|
// Activity event listeners
|
||||||
|
const activityEvents = ['mousedown', 'keydown', 'touchstart', 'scroll'];
|
||||||
|
const handleActivity = () => {
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
|
||||||
|
// If warning is open and user becomes active, extend session automatically
|
||||||
|
if (warningShownRef.current) {
|
||||||
|
extendSession();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activityEvents.forEach((event) => {
|
||||||
|
window.addEventListener(event, handleActivity, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat: keep session alive during passive reading
|
||||||
|
heartbeatIntervalRef.current = setInterval(() => {
|
||||||
|
if (!isAuthenticatedRef.current) return;
|
||||||
|
|
||||||
|
api.users.me().catch(async (error) => {
|
||||||
|
if (error?.code === 401) {
|
||||||
|
await performLogout('heartbeat received 401');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Activity check: determine when to show warning or logout
|
||||||
|
activityCheckIntervalRef.current = setInterval(() => {
|
||||||
|
if (!isAuthenticatedRef.current) return;
|
||||||
|
|
||||||
|
const idleTime = Date.now() - lastActivityRef.current;
|
||||||
|
const timeUntilTimeout = IDLE_TIMEOUT_MS - idleTime;
|
||||||
|
|
||||||
|
// If idle time exceeds timeout, logout
|
||||||
|
if (idleTime >= IDLE_TIMEOUT_MS) {
|
||||||
|
performLogout('idle timeout exceeded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show warning if we're within the warning threshold and not already shown
|
||||||
|
if (timeUntilTimeout <= WARNING_BEFORE_MS && !warningShownRef.current) {
|
||||||
|
warningShownRef.current = true;
|
||||||
|
setIsWarningOpen(true);
|
||||||
|
setCountdownSeconds(Math.ceil(timeUntilTimeout / 1000));
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
countdownIntervalRef.current = setInterval(() => {
|
||||||
|
const remaining = IDLE_TIMEOUT_MS - (Date.now() - lastActivityRef.current);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// Time's up - logout
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
}
|
||||||
|
performLogout('countdown reached zero');
|
||||||
|
} else {
|
||||||
|
setCountdownSeconds(Math.ceil(remaining / 1000));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 5000); // Check every 5 seconds
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
activityEvents.forEach((event) => {
|
||||||
|
window.removeEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
if (heartbeatIntervalRef.current) {
|
||||||
|
clearInterval(heartbeatIntervalRef.current);
|
||||||
|
}
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
}
|
||||||
|
if (activityCheckIntervalRef.current) {
|
||||||
|
clearInterval(activityCheckIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, extendSession, performLogout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWarningOpen,
|
||||||
|
countdownSeconds,
|
||||||
|
extendSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
+77
-264
@@ -100,6 +100,24 @@ export interface LoginResponse {
|
|||||||
is_first_user?: boolean;
|
is_first_user?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
is_current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionsResponse {
|
||||||
|
sessions: Session[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshSessionResponse {
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TotpEnrollResponse {
|
export interface TotpEnrollResponse {
|
||||||
secret: string;
|
secret: string;
|
||||||
provisioning_uri: string;
|
provisioning_uri: string;
|
||||||
@@ -177,40 +195,6 @@ export interface AdminLinkedAccount {
|
|||||||
linked_at: string | null;
|
linked_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserSshCertificate {
|
|
||||||
id: string;
|
|
||||||
ca_id: string;
|
|
||||||
user_id: string;
|
|
||||||
ssh_key_id: string | null;
|
|
||||||
serial: string;
|
|
||||||
key_id: string;
|
|
||||||
cert_type: 'user' | 'host';
|
|
||||||
principals: string[];
|
|
||||||
valid_after: string;
|
|
||||||
valid_before: string;
|
|
||||||
revoked: boolean;
|
|
||||||
revoked_at: string | null;
|
|
||||||
revoke_reason: string | null;
|
|
||||||
status: 'issued' | 'revoked' | 'expired' | 'superseded';
|
|
||||||
request_ip: string | null;
|
|
||||||
request_user_agent: string | null;
|
|
||||||
critical_options: Record<string, string>;
|
|
||||||
extensions: Record<string, string>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
is_valid: boolean;
|
|
||||||
days_until_expiry: number;
|
|
||||||
ssh_key: {
|
|
||||||
id: string;
|
|
||||||
fingerprint: string;
|
|
||||||
key_type: string;
|
|
||||||
key_bits: number;
|
|
||||||
key_comment: string | null;
|
|
||||||
description: string | null;
|
|
||||||
verified: boolean;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// External Auth Types
|
// External Auth Types
|
||||||
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
|
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
|
||||||
|
|
||||||
@@ -367,6 +351,14 @@ export const tokenManager = {
|
|||||||
hasValidToken: (): boolean => {
|
hasValidToken: (): boolean => {
|
||||||
return tokenManager.getToken() !== null;
|
return tokenManager.getToken() !== null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateExpiry: (expiresAt: string): void => {
|
||||||
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
|
||||||
|
},
|
||||||
|
|
||||||
|
getExpiry: (): string | null => {
|
||||||
|
return localStorage.getItem(TOKEN_EXPIRY_KEY);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Error types that indicate the session/token is truly invalid
|
// Error types that indicate the session/token is truly invalid
|
||||||
@@ -421,42 +413,25 @@ async function request<T>(
|
|||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle HTTP 401 before parsing JSON — catches non-JSON responses and
|
|
||||||
// unknown body shapes that would otherwise bypass the session-expired logic
|
|
||||||
if (response.status === 401) {
|
|
||||||
let errorType = 'UNAUTHORIZED';
|
|
||||||
try {
|
|
||||||
const errBody = await response.json();
|
|
||||||
errorType = errBody.error?.type || 'UNAUTHORIZED';
|
|
||||||
} catch {
|
|
||||||
// Non-JSON body (HTML error page, plain text, etc.) — use default
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clearTokenOn401 !== false) {
|
|
||||||
tokenManager.clearToken();
|
|
||||||
window.dispatchEvent(new CustomEvent('session:expired'));
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new ApiError('Unauthorized', 401, errorType, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const json: ApiResponse<T> = await response.json();
|
const json: ApiResponse<T> = await response.json();
|
||||||
|
|
||||||
if (!json.success) {
|
if (!json.success) {
|
||||||
const errorType = json.error?.type || 'UNKNOWN_ERROR';
|
const errorType = json.error?.type || 'UNKNOWN_ERROR';
|
||||||
|
|
||||||
// Handle 401 in JSON body (backstop for servers that return 200 with code:401)
|
// Handle 401 token clearing based on configuration
|
||||||
if (json.code === 401) {
|
if (json.code === 401) {
|
||||||
if (clearTokenOn401 !== false) {
|
const shouldClearToken =
|
||||||
|
clearTokenOn401 === true ||
|
||||||
|
(clearTokenOn401 === 'auto' && SESSION_INVALID_ERROR_TYPES.includes(errorType));
|
||||||
|
|
||||||
|
if (shouldClearToken) {
|
||||||
tokenManager.clearToken();
|
tokenManager.clearToken();
|
||||||
window.dispatchEvent(new CustomEvent('session:expired'));
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`);
|
console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`);
|
||||||
}
|
}
|
||||||
|
} else if (import.meta.env.DEV) {
|
||||||
|
console.log(`[API] 401 received but token preserved (type: ${errorType}, endpoint: ${endpoint})`);
|
||||||
}
|
}
|
||||||
throw new ApiError(json.message || 'Unauthorized', 401, errorType, json.error?.details || {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 403 authorization errors
|
// Handle 403 authorization errors
|
||||||
@@ -562,6 +537,22 @@ export const api = {
|
|||||||
tokenManager.clearToken();
|
tokenManager.clearToken();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshSession: async () => {
|
||||||
|
const response = await request<RefreshSessionResponse>('/auth/sessions/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
tokenManager.updateExpiry(response.expires_at);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
listSessions: () =>
|
||||||
|
request<SessionsResponse>('/auth/sessions', {}),
|
||||||
|
|
||||||
|
revokeSession: (sessionId: string) =>
|
||||||
|
request<{ message: string }>(`/auth/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
@@ -749,58 +740,6 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Get SSH certificates issued to a user (admin view)
|
|
||||||
getUserSshCertificates: (userId: string, params?: {
|
|
||||||
status?: string;
|
|
||||||
active?: string;
|
|
||||||
cert_type?: string;
|
|
||||||
page?: number;
|
|
||||||
per_page?: number;
|
|
||||||
}, requestConfig?: RequestConfig) => {
|
|
||||||
const qs = params ? '?' + new URLSearchParams(
|
|
||||||
Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])
|
|
||||||
).toString() : '';
|
|
||||||
return request<{
|
|
||||||
user: { id: string; email: string; full_name: string };
|
|
||||||
certificates: AdminUserSshCertificate[];
|
|
||||||
count: number;
|
|
||||||
page: number;
|
|
||||||
per_page: number;
|
|
||||||
pages: number;
|
|
||||||
}>(`/admin/users/${userId}/ssh-certificates${qs}`, {}, true, requestConfig);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
superadmin: {
|
|
||||||
getUserAuditLogs: (userId: string, params?: Record<string, string>, requestConfig?: RequestConfig) =>
|
|
||||||
request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number; user: User }>(
|
|
||||||
`/superadmin/users/${userId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`,
|
|
||||||
{},
|
|
||||||
true,
|
|
||||||
requestConfig,
|
|
||||||
),
|
|
||||||
|
|
||||||
exportUserAuditLogs: async (userId: string, params?: Record<string, string>): Promise<void> => {
|
|
||||||
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
|
||||||
const token = tokenManager.getToken();
|
|
||||||
const res = await fetch(`${config.api.baseUrl}/superadmin/users/${userId}/audit-logs/export${qs}`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
tokenManager.clearToken();
|
|
||||||
window.dispatchEvent(new CustomEvent('session:expired'));
|
|
||||||
throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED');
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new ApiError('Export failed', res.status, 'EXPORT_ERROR');
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `user_${userId}_audit_logs.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
totp: {
|
totp: {
|
||||||
@@ -885,11 +824,6 @@ export const api = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
|
||||||
tokenManager.clearToken();
|
|
||||||
window.dispatchEvent(new CustomEvent('session:expired'));
|
|
||||||
throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED');
|
|
||||||
}
|
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
error.message || 'Failed to begin registration',
|
error.message || 'Failed to begin registration',
|
||||||
@@ -918,11 +852,6 @@ export const api = {
|
|||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
|
||||||
tokenManager.clearToken();
|
|
||||||
window.dispatchEvent(new CustomEvent('session:expired'));
|
|
||||||
throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED');
|
|
||||||
}
|
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
error.message || 'No passkeys found for this account',
|
error.message || 'No passkeys found for this account',
|
||||||
@@ -946,12 +875,6 @@ export const api = {
|
|||||||
body: JSON.stringify(assertion),
|
body: JSON.stringify(assertion),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
tokenManager.clearToken();
|
|
||||||
window.dispatchEvent(new CustomEvent('session:expired'));
|
|
||||||
throw new ApiError('Unauthorized', 401, 'UNAUTHORIZED');
|
|
||||||
}
|
|
||||||
|
|
||||||
const json: ApiResponse<WebAuthnLoginCompleteResponse> = await response.json();
|
const json: ApiResponse<WebAuthnLoginCompleteResponse> = await response.json();
|
||||||
|
|
||||||
if (!json.success) {
|
if (!json.success) {
|
||||||
@@ -1103,14 +1026,14 @@ export const api = {
|
|||||||
request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig),
|
request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig),
|
||||||
|
|
||||||
// Create department
|
// Create department
|
||||||
createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) =>
|
createDepartment: (orgId: string, name: string, description?: string, canSudo?: boolean, requestConfig?: RequestConfig) =>
|
||||||
request<{ department: Department }>(`/organizations/${orgId}/departments`, {
|
request<{ department: Department }>(`/organizations/${orgId}/departments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, description }),
|
body: JSON.stringify({ name, description, can_sudo: canSudo }),
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Update department
|
// Update department
|
||||||
updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) =>
|
updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string; can_sudo?: boolean }, requestConfig?: RequestConfig) =>
|
||||||
request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, {
|
request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -1222,10 +1145,10 @@ export const api = {
|
|||||||
request<{ clients: OIDCClient[]; count: number }>(`/organizations/${orgId}/clients`, {}, true, requestConfig),
|
request<{ clients: OIDCClient[]; count: number }>(`/organizations/${orgId}/clients`, {}, true, requestConfig),
|
||||||
|
|
||||||
// Create OIDC client
|
// Create OIDC client
|
||||||
createClient: (orgId: string, name: string, redirect_uris: string[], allowed_cors_origins?: string[] | null, requestConfig?: RequestConfig) =>
|
createClient: (orgId: string, name: string, redirect_uris: string[], requestConfig?: RequestConfig) =>
|
||||||
request<{ client: OIDCClientWithSecret }>(`/organizations/${orgId}/clients`, {
|
request<{ client: OIDCClientWithSecret }>(`/organizations/${orgId}/clients`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, redirect_uris, allowed_cors_origins }),
|
body: JSON.stringify({ name, redirect_uris }),
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Delete OIDC client
|
// Delete OIDC client
|
||||||
@@ -1234,8 +1157,8 @@ export const api = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Update OIDC client (name, redirect_uris, and/or allowed_cors_origins)
|
// Update OIDC client (name and/or redirect_uris)
|
||||||
updateClient: (orgId: string, clientId: string, data: { name?: string; redirect_uris?: string[]; allowed_cors_origins?: string[] | null }, requestConfig?: RequestConfig) =>
|
updateClient: (orgId: string, clientId: string, data: { name?: string; redirect_uris?: string[] }, requestConfig?: RequestConfig) =>
|
||||||
request<{ client: OIDCClient }>(`/organizations/${orgId}/clients/${clientId}`, {
|
request<{ client: OIDCClient }>(`/organizations/${orgId}/clients/${clientId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -1395,10 +1318,10 @@ export const api = {
|
|||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Sign a certificate for the given key
|
// Sign a certificate for the given key
|
||||||
signCertificate: (key_id: string, principals?: string[], cert_type?: 'user' | 'host', expiry_hours?: number, organization_id?: string, requestConfig?: RequestConfig) =>
|
signCertificate: (key_id: string, principals?: string[], cert_type?: 'user' | 'host', expiry_hours?: number, requestConfig?: RequestConfig) =>
|
||||||
request<SSHSignResponse>('/ssh/sign', {
|
request<SSHSignResponse>('/ssh/sign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ key_id, principals, cert_type, expiry_hours, organization_id }),
|
body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }),
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Issue a host certificate by submitting a raw server host public key
|
// Issue a host certificate by submitting a raw server host public key
|
||||||
@@ -1620,10 +1543,15 @@ export const api = {
|
|||||||
{ method: "POST" }, true, requestConfig,
|
{ method: "POST" }, true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
assignAccess: (orgId: string, data: { target_user_id: string; portal_network_id: string; justification?: string }, requestConfig?: RequestConfig) =>
|
assignAccess: (orgId: string, data: {
|
||||||
|
target_user_id: string;
|
||||||
|
portal_network_id: string;
|
||||||
|
justification?: string;
|
||||||
|
}, requestConfig?: RequestConfig) =>
|
||||||
request<{ approval: UserNetworkApproval }>(
|
request<{ approval: UserNetworkApproval }>(
|
||||||
`/organizations/${orgId}/approvals/assign`,
|
`/organizations/${orgId}/approvals/assign`,
|
||||||
{ method: "POST", body: JSON.stringify(data) }, true, requestConfig,
|
{ method: "POST", body: JSON.stringify(data) },
|
||||||
|
true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Memberships ────────────────────────────────────────────────────────────
|
// ── Memberships ────────────────────────────────────────────────────────────
|
||||||
@@ -1682,34 +1610,16 @@ export const api = {
|
|||||||
true, requestConfig,
|
true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Org Members (for Add New Membership dialog) ────────────────────
|
|
||||||
getOrgMembers: (orgId: string, requestConfig?: RequestConfig) =>
|
|
||||||
request<{ members: OrgMember[]; count: number }>(
|
|
||||||
`/organizations/${orgId}/members`,
|
|
||||||
{}, true, requestConfig,
|
|
||||||
),
|
|
||||||
|
|
||||||
getUserDevices: (orgId: string, userId: string, requestConfig?: RequestConfig) =>
|
|
||||||
request<{ devices: Device[]; count: number }>(
|
|
||||||
`/organizations/${orgId}/users/${userId}/devices`,
|
|
||||||
{}, true, requestConfig,
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||||
listUserSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
listSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ sessions: UserSession[]; count: number }>(
|
request<{ sessions: ActivationSession[]; count: number }>(
|
||||||
`/organizations/${orgId}/sessions`, {}, true, requestConfig,
|
`/organizations/${orgId}/sessions`, {}, true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
adminListSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
endSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ sessions: AdminSession[]; count: number }>(
|
request<{ message: string }>(
|
||||||
`/organizations/${orgId}/admin/sessions`, {}, true, requestConfig,
|
`/organizations/${orgId}/sessions/${sessionId}`,
|
||||||
),
|
{ method: "DELETE" }, true, requestConfig,
|
||||||
|
|
||||||
adminEndSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) =>
|
|
||||||
request<{ session: ActivationSession; message: string }>(
|
|
||||||
`/organizations/${orgId}/admin/sessions/${sessionId}/end`,
|
|
||||||
{ method: "POST", body: "{}" }, true, requestConfig,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Kill Switch ───────────────────────────────────────────────────────────
|
// ── Kill Switch ───────────────────────────────────────────────────────────
|
||||||
@@ -1725,13 +1635,6 @@ export const api = {
|
|||||||
true, requestConfig,
|
true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
networkKillSwitch: (orgId: string, networkId: string, data?: { reason?: string }, requestConfig?: RequestConfig) =>
|
|
||||||
request<{ message: string; count: number }>(
|
|
||||||
`/organizations/${orgId}/networks/${networkId}/kill-switch`,
|
|
||||||
{ method: "POST", body: data ? JSON.stringify(data) : "{}" },
|
|
||||||
true, requestConfig,
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── ZeroTier Controller (org-scoped admin) ─────────────────────────────────
|
// ── ZeroTier Controller (org-scoped admin) ─────────────────────────────────
|
||||||
getZtStatus: (orgId: string, requestConfig?: RequestConfig) =>
|
getZtStatus: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ status: Record<string, unknown> }>(
|
request<{ status: Record<string, unknown> }>(
|
||||||
@@ -1816,6 +1719,7 @@ export interface Department {
|
|||||||
organization_id: string;
|
organization_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
can_sudo: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
@@ -1898,7 +1802,6 @@ export interface OIDCClient {
|
|||||||
redirect_uris: string[];
|
redirect_uris: string[];
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
grant_types: string[];
|
grant_types: string[];
|
||||||
allowed_cors_origins: string[] | null;
|
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -2103,32 +2006,6 @@ export interface AvailableZtNetwork {
|
|||||||
portal_network_name: string | null;
|
portal_network_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrgMember {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
organization_id: string;
|
|
||||||
role: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
deleted_at: string | null;
|
|
||||||
invited_at: string | null;
|
|
||||||
invited_by_id: string | null;
|
|
||||||
joined_at: string | null;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string | null;
|
|
||||||
status: string;
|
|
||||||
avatar_url: string | null;
|
|
||||||
activated: boolean;
|
|
||||||
email_verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
last_login_at: string | null;
|
|
||||||
last_login_ip: string | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -2150,18 +2027,10 @@ export interface UserNetworkApproval {
|
|||||||
id: string;
|
id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
|
||||||
user_email: string | null;
|
|
||||||
portal_network_id: string;
|
portal_network_id: string;
|
||||||
device_id?: string;
|
|
||||||
device_name?: string | null;
|
|
||||||
device_nickname?: string | null;
|
|
||||||
active?: boolean;
|
|
||||||
active_session?: ActivationSession | null;
|
|
||||||
join_seen?: boolean;
|
|
||||||
granted_by_user_id: string | null;
|
granted_by_user_id: string | null;
|
||||||
grant_type: ApprovalGrantType;
|
grant_type: ApprovalGrantType;
|
||||||
status: ApprovalState;
|
state: ApprovalState;
|
||||||
justification: string | null;
|
justification: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -2173,35 +2042,19 @@ export interface DeviceNetworkMembership {
|
|||||||
id: string;
|
id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
|
||||||
user_email: string | null;
|
|
||||||
device_id: string;
|
device_id: string;
|
||||||
device_name: string | null;
|
|
||||||
device_node_id: string | null;
|
|
||||||
portal_network_id: string;
|
portal_network_id: string;
|
||||||
user_network_approval_id: string | null;
|
user_network_approval_id: string | null;
|
||||||
active: boolean;
|
state: MembershipState;
|
||||||
status: ApprovalState;
|
|
||||||
grant_type: ApprovalGrantType;
|
|
||||||
granted_by_user_id: string | null;
|
|
||||||
justification: string | null;
|
|
||||||
join_seen: boolean;
|
join_seen: boolean;
|
||||||
|
currently_authorized: boolean;
|
||||||
|
approved_for_activation: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
active_session: ActivationSession | null;
|
active_session: ActivationSession | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveMembershipState(status: ApprovalState, active: boolean): MembershipState {
|
|
||||||
if (active) return "active_authorized";
|
|
||||||
if (status === "approved") return "approved_inactive";
|
|
||||||
if (status === "pending") return "pending_manager_approval";
|
|
||||||
if (status === "rejected") return "rejected";
|
|
||||||
if (status === "revoked") return "revoked";
|
|
||||||
if (status === "suspended") return "suspended";
|
|
||||||
return "pending_manager_approval";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrichedMembership {
|
export interface EnrichedMembership {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -2243,46 +2096,6 @@ export interface ActivationSession {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSessionDevice {
|
|
||||||
id: string;
|
|
||||||
node_id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSessionNetwork {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSession {
|
|
||||||
id: string;
|
|
||||||
authenticated_at: string;
|
|
||||||
expires_at: string;
|
|
||||||
duration_seconds: number;
|
|
||||||
remaining_seconds: number;
|
|
||||||
is_active: boolean;
|
|
||||||
is_expired: boolean;
|
|
||||||
ended_at: string | null;
|
|
||||||
end_reason: string | null;
|
|
||||||
device: UserSessionDevice;
|
|
||||||
network: UserSessionNetwork;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminSession {
|
|
||||||
id: string;
|
|
||||||
user: { id: string; full_name: string; email: string };
|
|
||||||
authenticated_at: string;
|
|
||||||
expires_at: string;
|
|
||||||
duration_seconds: number;
|
|
||||||
remaining_seconds: number;
|
|
||||||
is_active: boolean;
|
|
||||||
is_expired: boolean;
|
|
||||||
ended_at: string | null;
|
|
||||||
end_reason: string | null;
|
|
||||||
device: UserSessionDevice;
|
|
||||||
network: UserSessionNetwork;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KillSwitchEvent {
|
export interface KillSwitchEvent {
|
||||||
id: string;
|
id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
|
|||||||
@@ -19,12 +19,8 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Link2,
|
Link2,
|
||||||
Unlink,
|
Unlink,
|
||||||
Award,
|
|
||||||
ExternalLink,
|
|
||||||
Lock,
|
Lock,
|
||||||
FileKey,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -60,7 +56,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
function formatDate(d: string | null) {
|
function formatDate(d: string | null) {
|
||||||
@@ -103,7 +99,6 @@ function RoleBadge({ role }: { role: string }) {
|
|||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// User list
|
// User list
|
||||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||||
@@ -167,11 +162,6 @@ export default function AdminUsersPage() {
|
|||||||
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
||||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||||
|
|
||||||
// SSH Certificates summary
|
|
||||||
const [userSshCerts, setUserSshCerts] = useState<AdminUserSshCertificate[]>([]);
|
|
||||||
const [sshCertsCount, setSshCertsCount] = useState(0);
|
|
||||||
const [isSshCertsLoading, setIsSshCertsLoading] = useState(false);
|
|
||||||
|
|
||||||
// ── Fetch users ─────────────────────────────────────────────────────────────
|
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -213,16 +203,12 @@ export default function AdminUsersPage() {
|
|||||||
setUserMfaMethods([]);
|
setUserMfaMethods([]);
|
||||||
setUserLinkedAccounts([]);
|
setUserLinkedAccounts([]);
|
||||||
setTotalAuthMethods(0);
|
setTotalAuthMethods(0);
|
||||||
setUserSshCerts([]);
|
|
||||||
setSshCertsCount(0);
|
|
||||||
setIsSshCertsLoading(true);
|
|
||||||
setIsDrawerLoading(true);
|
setIsDrawerLoading(true);
|
||||||
try {
|
try {
|
||||||
const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([
|
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||||
api.admin.getUser(user.id),
|
api.admin.getUser(user.id),
|
||||||
api.admin.getUserMfa(user.id),
|
api.admin.getUserMfa(user.id),
|
||||||
api.admin.getUserLinkedAccounts(user.id),
|
api.admin.getUserLinkedAccounts(user.id),
|
||||||
api.admin.getUserSshCertificates(user.id, { per_page: 5 }),
|
|
||||||
]);
|
]);
|
||||||
if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys);
|
if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys);
|
||||||
if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
|
if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
|
||||||
@@ -230,18 +216,10 @@ export default function AdminUsersPage() {
|
|||||||
setUserLinkedAccounts(linkedData.value.linked_accounts);
|
setUserLinkedAccounts(linkedData.value.linked_accounts);
|
||||||
setTotalAuthMethods(linkedData.value.total_auth_methods);
|
setTotalAuthMethods(linkedData.value.total_auth_methods);
|
||||||
}
|
}
|
||||||
if (certsData.status === "fulfilled") {
|
|
||||||
setUserSshCerts(certsData.value.certificates);
|
|
||||||
setSshCertsCount(certsData.value.count);
|
|
||||||
} else {
|
|
||||||
setUserSshCerts([]);
|
|
||||||
setSshCertsCount(0);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal
|
// Non-fatal
|
||||||
} finally {
|
} finally {
|
||||||
setIsDrawerLoading(false);
|
setIsDrawerLoading(false);
|
||||||
setIsSshCertsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -951,70 +929,6 @@ export default function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SSH Certificates summary */}
|
|
||||||
{selectedUser.id !== currentUser?.id && (
|
|
||||||
<div className="mt-6 p-4 border rounded-lg space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
<Award className="w-4 h-4" />
|
|
||||||
SSH Certificates
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUser(null);
|
|
||||||
navigate(`/org/members/${selectedUser.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3 mr-1" />
|
|
||||||
Full details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDrawerLoading || isSshCertsLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : sshCertsCount === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No SSH certificates issued.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Total count badge */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
<FileKey className="w-3 h-3 mr-1" />
|
|
||||||
{sshCertsCount} certificate{sshCertsCount !== 1 ? "s" : ""}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent certificates (up to 5) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{userSshCerts.slice(0, 5).map((cert) => (
|
|
||||||
<div key={cert.id} className="flex items-center justify-between p-2 border rounded text-xs">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="font-mono truncate">{cert.key_id}</span>
|
|
||||||
{cert.revoked ? (
|
|
||||||
<Badge variant="destructive" className="text-[10px] px-1 py-0">Revoked</Badge>
|
|
||||||
) : !cert.is_valid ? (
|
|
||||||
<Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">Expired</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge className="bg-green-500/10 text-green-600 border-0 text-[10px] px-1 py-0">Active</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground flex-shrink-0 ml-2">
|
|
||||||
{cert.principals.slice(0, 2).join(", ")}
|
|
||||||
{cert.principals.length > 2 && ` +${cert.principals.length - 2}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Danger zone — Hard delete */}
|
{/* Danger zone — Hard delete */}
|
||||||
{selectedUser.id !== currentUser?.id && (
|
{selectedUser.id !== currentUser?.id && (
|
||||||
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -16,7 +15,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
X,
|
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -125,7 +123,6 @@ const ACTION_FILTER_OPTIONS = [
|
|||||||
export default function SystemAuditPage() {
|
export default function SystemAuditPage() {
|
||||||
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [accessDenied, setAccessDenied] = useState(false);
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
const [isAdminView, setIsAdminView] = useState(false);
|
const [isAdminView, setIsAdminView] = useState(false);
|
||||||
@@ -135,8 +132,6 @@ export default function SystemAuditPage() {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [actionFilter, setActionFilter] = useState("all");
|
const [actionFilter, setActionFilter] = useState("all");
|
||||||
const [successFilter, setSuccessFilter] = useState("all");
|
const [successFilter, setSuccessFilter] = useState("all");
|
||||||
const [userFilter, setUserFilter] = useState<string | null>(null);
|
|
||||||
const [userFilterLabel, setUserFilterLabel] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// pagination
|
// pagination
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -161,7 +156,6 @@ export default function SystemAuditPage() {
|
|||||||
};
|
};
|
||||||
if (actionFilter !== "all") params.action = actionFilter;
|
if (actionFilter !== "all") params.action = actionFilter;
|
||||||
if (successFilter !== "all") params.success = successFilter;
|
if (successFilter !== "all") params.success = successFilter;
|
||||||
if (userFilter) params.user_id = userFilter;
|
|
||||||
if (debouncedSearch) params.q = debouncedSearch;
|
if (debouncedSearch) params.q = debouncedSearch;
|
||||||
|
|
||||||
const resp = await api.admin.getAuditLogs(params);
|
const resp = await api.admin.getAuditLogs(params);
|
||||||
@@ -179,7 +173,7 @@ export default function SystemAuditPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, actionFilter, successFilter, userFilter, debouncedSearch]);
|
}, [page, actionFilter, successFilter, debouncedSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
@@ -188,7 +182,7 @@ export default function SystemAuditPage() {
|
|||||||
// reset to page 1 when filters change
|
// reset to page 1 when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [actionFilter, successFilter, userFilter, debouncedSearch]);
|
}, [actionFilter, successFilter, debouncedSearch]);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => formatDateTime(dateString);
|
const formatDate = (dateString: string) => formatDateTime(dateString);
|
||||||
|
|
||||||
@@ -199,59 +193,6 @@ export default function SystemAuditPage() {
|
|||||||
return ua.slice(0, 40);
|
return ua.slice(0, 40);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
|
||||||
setIsExporting(true);
|
|
||||||
try {
|
|
||||||
const EXPORT_PER_PAGE = 200;
|
|
||||||
const buildParams = (p: number) => {
|
|
||||||
const params: Record<string, string> = { page: String(p), per_page: String(EXPORT_PER_PAGE) };
|
|
||||||
if (actionFilter !== "all") params.action = actionFilter;
|
|
||||||
if (successFilter !== "all") params.success = successFilter;
|
|
||||||
if (userFilter) params.user_id = userFilter;
|
|
||||||
if (debouncedSearch) params.q = debouncedSearch;
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
const first = await api.admin.getAuditLogs(buildParams(1));
|
|
||||||
const allLogs = [...(first.audit_logs ?? [])];
|
|
||||||
const totalPages = first.pages ?? 1;
|
|
||||||
|
|
||||||
if (totalPages > 1) {
|
|
||||||
const remaining = await Promise.all(
|
|
||||||
Array.from({ length: totalPages - 1 }, (_, i) =>
|
|
||||||
api.admin.getAuditLogs(buildParams(i + 2))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
for (const r of remaining) allLogs.push(...(r.audit_logs ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
|
||||||
const header = ["ID","Action","Description","User Email","User ID","Resource Type","Resource ID","IP Address","User Agent","Success","Error Message","Created At","Updated At"];
|
|
||||||
const rows = allLogs.map((l) => [
|
|
||||||
l.id, l.action, l.description ?? "",
|
|
||||||
l.user?.email ?? "", l.user_id ?? "",
|
|
||||||
l.resource_type ?? "", l.resource_id ?? "",
|
|
||||||
l.ip_address ?? "", l.user_agent ?? "",
|
|
||||||
l.success ? "Yes" : "No",
|
|
||||||
l.error_message ?? "",
|
|
||||||
l.created_at, l.updated_at ?? "",
|
|
||||||
].map(esc).join(","));
|
|
||||||
const csv = [header.map(esc).join(","), ...rows].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Export failed:", err);
|
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
}
|
|
||||||
}, [actionFilter, successFilter, userFilter, debouncedSearch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -264,25 +205,15 @@ export default function SystemAuditPage() {
|
|||||||
: "Your account events"}
|
: "Your account events"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline" size="sm"
|
size="sm"
|
||||||
onClick={handleExport}
|
onClick={() => fetchLogs()}
|
||||||
disabled={isExporting || isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
{isExporting ? "Exporting…" : "Export CSV"}
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fetchLogs()}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -319,39 +250,6 @@ export default function SystemAuditPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filter chips */}
|
|
||||||
{(actionFilter !== "all" || successFilter !== "all" || userFilter) && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
||||||
{actionFilter !== "all" && (
|
|
||||||
<Badge variant="secondary" className="gap-1 px-3 py-1">
|
|
||||||
<span className="text-xs">Action: {getActionLabel(actionFilter)}</span>
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => setActionFilter("all")}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{userFilter && (
|
|
||||||
<Badge variant="secondary" className="gap-1 px-3 py-1">
|
|
||||||
<span className="text-xs">User: {userFilterLabel ?? userFilter.slice(0, 8) + "…"}</span>
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => { setUserFilter(null); setUserFilterLabel(null); }}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{successFilter !== "all" && (
|
|
||||||
<Badge variant="secondary" className="gap-1 px-3 py-1">
|
|
||||||
<span className="text-xs">Status: {successFilter === "true" ? "Success only" : "Failures only"}</span>
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => setSuccessFilter("all")}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -396,12 +294,7 @@ export default function SystemAuditPage() {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span className="font-medium text-sm text-foreground">
|
||||||
className="font-medium text-sm text-foreground cursor-pointer hover:text-primary transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
setActionFilter((prev) => (prev === log.action ? "all" : log.action))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getActionLabel(log.action)}
|
{getActionLabel(log.action)}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
|
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
|
||||||
@@ -430,23 +323,9 @@ export default function SystemAuditPage() {
|
|||||||
{/* Meta row */}
|
{/* Meta row */}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
{log.user?.email ? (
|
{log.user?.email ? (
|
||||||
<span
|
<span className="font-medium text-foreground/70">{log.user.email}</span>
|
||||||
className="font-medium text-foreground/70 cursor-pointer hover:text-foreground transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
if (log.user_id) {
|
|
||||||
setUserFilter((prev) => (prev === log.user_id ? null : log.user_id));
|
|
||||||
setUserFilterLabel((prev) => (prev === log.user.email ? null : log.user.email));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{log.user.email}</span>
|
|
||||||
) : log.user_id ? (
|
) : log.user_id ? (
|
||||||
<span
|
<span className="font-mono">{log.user_id.slice(0, 8)}…</span>
|
||||||
className="font-mono cursor-pointer hover:text-foreground transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setUserFilter((prev) => (prev === log.user_id ? null : log.user_id));
|
|
||||||
setUserFilterLabel((prev) => prev === log.user_id ? null : `${log.user_id!.slice(0, 8)}…`);
|
|
||||||
}}
|
|
||||||
>{log.user_id.slice(0, 8)}…</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="italic">System</span>
|
<span className="italic">System</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Plus,
|
Plus,
|
||||||
Award,
|
|
||||||
Clock,
|
|
||||||
FileKey,
|
|
||||||
Globe,
|
|
||||||
Terminal,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -33,13 +26,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -52,7 +38,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -169,236 +155,6 @@ function UserManagementSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Certificate Status Badge ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CertStatusBadge({ cert }: { cert: AdminUserSshCertificate }) {
|
|
||||||
if (cert.revoked) {
|
|
||||||
return (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
<ShieldOff className="w-3 h-3 mr-1" />
|
|
||||||
Revoked
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (cert.status === "superseded") {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
|
||||||
Superseded
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!cert.is_valid) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
Expired
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Active + valid
|
|
||||||
if (cert.days_until_expiry >= 0 && cert.days_until_expiry <= 7) {
|
|
||||||
return (
|
|
||||||
<Badge className="bg-amber-500/10 text-amber-600 border-0 text-xs">
|
|
||||||
<Clock className="w-3 h-3 mr-1" />
|
|
||||||
{cert.days_until_expiry === 0 ? "Expires today" : `${cert.days_until_expiry}d left`}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Certificate Row ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CertificateRow({ cert }: { cert: AdminUserSshCertificate }) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full flex items-start justify-between gap-3 p-4 text-left hover:bg-accent/30 transition-colors"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0 space-y-1.5">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm font-medium font-mono truncate">
|
|
||||||
{cert.key_id}
|
|
||||||
</span>
|
|
||||||
<CertStatusBadge cert={cert} />
|
|
||||||
<Badge variant="secondary" className="text-xs font-mono">
|
|
||||||
{cert.cert_type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
<span className="font-mono">
|
|
||||||
Serial #{cert.serial}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{cert.principals.length > 0
|
|
||||||
? cert.principals.join(", ")
|
|
||||||
: "No principals"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Valid {formatDate(cert.valid_after)} → {formatDate(cert.valid_before)}
|
|
||||||
{cert.days_until_expiry < 0 && (
|
|
||||||
<span className="text-red-500 ml-1">
|
|
||||||
(expired {Math.abs(cert.days_until_expiry)}d ago)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 pt-0.5">
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t bg-muted/30 p-4 space-y-3">
|
|
||||||
{/* Revocation info */}
|
|
||||||
{cert.revoked && (
|
|
||||||
<div className="p-3 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 space-y-1">
|
|
||||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 flex items-center gap-1.5">
|
|
||||||
<ShieldOff className="w-3.5 h-3.5" />
|
|
||||||
Certificate Revoked
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<span className="text-red-600/70 dark:text-red-400/70">Revoked at</span>
|
|
||||||
<span className="text-red-700 dark:text-red-300">{formatDate(cert.revoked_at)}</span>
|
|
||||||
{cert.revoke_reason && (
|
|
||||||
<>
|
|
||||||
<span className="text-red-600/70 dark:text-red-400/70">Reason</span>
|
|
||||||
<span className="text-red-700 dark:text-red-300">{cert.revoke_reason}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Certificate details grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
|
||||||
<span className="text-muted-foreground">Certificate ID</span>
|
|
||||||
<span className="font-mono truncate">{cert.id}</span>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground">CA ID</span>
|
|
||||||
<span className="font-mono truncate">{cert.ca_id}</span>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground">Principals</span>
|
|
||||||
<span className="font-mono">{cert.principals.join(", ")}</span>
|
|
||||||
|
|
||||||
{cert.request_ip && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">Request IP</span>
|
|
||||||
<span className="font-mono flex items-center gap-1">
|
|
||||||
<Globe className="w-3 h-3" />
|
|
||||||
{cert.request_ip}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cert.request_user_agent && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">User Agent</span>
|
|
||||||
<span className="font-mono truncate">{cert.request_user_agent}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="text-muted-foreground">Created</span>
|
|
||||||
<span>{formatDate(cert.created_at)}</span>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground">Last Updated</span>
|
|
||||||
<span>{formatDate(cert.updated_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Extensions */}
|
|
||||||
{Object.keys(cert.extensions).length > 0 && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
|
|
||||||
<Terminal className="w-3 h-3" />
|
|
||||||
Extensions
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{Object.entries(cert.extensions).map(([key, val]) => (
|
|
||||||
<Badge key={key} variant="secondary" className="text-[10px] font-mono">
|
|
||||||
{key}{val ? `=${val}` : ""}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Critical options */}
|
|
||||||
{Object.keys(cert.critical_options).length > 0 && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
Critical Options
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{Object.entries(cert.critical_options).map(([key, val]) => (
|
|
||||||
<Badge key={key} variant="outline" className="text-[10px] font-mono text-amber-600 border-amber-300">
|
|
||||||
{key}{val ? `=${val}` : ""}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SSH Key info */}
|
|
||||||
{cert.ssh_key && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
|
|
||||||
<Key className="w-3 h-3" />
|
|
||||||
SSH Key
|
|
||||||
</p>
|
|
||||||
<div className="p-3 rounded-md border bg-background space-y-1.5 text-xs">
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
||||||
<span className="text-muted-foreground">Fingerprint</span>
|
|
||||||
<span className="font-mono truncate">{cert.ssh_key.fingerprint}</span>
|
|
||||||
<span className="text-muted-foreground">Type</span>
|
|
||||||
<span className="font-mono">{cert.ssh_key.key_type} ({cert.ssh_key.key_bits} bits)</span>
|
|
||||||
{cert.ssh_key.description && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">Description</span>
|
|
||||||
<span>{cert.ssh_key.description}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{cert.ssh_key.key_comment && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">Comment</span>
|
|
||||||
<span className="font-mono">{cert.ssh_key.key_comment}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground">Verified</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{cert.ssh_key.verified ? (
|
|
||||||
<><CheckCircle className="w-3 h-3 text-green-500" /> Yes</>
|
|
||||||
) : (
|
|
||||||
<><XCircle className="w-3 h-3 text-amber-500" /> No</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!cert.ssh_key && (
|
|
||||||
<p className="text-xs text-muted-foreground italic">No SSH key linked to this certificate</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Component ────────────────────────────────────────────────────────────
|
// ── Main Component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function UserManagementPage() {
|
export default function UserManagementPage() {
|
||||||
@@ -451,16 +207,6 @@ export default function UserManagementPage() {
|
|||||||
// Role update state
|
// Role update state
|
||||||
const [selectedRole, setSelectedRole] = useState<string>("member");
|
const [selectedRole, setSelectedRole] = useState<string>("member");
|
||||||
|
|
||||||
// SSH Certificates state
|
|
||||||
const [sshCerts, setSshCerts] = useState<AdminUserSshCertificate[]>([]);
|
|
||||||
const [isCertsLoading, setIsCertsLoading] = useState(false);
|
|
||||||
const [certsPage, setCertsPage] = useState(1);
|
|
||||||
const [certsPages, setCertsPages] = useState(1);
|
|
||||||
const [certsCount, setCertsCount] = useState(0);
|
|
||||||
const [certStatusFilter, setCertStatusFilter] = useState<string>("all");
|
|
||||||
const [certActiveFilter, setCertActiveFilter] = useState<string>("all");
|
|
||||||
const [certTypeFilter, setCertTypeFilter] = useState<string>("all");
|
|
||||||
|
|
||||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
||||||
@@ -799,38 +545,6 @@ export default function UserManagementPage() {
|
|||||||
};
|
};
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// ── Fetch SSH Certificates ───────────────────────────────────────────────────
|
|
||||||
// Reset page when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
setCertsPage(1);
|
|
||||||
}, [certStatusFilter, certActiveFilter, certTypeFilter]);
|
|
||||||
|
|
||||||
// Fetch SSH certificates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const fetchCerts = async () => {
|
|
||||||
setIsCertsLoading(true);
|
|
||||||
try {
|
|
||||||
const params: Record<string, string | number> = { page: certsPage, per_page: 20 };
|
|
||||||
if (certStatusFilter !== "all") params.status = certStatusFilter;
|
|
||||||
if (certActiveFilter !== "all") params.active = certActiveFilter;
|
|
||||||
if (certTypeFilter !== "all") params.cert_type = certTypeFilter;
|
|
||||||
const data = await api.admin.getUserSshCertificates(userId, params);
|
|
||||||
if (cancelled) return;
|
|
||||||
setSshCerts(data.certificates);
|
|
||||||
setCertsPages(data.pages);
|
|
||||||
setCertsCount(data.count);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setSshCerts([]);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setIsCertsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchCerts();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [userId, certsPage, certStatusFilter, certActiveFilter, certTypeFilter]);
|
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -874,10 +588,6 @@ export default function UserManagementPage() {
|
|||||||
<TabsTrigger value="details">User Details</TabsTrigger>
|
<TabsTrigger value="details">User Details</TabsTrigger>
|
||||||
<TabsTrigger value="security">Security</TabsTrigger>
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
<TabsTrigger value="access">Access</TabsTrigger>
|
<TabsTrigger value="access">Access</TabsTrigger>
|
||||||
<TabsTrigger value="certs">
|
|
||||||
<FileKey className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
SSH Certificates
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ── User Details Tab ────────────────────────────────────────────── */}
|
{/* ── User Details Tab ────────────────────────────────────────────── */}
|
||||||
@@ -1208,110 +918,6 @@ export default function UserManagementPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── SSH Certificates Tab ──────────────────────────────────────────── */}
|
|
||||||
<TabsContent value="certs">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Award className="w-5 h-5" />
|
|
||||||
SSH Certificates
|
|
||||||
</div>
|
|
||||||
{!isCertsLoading && (
|
|
||||||
<Badge variant="secondary">{certsCount}</Badge>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
SSH certificates issued to this user via the organization's Certificate Authority
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* Filter controls */}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
<Select value={certStatusFilter} onValueChange={setCertStatusFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="All statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
|
||||||
<SelectItem value="issued">Issued</SelectItem>
|
|
||||||
<SelectItem value="revoked">Revoked</SelectItem>
|
|
||||||
<SelectItem value="expired">Expired</SelectItem>
|
|
||||||
<SelectItem value="superseded">Superseded</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={certActiveFilter} onValueChange={setCertActiveFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="All certs" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All certs</SelectItem>
|
|
||||||
<SelectItem value="true">Active only</SelectItem>
|
|
||||||
<SelectItem value="false">Inactive only</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={certTypeFilter} onValueChange={setCertTypeFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="All types" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All types</SelectItem>
|
|
||||||
<SelectItem value="user">User</SelectItem>
|
|
||||||
<SelectItem value="host">Host</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isCertsLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : sshCerts.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<Award className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
|
||||||
<p className="text-sm font-medium">No certificates found</p>
|
|
||||||
<p className="text-xs mt-1">This user has not been issued any SSH certificates yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sshCerts.map((cert) => (
|
|
||||||
<CertificateRow key={cert.id} cert={cert} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{certsPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Page {certsPage} of {certsPages} · {certsCount} total
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCertsPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={certsPage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCertsPage((p) => Math.min(certsPages, p + 1))}
|
|
||||||
disabled={certsPage === certsPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
|
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
|
||||||
@@ -1363,34 +969,6 @@ export default function UserManagementPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── Suspend confirmation dialog ─────────────────────────────────────── */}
|
|
||||||
<Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
|
||||||
<AlertTriangle className="w-5 h-5" />
|
|
||||||
Suspend account?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{user?.full_name || user?.email}</strong> will be blocked from requesting SSH certificates. You can restore their access at any time.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowSuspendConfirm(false)} disabled={isSuspending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleSuspend}
|
|
||||||
disabled={isSuspending}
|
|
||||||
>
|
|
||||||
{isSuspending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Suspend
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ── Admin add SSH key dialog ──────────────────────────────────────────── */}
|
{/* ── Admin add SSH key dialog ──────────────────────────────────────────── */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showAddKey}
|
open={showAddKey}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise<s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin } = useAuth();
|
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin, sessionExpired, clearSessionExpired } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -88,6 +88,13 @@ export default function LoginPage() {
|
|||||||
.catch(() => {/* ignore — user will just land on normal login */});
|
.catch(() => {/* ignore — user will just land on normal login */});
|
||||||
}, [cliToken]);
|
}, [cliToken]);
|
||||||
|
|
||||||
|
// Clear session expired flag once the user sees the login page
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionExpired) {
|
||||||
|
clearSessionExpired();
|
||||||
|
}
|
||||||
|
}, [sessionExpired, clearSessionExpired]);
|
||||||
|
|
||||||
const finishCliFlow = useCallback((token: string) => {
|
const finishCliFlow = useCallback((token: string) => {
|
||||||
if (!cliRedirectUrl) return false;
|
if (!cliRedirectUrl) return false;
|
||||||
// cliRedirectUrl already ends with "token=" — just append the value
|
// cliRedirectUrl already ends with "token=" — just append the value
|
||||||
@@ -955,6 +962,12 @@ export default function LoginPage() {
|
|||||||
{oidcError && (
|
{oidcError && (
|
||||||
<p className="text-sm text-destructive mt-2">{oidcError}</p>
|
<p className="text-sm text-destructive mt-2">{oidcError}</p>
|
||||||
)}
|
)}
|
||||||
|
{sessionExpired && (
|
||||||
|
<div className="mt-4 p-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800 text-sm flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Your session has expired due to inactivity. Please sign in again to continue.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -362,147 +362,6 @@ $ systemctl restart sshd`}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Deployment Guide */}
|
|
||||||
<section className="py-16 lg:py-24 bg-muted/30">
|
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 text-accent text-sm font-medium mb-4">
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
Deployment Guide
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-4">
|
|
||||||
Deploy to Your Servers
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
One-time setup per server. The script below installs the CA key, configures
|
|
||||||
principal-based access, and reloads SSH — all in a single idempotent run.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">1</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold mb-1">Get your CA public key</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
In the Secuird dashboard, go to <strong>Certificate Authorities</strong> and
|
|
||||||
copy the <strong>User CA</strong> public key from the detail card.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">2</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold mb-1">Decide the Unix user and principal</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Each server has a local Unix user (e.g. <code className="font-mono text-xs">ubuntu</code>, <code className="font-mono text-xs">deploy</code>, <code className="font-mono text-xs">root</code>)
|
|
||||||
that SSH sessions connect to. Choose which <strong>principal</strong> (from your Secuird configuration) should be
|
|
||||||
allowed to log in as that user.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">3</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold mb-1">Run the setup script</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
SSH into the server and run the script below as <strong>root</strong>. Paste your
|
|
||||||
CA public key, set the Unix user and principal, then execute.
|
|
||||||
</p>
|
|
||||||
<Card>
|
|
||||||
<div className="bg-muted/50 px-4 py-2 border-b flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-destructive/60" />
|
|
||||||
<div className="h-3 w-3 rounded-full bg-warning/60" />
|
|
||||||
<div className="h-3 w-3 rounded-full bg-success/60" />
|
|
||||||
<span className="text-xs text-muted-foreground ml-2 font-mono">deploy.sh</span>
|
|
||||||
</div>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<pre className="p-4 text-sm font-mono text-foreground overflow-x-auto">
|
|
||||||
<code>
|
|
||||||
{`#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CA_KEY='<Your CA public key>'
|
|
||||||
UNIX_USER="ubuntu" # ← change to the server's unix user
|
|
||||||
PRINCIPAL="<Your principal>" # ← change to the principal for this user
|
|
||||||
|
|
||||||
CA_FILE="/etc/ssh/trusted_user_ca"
|
|
||||||
PRINCIPALS_DIR="/etc/ssh/auth_principals"
|
|
||||||
SSHD_DROP_IN="/etc/ssh/sshd_config.d/99-ca-auth.conf"
|
|
||||||
|
|
||||||
if [[ "$(id -u)" -ne 0 ]]; then
|
|
||||||
echo "error: must be run as root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m 0644 -o root -g root /dev/null "\${CA_FILE}"
|
|
||||||
echo "\${CA_KEY}" > "\${CA_FILE}"
|
|
||||||
|
|
||||||
install -d -m 0755 -o root -g root "\${PRINCIPALS_DIR}"
|
|
||||||
install -m 0644 -o root -g root /dev/null "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
|
||||||
echo "\${PRINCIPAL}" > "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
|
||||||
|
|
||||||
install -d -m 0755 -o root -g root "/etc/ssh/sshd_config.d"
|
|
||||||
install -m 0600 -o root -g root /dev/null "\${SSHD_DROP_IN}"
|
|
||||||
cat > "\${SSHD_DROP_IN}" <<EOF
|
|
||||||
TrustedUserCAKeys \${CA_FILE}
|
|
||||||
AuthorizedPrincipalsFile \${PRINCIPALS_DIR}/%u
|
|
||||||
EOF
|
|
||||||
|
|
||||||
if sshd -t; then
|
|
||||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
||||||
echo "done — CA trust and principal '\${PRINCIPAL}' configured for '\${UNIX_USER}'"
|
|
||||||
else
|
|
||||||
echo "error: sshd configuration test failed — SSH was NOT reloaded" >&2
|
|
||||||
exit 1
|
|
||||||
fi`}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">4</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold mb-1">Verify the configuration</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The script validates <code className="font-mono text-xs">sshd -t</code> before reloading — if you see
|
|
||||||
<strong>"done"</strong> at the end, everything is working. To double-check, run:
|
|
||||||
</p>
|
|
||||||
<pre className="mt-2 p-3 bg-muted rounded text-xs font-mono text-foreground overflow-x-auto">
|
|
||||||
<code>{`ssh -T user@your-server # should succeed without a password prompt`}</code>
|
|
||||||
</pre>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
Repeat on every server. Once the CA key is trusted, <strong>any</strong> user with a valid
|
|
||||||
Secuird-signed certificate for the matching principal can connect — no more distributing
|
|
||||||
individual SSH keys to each server.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Deep Dive */}
|
{/* Features Deep Dive */}
|
||||||
<section className="py-16 lg:py-24 bg-muted/30">
|
<section className="py-16 lg:py-24 bg-muted/30">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
|||||||
+140
-432
@@ -11,12 +11,11 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
UserPlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Skull,
|
Skull,
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -58,7 +57,7 @@ import {
|
|||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
UserNetworkApproval,
|
UserNetworkApproval,
|
||||||
AdminSession,
|
ActivationSession,
|
||||||
KillSwitchEvent,
|
KillSwitchEvent,
|
||||||
PortalNetwork,
|
PortalNetwork,
|
||||||
OrganizationMember,
|
OrganizationMember,
|
||||||
@@ -93,25 +92,13 @@ function formatExpiry(d: string | null | undefined) {
|
|||||||
return `${Math.floor(diff / 1440)}d ${Math.floor((diff % 1440) / 60)}h left`;
|
return `${Math.floor(diff / 1440)}d ${Math.floor((diff % 1440) / 60)}h left`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ApprovalState, string> = {
|
|
||||||
pending: "Pending",
|
|
||||||
approved: "Approved",
|
|
||||||
rejected: "Rejected",
|
|
||||||
revoked: "Revoked",
|
|
||||||
suspended: "Suspended",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getStatusLabel(state: ApprovalState) {
|
|
||||||
return STATUS_LABELS[state] ?? state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
|
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
|
||||||
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
|
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: STATUS_LABELS.pending },
|
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: STATUS_LABELS.approved },
|
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: STATUS_LABELS.rejected },
|
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: STATUS_LABELS.revoked },
|
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: STATUS_LABELS.suspended },
|
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 };
|
const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state };
|
||||||
return (
|
return (
|
||||||
@@ -127,7 +114,7 @@ export default function AccessPage() {
|
|||||||
|
|
||||||
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
|
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
|
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
||||||
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
|
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
|
||||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||||
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
||||||
@@ -136,16 +123,17 @@ export default function AccessPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
|
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
|
||||||
|
|
||||||
const [approvalStateFilter, setApprovalStateFilter] = useState<ApprovalState | "all">("all");
|
|
||||||
const [sortColumn, setSortColumn] = useState("requested");
|
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
|
||||||
|
|
||||||
const [approveId, setApproveId] = useState<string | null>(null);
|
const [approveId, setApproveId] = useState<string | null>(null);
|
||||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||||
const [revokeId, setRevokeId] = useState<string | null>(null);
|
const [revokeId, setRevokeId] = useState<string | null>(null);
|
||||||
const [unsuspendId, setUnsuspendId] = useState<string | null>(null);
|
|
||||||
const [isApproving, setIsApproving] = useState(false);
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
const [rejectConfirmId, setRejectConfirmId] = useState<string | null>(null);
|
|
||||||
|
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 [showKillSwitch, setShowKillSwitch] = useState(false);
|
||||||
const [killTargetUserId, setKillTargetUserId] = useState("");
|
const [killTargetUserId, setKillTargetUserId] = useState("");
|
||||||
@@ -155,8 +143,7 @@ export default function AccessPage() {
|
|||||||
const [killError, setKillError] = useState<string | null>(null);
|
const [killError, setKillError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [endSessionId, setEndSessionId] = useState<string | null>(null);
|
const [endSessionId, setEndSessionId] = useState<string | null>(null);
|
||||||
const [showEndSessionConfirm, setShowEndSessionConfirm] = useState(false);
|
const [isEndingSession, setIsEndingSession] = useState(false);
|
||||||
const [endSessionTarget, setEndSessionTarget] = useState<AdminSession | null>(null);
|
|
||||||
|
|
||||||
const [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
|
const [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
|
||||||
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
|
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
|
||||||
@@ -177,7 +164,7 @@ export default function AccessPage() {
|
|||||||
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
||||||
api.zerotier.listPendingApprovals(orgId),
|
api.zerotier.listPendingApprovals(orgId),
|
||||||
api.zerotier.adminListAllApprovals(orgId),
|
api.zerotier.adminListAllApprovals(orgId),
|
||||||
api.zerotier.adminListSessions(orgId),
|
api.zerotier.listSessions(orgId),
|
||||||
api.zerotier.listNetworks(orgId),
|
api.zerotier.listNetworks(orgId),
|
||||||
api.organizations.getMembers(orgId),
|
api.organizations.getMembers(orgId),
|
||||||
api.zerotier.adminListAllMemberships(orgId),
|
api.zerotier.adminListAllMemberships(orgId),
|
||||||
@@ -201,21 +188,6 @@ export default function AccessPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const refreshSessions = useCallback(async () => {
|
|
||||||
if (!orgId) return;
|
|
||||||
try {
|
|
||||||
const sessionsRes = await api.zerotier.adminListSessions(orgId);
|
|
||||||
setSessions(sessionsRes.sessions || []);
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
}, [orgId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => refreshSessions(), 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [refreshSessions]);
|
|
||||||
|
|
||||||
const handleApprove = async (approvalId: string) => {
|
const handleApprove = async (approvalId: string) => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setApproveId(approvalId);
|
setApproveId(approvalId);
|
||||||
@@ -261,21 +233,26 @@ export default function AccessPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnsuspend = async (approval: UserNetworkApproval) => {
|
const handleAssign = async () => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setUnsuspendId(approval.id);
|
setAssignError(null);
|
||||||
|
if (!assignUserId) { setAssignError("Please select a user."); return; }
|
||||||
|
if (!assignNetworkId) { setAssignError("Please select a network."); return; }
|
||||||
|
setIsAssigning(true);
|
||||||
try {
|
try {
|
||||||
await api.zerotier.assignAccess(orgId, {
|
await api.zerotier.assignAccess(orgId, {
|
||||||
target_user_id: approval.user_id,
|
target_user_id: assignUserId,
|
||||||
portal_network_id: approval.portal_network_id,
|
portal_network_id: assignNetworkId,
|
||||||
justification: "Reinstating suspended membership",
|
justification: assignJustification.trim() || undefined,
|
||||||
});
|
});
|
||||||
toast({ title: "Access restored", description: "The user's access has been reinstated." });
|
toast({ title: "Access assigned", description: "The user can now register devices for this network." });
|
||||||
|
setShowAssign(false);
|
||||||
|
setAssignUserId(""); setAssignNetworkId(""); setAssignJustification("");
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({ variant: "destructive", title: "Failed to restore access", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
setAssignError(err instanceof ApiError ? err.message : "Failed to assign access.");
|
||||||
} finally {
|
} finally {
|
||||||
setUnsuspendId(null);
|
setIsAssigning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,42 +278,18 @@ export default function AccessPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndSession = (session: AdminSession) => {
|
const handleEndSession = async (sessionId: string) => {
|
||||||
setEndSessionTarget(session);
|
if (!orgId) return;
|
||||||
setShowEndSessionConfirm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEndSessionConfirm = async () => {
|
|
||||||
if (!orgId || !endSessionTarget) return;
|
|
||||||
const sessionId = endSessionTarget.id;
|
|
||||||
setEndSessionId(sessionId);
|
setEndSessionId(sessionId);
|
||||||
setShowEndSessionConfirm(false);
|
setIsEndingSession(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.zerotier.adminEndSession(orgId, sessionId);
|
await api.zerotier.endSession(orgId, sessionId);
|
||||||
setSessions((prev) =>
|
toast({ title: "Session ended" });
|
||||||
prev.map((s) =>
|
|
||||||
s.id === sessionId
|
|
||||||
? { ...s, ended_at: res.session.ended_at, is_expired: true, is_active: false }
|
|
||||||
: s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
toast({ title: "Session ended", description: res.message });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
if (err.message?.includes("NOT_FOUND")) {
|
|
||||||
toast({ variant: "destructive", title: "Session not found", description: `Session ${sessionId} not found.` });
|
|
||||||
} else if (err.message?.includes("already ended")) {
|
|
||||||
toast({ variant: "destructive", title: "Session already ended", description: "This session has already been ended." });
|
|
||||||
} else {
|
|
||||||
toast({ variant: "destructive", title: "Failed to end session", description: err.message });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast({ variant: "destructive", title: "Failed to end session", description: "Something went wrong." });
|
|
||||||
}
|
|
||||||
fetchData();
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
toast({ variant: "destructive", title: "Failed to end session", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
||||||
} finally {
|
} finally {
|
||||||
setEndSessionId(null);
|
setEndSessionId(null);
|
||||||
setEndSessionTarget(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,6 +344,9 @@ export default function AccessPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredSessions = sessions.filter((s) => s.is_active);
|
||||||
|
const activeSessions = filteredSessions;
|
||||||
|
|
||||||
const getNetworkName = (networkId: string) => {
|
const getNetworkName = (networkId: string) => {
|
||||||
return networks.find((n) => n.id === networkId)?.name ?? networkId;
|
return networks.find((n) => n.id === networkId)?.name ?? networkId;
|
||||||
};
|
};
|
||||||
@@ -400,69 +356,10 @@ export default function AccessPage() {
|
|||||||
return member?.user?.email ?? member?.user?.full_name ?? userId;
|
return member?.user?.email ?? member?.user?.full_name ?? userId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredApprovals = approvals.filter((a) => {
|
|
||||||
if (approvalStateFilter !== "all" && a.state !== approvalStateFilter) return false;
|
|
||||||
if (selectedNetworkFilter !== "all" && a.portal_network_id !== selectedNetworkFilter) return false;
|
|
||||||
if (search) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const userDisplay = getUserDisplay(a.user_id).toLowerCase();
|
|
||||||
const deviceName = (a.device_name || a.device_nickname || a.device_id || "").toLowerCase();
|
|
||||||
const networkName = getNetworkName(a.portal_network_id).toLowerCase();
|
|
||||||
const statusLabel = getStatusLabel(a.status).toLowerCase();
|
|
||||||
if (!userDisplay.includes(q) && !deviceName.includes(q) && !networkName.includes(q) && !statusLabel.includes(q)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSort = (column: string) => {
|
|
||||||
if (sortColumn === column) {
|
|
||||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
|
||||||
} else {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection("asc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortIndicator = (column: string) => {
|
|
||||||
if (sortColumn !== column) return null;
|
|
||||||
return sortDirection === "asc" ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedApprovals = [...filteredApprovals].sort((a, b) => {
|
|
||||||
let cmp = 0;
|
|
||||||
switch (sortColumn) {
|
|
||||||
case "user":
|
|
||||||
cmp = getUserDisplay(a.user_id).localeCompare(getUserDisplay(b.user_id));
|
|
||||||
break;
|
|
||||||
case "device": {
|
|
||||||
const aDevice = a.device_name || a.device_nickname || a.device_id || "";
|
|
||||||
const bDevice = b.device_name || b.device_nickname || b.device_id || "";
|
|
||||||
cmp = aDevice.localeCompare(bDevice);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "network":
|
|
||||||
cmp = getNetworkName(a.portal_network_id).localeCompare(getNetworkName(b.portal_network_id));
|
|
||||||
break;
|
|
||||||
case "status":
|
|
||||||
cmp = a.status.localeCompare(b.status);
|
|
||||||
break;
|
|
||||||
case "requested":
|
|
||||||
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
||||||
break;
|
|
||||||
case "approved":
|
|
||||||
cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return sortDirection === "asc" ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredSessions = sessions.filter((s) => s.is_active);
|
|
||||||
const activeSessions = filteredSessions;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">ZeroTier Access</h1>
|
<h1 className="page-title">Access Control</h1>
|
||||||
<p className="page-description">Manage network access requests, approvals, and active sessions</p>
|
<p className="page-description">Manage network access requests, approvals, and active sessions</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -485,6 +382,9 @@ export default function AccessPage() {
|
|||||||
{networks.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
|
{networks.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
<Button variant="destructive" onClick={() => setShowKillSwitch(true)} className="gap-2">
|
||||||
<Skull className="w-4 h-4" /> Kill Switch
|
<Skull className="w-4 h-4" /> Kill Switch
|
||||||
</Button>
|
</Button>
|
||||||
@@ -544,7 +444,7 @@ export default function AccessPage() {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
||||||
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
||||||
<ApprovalStateBadge state={approval.status} />
|
<ApprovalStateBadge state={approval.state} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
||||||
@@ -610,34 +510,12 @@ export default function AccessPage() {
|
|||||||
<Zap className="w-4 h-4 text-green-500" />
|
<Zap className="w-4 h-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{session.user?.full_name || session.user?.email || "Unknown user"}</p>
|
<p className="font-medium font-mono truncate">{session.device_network_membership_id}</p>
|
||||||
{session.user?.email && (
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">{session.user.email}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
||||||
{session.device && (
|
|
||||||
<Badge variant="outline" className="text-xs font-mono">
|
|
||||||
{session.device.name || session.device.node_id}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{session.network && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{session.network.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
|
|
||||||
<span>Activated: {formatDate(session.authenticated_at)}</span>
|
<span>Activated: {formatDate(session.authenticated_at)}</span>
|
||||||
<span className="text-green-600 font-medium flex items-center gap-1">
|
<span className="text-green-600 font-medium flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{session.remaining_seconds > 0
|
{formatExpiry(session.expires_at)}
|
||||||
? (() => {
|
|
||||||
const min = Math.floor(session.remaining_seconds / 60);
|
|
||||||
return min >= 60
|
|
||||||
? `${Math.floor(min / 60)}h ${min % 60}m remaining`
|
|
||||||
: `${min}m remaining`;
|
|
||||||
})()
|
|
||||||
: "Expired"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,7 +523,7 @@ export default function AccessPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
|
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
|
||||||
onClick={() => handleEndSession(session)}
|
onClick={() => handleEndSession(session.id)}
|
||||||
disabled={endSessionId === session.id}
|
disabled={endSessionId === session.id}
|
||||||
>
|
>
|
||||||
{endSessionId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
{endSessionId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||||
@@ -663,25 +541,10 @@ export default function AccessPage() {
|
|||||||
<TabsContent value="approvals">
|
<TabsContent value="approvals">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<Shield className="w-4 h-4" />
|
||||||
<Shield className="w-4 h-4" />
|
All Approvals
|
||||||
All Approvals
|
</CardTitle>
|
||||||
</CardTitle>
|
|
||||||
<Select value={approvalStateFilter} onValueChange={(v) => setApprovalStateFilter(v as ApprovalState | "all")}>
|
|
||||||
<SelectTrigger className="w-[160px] h-8">
|
|
||||||
<SelectValue placeholder="Filter by state" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All States</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
<SelectItem value="revoked">Revoked</SelectItem>
|
|
||||||
<SelectItem value="suspended">Suspended</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<CardDescription>Complete history of network access grants</CardDescription>
|
<CardDescription>Complete history of network access grants</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -690,190 +553,41 @@ export default function AccessPage() {
|
|||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-muted-foreground">Loading…</span>
|
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
) : filteredApprovals.length === 0 ? (
|
) : approvals.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
<div className="p-8 text-center text-muted-foreground">No approvals found.</div>
|
||||||
{approvalStateFilter !== "all" || search || selectedNetworkFilter !== "all"
|
|
||||||
? "No approvals match your filters."
|
|
||||||
: "No approvals found."}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="divide-y">
|
||||||
<table className="w-full text-sm">
|
{approvals.map((approval) => (
|
||||||
<thead>
|
<div key={approval.id} className="flex items-center gap-4 p-4">
|
||||||
<tr className="border-b bg-muted/50">
|
<div className="flex-1 min-w-0">
|
||||||
<th className="text-left p-3 font-medium">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button onClick={() => handleSort("user")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
||||||
User {getSortIndicator("user")}
|
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
||||||
</button>
|
<ApprovalStateBadge state={approval.state} />
|
||||||
</th>
|
</div>
|
||||||
<th className="text-left p-3 font-medium">
|
<p className="text-sm text-muted-foreground">
|
||||||
<button onClick={() => handleSort("device")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
||||||
Device {getSortIndicator("device")}
|
{approval.justification && ` — "${approval.justification}"`}
|
||||||
</button>
|
</p>
|
||||||
</th>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<th className="text-left p-3 font-medium">
|
{formatDate(approval.created_at)}
|
||||||
<button onClick={() => handleSort("network")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
{approval.granted_by_user_id && ` · Granted by: ${getUserDisplay(approval.granted_by_user_id)}`}
|
||||||
Network {getSortIndicator("network")}
|
</p>
|
||||||
</button>
|
</div>
|
||||||
</th>
|
{(approval.state === "approved" || approval.state === "suspended") && (
|
||||||
<th className="text-left p-3 font-medium">
|
<Button
|
||||||
<button onClick={() => handleSort("status")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
size="sm"
|
||||||
Status {getSortIndicator("status")}
|
variant="outline"
|
||||||
</button>
|
className="text-red-600 border-red-300 hover:bg-red-50 gap-1 flex-shrink-0"
|
||||||
</th>
|
onClick={() => handleRevoke(approval.id)}
|
||||||
<th className="text-left p-3 font-medium">
|
disabled={revokeId === approval.id || isApproving}
|
||||||
<button onClick={() => handleSort("requested")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
|
||||||
Requested {getSortIndicator("requested")}
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
<th className="text-left p-3 font-medium">
|
|
||||||
<button onClick={() => handleSort("approved")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
|
||||||
Approved {getSortIndicator("approved")}
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
<th className="text-right p-3 font-medium">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{sortedApprovals.map((approval) => (
|
|
||||||
<tr key={approval.id} className="hover:bg-accent/30">
|
|
||||||
<td className="p-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<button type="button" onClick={() => setSearch(getUserDisplay(approval.user_id))} className="font-medium truncate max-w-[160px] text-left cursor-pointer hover:underline">
|
|
||||||
{getUserDisplay(approval.user_id)}
|
|
||||||
</button>
|
|
||||||
{approval.justification && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate max-w-[160px]" title={approval.justification}>
|
|
||||||
"{approval.justification}"
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{approval.device_name || approval.device_nickname ? (
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={() => setSearch(approval.device_name || approval.device_nickname || "")} className="text-sm font-medium truncate max-w-[160px] text-left cursor-pointer hover:underline">
|
|
||||||
{approval.device_name || approval.device_nickname}
|
|
||||||
</button>
|
|
||||||
{approval.device_nickname && approval.device_name !== approval.device_nickname && (
|
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate max-w-[160px]">{approval.device_id}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : approval.device_id ? (
|
|
||||||
<button type="button" onClick={() => setSearch(approval.device_id)} className="font-mono text-xs cursor-pointer hover:underline">
|
|
||||||
{approval.device_id}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<button type="button" onClick={() => setSearch(getNetworkName(approval.portal_network_id))} className="text-xs font-medium cursor-pointer hover:underline">
|
|
||||||
{getNetworkName(approval.portal_network_id)}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<button type="button" onClick={() => setSearch(getStatusLabel(approval.status))} className="cursor-pointer">
|
|
||||||
<ApprovalStateBadge state={approval.status} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<span className="text-xs">{formatDate(approval.created_at)}</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{approval.status !== "pending" ? (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs">{formatDate(approval.updated_at)}</span>
|
|
||||||
{approval.granted_by_user_id && (
|
|
||||||
<p className="text-xs text-muted-foreground">by {getUserDisplay(approval.granted_by_user_id)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
{approval.status === "pending" || approval.status === "approved" || approval.status === "suspended" ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
disabled={
|
|
||||||
(approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
|
|
||||||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id || unsuspendId === approval.id))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{((approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
|
{revokeId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
|
||||||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id || unsuspendId === approval.id))) ? (
|
Revoke
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
</Button>
|
||||||
) : (
|
)}
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
</div>
|
||||||
)}
|
))}
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
|
||||||
{approval.status === "pending" && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleApprove(approval.id)}
|
|
||||||
disabled={approveId === approval.id || isApproving}
|
|
||||||
className="text-green-600 focus:text-green-700"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Approve
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setRejectConfirmId(approval.id)}
|
|
||||||
disabled={rejectId === approval.id || isApproving}
|
|
||||||
className="text-red-600 focus:text-red-700"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
|
||||||
Reject
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{approval.status === "suspended" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleUnsuspend(approval)}
|
|
||||||
disabled={unsuspendId === approval.id || isApproving}
|
|
||||||
className="text-amber-600 focus:text-amber-700"
|
|
||||||
>
|
|
||||||
{unsuspendId === approval.id ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
|
||||||
Restore Access
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{approval.status === "approved" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleRevoke(approval.id)}
|
|
||||||
disabled={revokeId === approval.id || isApproving}
|
|
||||||
className="text-red-600 focus:text-red-700"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
|
||||||
Revoke
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{approval.status === "suspended" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleRevoke(approval.id)}
|
|
||||||
disabled={revokeId === approval.id || isApproving}
|
|
||||||
className="text-red-600 focus:text-red-700"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
|
||||||
Revoke
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1006,6 +720,58 @@ export default function AccessPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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 */}
|
{/* Kill Switch Dialog */}
|
||||||
<Dialog open={showKillSwitch} onOpenChange={(open) => { if (!open) setShowKillSwitch(false); }}>
|
<Dialog open={showKillSwitch} onOpenChange={(open) => { if (!open) setShowKillSwitch(false); }}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
@@ -1015,7 +781,7 @@ export default function AccessPage() {
|
|||||||
Kill Switch
|
Kill Switch
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Instantly deactivate all active sessions for a user across all managed networks.
|
Instantly deactivate all active sessions for a user across all managed networks. This cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
@@ -1023,7 +789,7 @@ export default function AccessPage() {
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks. They will not be able to re-connect until re-authorised
|
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1069,64 +835,6 @@ export default function AccessPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Reject Confirmation Dialog */}
|
|
||||||
<Dialog open={!!rejectConfirmId} onOpenChange={(open) => { if (!open) setRejectConfirmId(null); }}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Reject Request</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to reject this access request? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="p-3 border border-red-300 rounded-lg bg-red-50 text-sm text-red-800">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>The request will be permanently rejected. The user will need to submit a new request if they want access in the future.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setRejectConfirmId(null)} disabled={rejectId !== null}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
const id = rejectConfirmId;
|
|
||||||
setRejectConfirmId(null);
|
|
||||||
if (id) handleReject(id);
|
|
||||||
}}
|
|
||||||
disabled={rejectId !== null}
|
|
||||||
>
|
|
||||||
{rejectId !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Reject Request
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* End Session Confirmation Dialog */}
|
|
||||||
<Dialog open={showEndSessionConfirm} onOpenChange={(open) => { if (!open) { setShowEndSessionConfirm(false); setEndSessionTarget(null); } }}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>End Session</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
End session for <strong>{endSessionTarget?.user?.full_name || endSessionTarget?.user?.email || "this user"}</strong> on <strong>{endSessionTarget?.network?.name || "this network"}</strong>? They will need to re-authenticate.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="p-3 border border-orange-300 rounded-lg bg-orange-50 text-sm text-orange-800">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>The user's approval will NOT be revoked — they can re-authenticate without admin re-approval. The device will be deauthorized from the ZeroTier network and lose connectivity until they re-authenticate.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => { setShowEndSessionConfirm(false); setEndSessionTarget(null); }}>Cancel</Button>
|
|
||||||
<Button variant="destructive" onClick={handleEndSessionConfirm}>
|
|
||||||
<ZapOff className="w-4 h-4 mr-2" />
|
|
||||||
End Session
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Plus, Copy, Trash2, Loader2, AlertCircle, CheckCircle, MoreHorizontal, Edit2, Check
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } 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,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api, OrganizationApiKey } from "@/lib/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
|
import { formatDate } from "@/lib/date";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface NewApiKeyState {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditingKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCopyButton() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return { copied, copy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { selectedOrgId: orgId } = useOrg();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { copy, copied } = useCopyButton();
|
||||||
|
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [newSecret, setNewSecret] = useState<NewApiKeyState | null>(null);
|
||||||
|
const [editingKey, setEditingKey] = useState<EditingKey | null>(null);
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const editNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editDescriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Fetch API keys
|
||||||
|
const { data: apiKeysData, isLoading } = useQuery({
|
||||||
|
queryKey: ['api-keys', orgId],
|
||||||
|
queryFn: () => orgId ? api.organizations.getApiKeys(orgId) : null,
|
||||||
|
enabled: !!orgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create API key mutation
|
||||||
|
const { mutate: createKey, isPending: isCreatingKey } = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!orgId) throw new Error('Organization ID not set');
|
||||||
|
const name = nameRef.current?.value;
|
||||||
|
const description = descriptionRef.current?.value;
|
||||||
|
if (!name) throw new Error('Name is required');
|
||||||
|
return api.organizations.createApiKey(orgId, name, description);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const apiKey = data.api_key;
|
||||||
|
setNewSecret({
|
||||||
|
key: apiKey.key || '',
|
||||||
|
name: apiKey.name,
|
||||||
|
description: apiKey.description || undefined,
|
||||||
|
createdAt: apiKey.created_at,
|
||||||
|
});
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
if (nameRef.current) nameRef.current.value = '';
|
||||||
|
if (descriptionRef.current) descriptionRef.current.value = '';
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] });
|
||||||
|
toast({
|
||||||
|
title: 'API Key Created',
|
||||||
|
description: 'Store the key value securely - you won\'t be able to see it again.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create API key',
|
||||||
|
description: 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update API key mutation
|
||||||
|
const { mutate: updateKey, isPending: isUpdatingKey } = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!orgId || !editingKey) throw new Error('Required data missing');
|
||||||
|
return api.organizations.updateApiKey(orgId, editingKey.id, {
|
||||||
|
name: editNameRef.current?.value,
|
||||||
|
description: editDescriptionRef.current?.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] });
|
||||||
|
toast({
|
||||||
|
title: 'API Key Updated',
|
||||||
|
description: 'Changes saved successfully.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update API key',
|
||||||
|
description: 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete API key mutation
|
||||||
|
const { mutate: deleteKey, isPending: isDeletingKey } = useMutation({
|
||||||
|
mutationFn: (keyId: string) => {
|
||||||
|
if (!orgId) throw new Error('Organization ID not set');
|
||||||
|
return api.organizations.deleteApiKey(orgId, keyId);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] });
|
||||||
|
toast({
|
||||||
|
title: 'API Key Deleted',
|
||||||
|
description: 'The API key has been permanently removed.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete API key',
|
||||||
|
description: 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateKey = () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
createKey();
|
||||||
|
setIsCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditKey = (key: OrganizationApiKey) => {
|
||||||
|
setEditingKey({
|
||||||
|
id: key.id,
|
||||||
|
name: key.name,
|
||||||
|
description: key.description,
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateKey = () => {
|
||||||
|
updateKey();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = (keyId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
|
||||||
|
deleteKey(keyId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKeys = apiKeysData?.api_keys || [];
|
||||||
|
const activeKeys = apiKeys.filter(k => !k.is_revoked);
|
||||||
|
const revokedKeys = apiKeys.filter(k => k.is_revoked);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">API Keys</h1>
|
||||||
|
<p className="page-description">Manage API keys for programmatic access to your organization.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New key reveal banner */}
|
||||||
|
{newSecret && (
|
||||||
|
<div className="mb-4 rounded-lg border border-green-500/40 bg-green-500/5 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
API key created — copy it now, you won't see it again.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs bg-muted px-3 py-2 rounded break-all font-mono">
|
||||||
|
{newSecret.key}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0 gap-1.5" onClick={() => copy(newSecret.key)}>
|
||||||
|
{copied ? <><Check className="w-3.5 h-3.5" /> Copied</> : <><Copy className="w-3.5 h-3.5" /> Copy</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setNewSecret(null)} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key list */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : apiKeys.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">No API keys yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">Create one to enable external integrations.</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> Create API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{activeKeys.map((key) => (
|
||||||
|
<div key={key.id} className="flex items-start gap-4 p-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-sm text-foreground">{key.name}</span>
|
||||||
|
{key.last_used_at && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Last used {formatDate(key.last_used_at)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{key.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{key.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Created {formatDate(key.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleEditKey(key)} className="cursor-pointer">
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteKey(key.id)}
|
||||||
|
className="text-destructive cursor-pointer"
|
||||||
|
disabled={isDeletingKey}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{revokedKeys.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-4 py-2 bg-muted/30">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Revoked</span>
|
||||||
|
</div>
|
||||||
|
{revokedKeys.map((key) => (
|
||||||
|
<div key={key.id} className="flex items-center gap-4 px-4 py-3 opacity-50">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground line-through">{key.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Revoked {formatDate(key.revoked_at || '')}
|
||||||
|
{key.revoke_reason && ` — ${key.revoke_reason}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create API Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new API key for external integrations. The key will be displayed only once.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="key-name">Key Name</Label>
|
||||||
|
<Input
|
||||||
|
id="key-name"
|
||||||
|
ref={nameRef}
|
||||||
|
placeholder="e.g., Production Integration"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="key-description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="key-description"
|
||||||
|
ref={descriptionRef}
|
||||||
|
placeholder="What is this key for?"
|
||||||
|
className="mt-1 resize-none h-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsCreateDialogOpen(false)}
|
||||||
|
disabled={isCreating || isCreatingKey}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateKey}
|
||||||
|
disabled={isCreating || isCreatingKey}
|
||||||
|
>
|
||||||
|
{isCreatingKey ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Key'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit API Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the name and description of this API key.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{editingKey && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-key-name">Key Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-key-name"
|
||||||
|
ref={editNameRef}
|
||||||
|
defaultValue={editingKey.name}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-key-description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-key-description"
|
||||||
|
ref={editDescriptionRef}
|
||||||
|
defaultValue={editingKey.description || ''}
|
||||||
|
className="mt-1 resize-none h-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsEditDialogOpen(false)}
|
||||||
|
disabled={isUpdatingKey}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdateKey}
|
||||||
|
disabled={isUpdatingKey}
|
||||||
|
>
|
||||||
|
{isUpdatingKey ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Update Key'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -390,7 +390,7 @@ export default function DepartmentsPage() {
|
|||||||
const [selectedPrincipalId, setSelectedPrincipalId] = useState("");
|
const [selectedPrincipalId, setSelectedPrincipalId] = useState("");
|
||||||
const [isLinking, setIsLinking] = useState(false);
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
||||||
const [formData, setFormData] = useState({ name: "", description: "" });
|
const [formData, setFormData] = useState({ name: "", description: "", can_sudo: false });
|
||||||
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
|
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
|
||||||
const [expandedMembers, setExpandedMembers] = useState<Set<string>>(new Set());
|
const [expandedMembers, setExpandedMembers] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -505,9 +505,10 @@ export default function DepartmentsPage() {
|
|||||||
const dept = await api.organizations.createDepartment(
|
const dept = await api.organizations.createDepartment(
|
||||||
orgId,
|
orgId,
|
||||||
formData.name,
|
formData.name,
|
||||||
formData.description || undefined
|
formData.description || undefined,
|
||||||
|
formData.can_sudo
|
||||||
);
|
);
|
||||||
setFormData({ name: "", description: "" });
|
setFormData({ name: "", description: "", can_sudo: false });
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
await fetchDepartments(orgId);
|
await fetchDepartments(orgId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -522,8 +523,9 @@ export default function DepartmentsPage() {
|
|||||||
await api.organizations.updateDepartment(orgId, editingDept.id, {
|
await api.organizations.updateDepartment(orgId, editingDept.id, {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
|
can_sudo: formData.can_sudo,
|
||||||
});
|
});
|
||||||
setFormData({ name: "", description: "" });
|
setFormData({ name: "", description: "", can_sudo: false });
|
||||||
setEditingDept(null);
|
setEditingDept(null);
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
await fetchDepartments(orgId);
|
await fetchDepartments(orgId);
|
||||||
@@ -546,7 +548,7 @@ export default function DepartmentsPage() {
|
|||||||
|
|
||||||
const openEditDialog = (dept: Department) => {
|
const openEditDialog = (dept: Department) => {
|
||||||
setEditingDept(dept);
|
setEditingDept(dept);
|
||||||
setFormData({ name: dept.name, description: dept.description || "" });
|
setFormData({ name: dept.name, description: dept.description || "", can_sudo: dept.can_sudo || false });
|
||||||
setIsEditDialogOpen(true);
|
setIsEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -572,7 +574,7 @@ export default function DepartmentsPage() {
|
|||||||
Manage departments and organize team members
|
Manage departments and organize team members
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
<Button onClick={() => { setFormData({ name: "", description: "", can_sudo: false }); setIsCreateDialogOpen(true); }}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Department
|
Create Department
|
||||||
</Button>
|
</Button>
|
||||||
@@ -619,7 +621,11 @@ export default function DepartmentsPage() {
|
|||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{dept.name}
|
{dept.name}
|
||||||
</p>
|
</p>
|
||||||
|
{dept.can_sudo && (
|
||||||
|
<Badge variant="secondary" className="text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300">
|
||||||
|
Sudo enabled
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{dept.description && (
|
{dept.description && (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
@@ -752,6 +758,18 @@ export default function DepartmentsPage() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/30">
|
||||||
|
<div>
|
||||||
|
<Label className="text-base font-medium cursor-pointer">Allow sudo access</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Members of this department can use sudo</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.can_sudo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, can_sudo: e.target.checked })}
|
||||||
|
className="w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
@@ -793,6 +811,18 @@ export default function DepartmentsPage() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/30">
|
||||||
|
<div>
|
||||||
|
<Label className="text-base font-medium cursor-pointer">Allow sudo access</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Members of this department can use sudo</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.can_sudo}
|
||||||
|
onChange={(e) => setFormData({ ...formData, can_sudo: e.target.checked })}
|
||||||
|
className="w-4 h-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||||
|
|||||||
+222
-299
@@ -40,7 +40,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -55,7 +61,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
Device,
|
Device,
|
||||||
DeviceNetworkMembership,
|
DeviceNetworkMembership,
|
||||||
UserSession,
|
ActivationSession,
|
||||||
MembershipState,
|
MembershipState,
|
||||||
PortalNetwork,
|
PortalNetwork,
|
||||||
UserNetworkApproval,
|
UserNetworkApproval,
|
||||||
@@ -108,64 +114,6 @@ function MembershipStateBadge({ state }: { state: MembershipState }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApprovedBadge({ approved }: { approved: boolean }) {
|
|
||||||
if (approved) {
|
|
||||||
return (
|
|
||||||
<Badge className="text-xs bg-green-500/10 text-green-700 border-green-300">
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />Approved
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
<XCircle className="w-3 h-3 mr-1" />Not Approved
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActiveBadge({ active }: { active: boolean }) {
|
|
||||||
if (active) {
|
|
||||||
return (
|
|
||||||
<Badge className="text-xs bg-green-500/15 text-green-700 border-green-400">
|
|
||||||
<Zap className="w-3 h-3 mr-1" />Active
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
<ZapOff className="w-3 h-3 mr-1" />Inactive
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionProgress({ session }: { session: { authenticated_at: string; expires_at: string } }) {
|
|
||||||
const now = Date.now();
|
|
||||||
const expires = new Date(session.expires_at).getTime();
|
|
||||||
const created = new Date(session.authenticated_at).getTime();
|
|
||||||
const total = expires - created;
|
|
||||||
const elapsed = now - created;
|
|
||||||
const ratio = Math.min(Math.max(elapsed / total, 0), 1);
|
|
||||||
const remaining = Math.max(expires - now, 0);
|
|
||||||
const remainingMin = Math.floor(remaining / 60000);
|
|
||||||
const barColor = ratio < 0.5 ? "bg-green-500" : ratio < 0.8 ? "bg-yellow-500" : "bg-red-500";
|
|
||||||
|
|
||||||
const remainingText = remainingMin >= 60
|
|
||||||
? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m remaining`
|
|
||||||
: `${remainingMin}m remaining`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1 w-full">
|
|
||||||
<div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={cn("h-full transition-all rounded-full", barColor)}
|
|
||||||
style={{ width: `${ratio * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{remainingText}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
|
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
|
||||||
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
|
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" },
|
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending" },
|
||||||
@@ -195,7 +143,7 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
|
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
||||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||||
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
|
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -211,7 +159,9 @@ export default function DevicesPage() {
|
|||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
const [regError, setRegError] = useState<string | null>(null);
|
const [regError, setRegError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [expandedDeviceId, setExpandedDeviceId] = useState<string | null>(null);
|
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
||||||
|
const [deviceMemberships, setDeviceMemberships] = useState<DeviceNetworkMembership[]>([]);
|
||||||
|
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||||
|
|
||||||
const [editDevice, setEditDevice] = useState<Device | null>(null);
|
const [editDevice, setEditDevice] = useState<Device | null>(null);
|
||||||
const [editNickname, setEditNickname] = useState("");
|
const [editNickname, setEditNickname] = useState("");
|
||||||
@@ -246,7 +196,7 @@ export default function DevicesPage() {
|
|||||||
const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([
|
const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([
|
||||||
api.zerotier.listDevices(orgId),
|
api.zerotier.listDevices(orgId),
|
||||||
api.zerotier.listMemberships(orgId),
|
api.zerotier.listMemberships(orgId),
|
||||||
api.zerotier.listUserSessions(orgId),
|
api.zerotier.listSessions(orgId),
|
||||||
api.zerotier.listNetworks(orgId),
|
api.zerotier.listNetworks(orgId),
|
||||||
api.zerotier.listMyApprovals(orgId),
|
api.zerotier.listMyApprovals(orgId),
|
||||||
]);
|
]);
|
||||||
@@ -268,20 +218,24 @@ export default function DevicesPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const refreshSessions = useCallback(async () => {
|
const openDeviceDrawer = async (device: Device) => {
|
||||||
if (!orgId) return;
|
setSelectedDevice(device);
|
||||||
|
setIsDrawerLoading(true);
|
||||||
|
setDeviceMemberships([]);
|
||||||
try {
|
try {
|
||||||
const sessionsRes = await api.zerotier.listUserSessions(orgId);
|
const deviceMem = memberships.filter((m) => m.device_id === device.id);
|
||||||
setSessions(sessionsRes.sessions || []);
|
setDeviceMemberships(deviceMem);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// non-fatal
|
||||||
|
} finally {
|
||||||
|
setIsDrawerLoading(false);
|
||||||
}
|
}
|
||||||
}, [orgId]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const closeDrawer = () => {
|
||||||
const interval = setInterval(() => refreshSessions(), 5000);
|
setSelectedDevice(null);
|
||||||
return () => clearInterval(interval);
|
setDeviceMemberships([]);
|
||||||
}, [refreshSessions]);
|
};
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
@@ -449,19 +403,14 @@ export default function DevicesPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getActiveSession = (deviceId: string, networkId: string): UserSession | null => {
|
const getActiveSession = (membershipId: string): ActivationSession | null => {
|
||||||
return sessions.find((s) => s.device?.id === deviceId && s.network?.id === networkId && s.is_active) ?? null;
|
return sessions.find((s) => s.device_network_membership_id === membershipId && s.is_active) ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => {
|
const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => {
|
||||||
return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? null;
|
return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMembershipBySession = (session: UserSession): DeviceNetworkMembership | undefined => {
|
|
||||||
if (!session.device || !session.network) return undefined;
|
|
||||||
return memberships.find((m) => m.device_id === session.device.id && m.portal_network_id === session.network.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getApprovalForNetwork = (networkId: string): UserNetworkApproval | null => {
|
const getApprovalForNetwork = (networkId: string): UserNetworkApproval | null => {
|
||||||
return myApprovals.find((a) => a.portal_network_id === networkId) ?? null;
|
return myApprovals.find((a) => a.portal_network_id === networkId) ?? null;
|
||||||
};
|
};
|
||||||
@@ -478,7 +427,7 @@ export default function DevicesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">ZeroTier Devices</h1>
|
<h1 className="page-title">ZeroTier Access</h1>
|
||||||
<p className="page-description">Manage your devices, networks, and access requests</p>
|
<p className="page-description">Manage your devices, networks, and access requests</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -528,7 +477,7 @@ export default function DevicesPage() {
|
|||||||
Registered Devices
|
Registered Devices
|
||||||
{!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>}
|
{!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Click a device to expand its details and network memberships</CardDescription>
|
<CardDescription>Click a device to view memberships and activation status</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -546,168 +495,67 @@ export default function DevicesPage() {
|
|||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{filteredDevices.map((device) => {
|
{filteredDevices.map((device) => {
|
||||||
const activeCount = memberships.filter(
|
const activeCount = memberships.filter(
|
||||||
(m) => m.device_id === device.id && m.active
|
(m) => m.device_id === device.id && m.currently_authorized
|
||||||
).length;
|
).length;
|
||||||
const isExpanded = expandedDeviceId === device.id;
|
|
||||||
const deviceMemberships = memberships.filter((m) => m.device_id === device.id);
|
|
||||||
return (
|
return (
|
||||||
<div key={device.id}>
|
<button
|
||||||
<button
|
key={device.id}
|
||||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||||
onClick={() => setExpandedDeviceId(isExpanded ? null : device.id)}
|
onClick={() => openDeviceDrawer(device)}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<DeviceTypeIcon nickname={device.device_nickname} hostname={device.hostname} />
|
<DeviceTypeIcon nickname={device.device_nickname} hostname={device.hostname} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p className="font-medium text-foreground truncate">
|
<p className="font-medium text-foreground truncate">
|
||||||
{device.device_nickname || device.hostname || device.node_id}
|
{device.device_nickname || device.hostname || device.node_id}
|
||||||
</p>
|
</p>
|
||||||
{device.device_nickname && device.hostname && (
|
{device.device_nickname && device.hostname && (
|
||||||
<span className="text-sm text-muted-foreground truncate">{device.hostname}</span>
|
<span className="text-sm text-muted-foreground truncate">{device.hostname}</span>
|
||||||
)}
|
|
||||||
<Badge variant={device.status === "active" ? "default" : "outline"} className="text-xs">
|
|
||||||
{device.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground font-mono">{device.node_id}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-sm flex-shrink-0">
|
|
||||||
{activeCount > 0 ? (
|
|
||||||
<><Zap className="w-4 h-4 text-green-500" /><span className="text-green-600">{activeCount} active</span></>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">Inactive</span>
|
|
||||||
)}
|
)}
|
||||||
|
<Badge variant={device.status === "active" ? "default" : "outline"} className="text-xs">
|
||||||
|
{device.status}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<p className="text-sm text-muted-foreground font-mono">{device.node_id}</p>
|
||||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-1 text-sm flex-shrink-0">
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
{activeCount > 0 ? (
|
||||||
</Button>
|
<><Zap className="w-4 h-4 text-green-500" /><span className="text-green-600">{activeCount} active</span></>
|
||||||
</DropdownMenuTrigger>
|
) : (
|
||||||
<DropdownMenuContent align="end">
|
<span className="text-muted-foreground">Inactive</span>
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setExpandedDeviceId(isExpanded ? null : device.id); }}>
|
)}
|
||||||
<ChevronRight className="w-4 h-4 mr-2" /> View memberships
|
</div>
|
||||||
</DropdownMenuItem>
|
<DropdownMenu>
|
||||||
<DropdownMenuItem onClick={(e) => {
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
e.stopPropagation();
|
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
setEditDevice(device);
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
setEditNickname(device.device_nickname || "");
|
</Button>
|
||||||
setEditHostname(device.hostname || "");
|
</DropdownMenuTrigger>
|
||||||
}}>
|
<DropdownMenuContent align="end">
|
||||||
<Pencil className="w-4 h-4 mr-2" /> Edit
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openDeviceDrawer(device); }}>
|
||||||
</DropdownMenuItem>
|
<ChevronRight className="w-4 h-4 mr-2" /> View memberships
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={(e) => {
|
||||||
className="text-destructive"
|
e.stopPropagation();
|
||||||
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }}
|
setEditDevice(device);
|
||||||
>
|
setEditNickname(device.device_nickname || "");
|
||||||
<Trash2 className="w-4 h-4 mr-2" /> Remove
|
setEditHostname(device.hostname || "");
|
||||||
</DropdownMenuItem>
|
}}>
|
||||||
</DropdownMenuContent>
|
<Pencil className="w-4 h-4 mr-2" /> Edit
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
<ChevronRight className={cn("w-4 h-4 text-muted-foreground flex-shrink-0 transition-transform", isExpanded && "rotate-90")} />
|
<DropdownMenuSeparator />
|
||||||
</button>
|
<DropdownMenuItem
|
||||||
{isExpanded && (
|
className="text-destructive"
|
||||||
<div className="border-t px-4 pb-4 pt-4 space-y-4">
|
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }}
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
>
|
||||||
{device.hostname && (
|
<Trash2 className="w-4 h-4 mr-2" /> Remove
|
||||||
<>
|
</DropdownMenuItem>
|
||||||
<span className="text-muted-foreground">Hostname</span>
|
</DropdownMenuContent>
|
||||||
<span>{device.hostname}</span>
|
</DropdownMenu>
|
||||||
</>
|
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
</button>
|
||||||
{device.asset_tag && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">Asset Tag</span>
|
|
||||||
<span>{device.asset_tag}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{device.serial_number && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">Serial</span>
|
|
||||||
<span>{device.serial_number}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground">Registered</span>
|
|
||||||
<span>{formatDate(device.created_at)}</span>
|
|
||||||
<span className="text-muted-foreground">Status</span>
|
|
||||||
<Badge variant={device.status === "active" ? "default" : "outline"} className="w-fit">{device.status}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
|
||||||
<Monitor className="w-4 h-4" />
|
|
||||||
Network Memberships ({deviceMemberships.length})
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{deviceMemberships.length === 0 ? (
|
|
||||||
<div className="p-4 text-center text-muted-foreground text-sm border rounded-lg">
|
|
||||||
No memberships found. Request network access to get started.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{deviceMemberships.map((m) => {
|
|
||||||
const session = getActiveSession(m.device_id, m.portal_network_id);
|
|
||||||
const network = networks.find((n) => n.id === m.portal_network_id);
|
|
||||||
return (
|
|
||||||
<div key={m.id} className="p-3 border rounded-lg space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
|
|
||||||
<ApprovedBadge approved={m.status === "approved"} />
|
|
||||||
<ActiveBadge active={m.active} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{m.status === "approved" && !m.active && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowActivateDialog(m.id)}
|
|
||||||
disabled={activatingId === m.id}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{activatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
|
|
||||||
Activate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{m.active && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleDeactivate(m.id)}
|
|
||||||
disabled={deactivatingId === m.id}
|
|
||||||
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
|
|
||||||
>
|
|
||||||
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
|
||||||
Deactivate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{session && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
<SessionProgress session={session} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
{m.join_seen ? (
|
|
||||||
<><CheckCircle className="w-3 h-3 text-green-500" /> Joined network</>
|
|
||||||
) : (
|
|
||||||
<><XCircle className="w-3 h-3" /> Not yet joined</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -729,56 +577,17 @@ export default function DevicesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{sessions.filter((s) => s.is_active).map((session) => {
|
{sessions.filter((s) => s.is_active).map((session) => (
|
||||||
const membership = getMembershipBySession(session);
|
<div key={session.id} className="flex items-center justify-between text-sm p-2 border rounded">
|
||||||
return (
|
<span className="text-muted-foreground font-mono">{session.device_network_membership_id}</span>
|
||||||
<div key={session.id} className="flex items-center gap-4 p-3 border rounded">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
<span className="text-muted-foreground">Expires: {formatExpiry(session.expires_at)}</span>
|
||||||
<Zap className="w-4 h-4 text-green-500" />
|
<Button size="sm" variant="ghost" onClick={() => handleDeactivate(session.id)} disabled={deactivatingId === session.id}>
|
||||||
</div>
|
{deactivatingId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||||
<div className="flex-1 min-w-0">
|
</Button>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{session.device && (
|
|
||||||
<Badge variant="outline" className="text-xs font-mono">
|
|
||||||
{session.device.name || session.device.node_id}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{session.network && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{session.network.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
|
|
||||||
<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" />
|
|
||||||
{session.remaining_seconds > 0
|
|
||||||
? (() => {
|
|
||||||
const min = Math.floor(session.remaining_seconds / 60);
|
|
||||||
return min >= 60
|
|
||||||
? `${Math.floor(min / 60)}h ${min % 60}m remaining`
|
|
||||||
: `${min}m remaining`;
|
|
||||||
})()
|
|
||||||
: "Expired"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{membership && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
|
|
||||||
onClick={() => handleDeactivate(membership.id)}
|
|
||||||
disabled={deactivatingId === membership.id}
|
|
||||||
>
|
|
||||||
{deactivatingId === membership.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
|
||||||
Deactivate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -844,7 +653,7 @@ export default function DevicesPage() {
|
|||||||
<p className="text-sm text-muted-foreground font-mono">{network.zerotier_network_id}</p>
|
<p className="text-sm text-muted-foreground font-mono">{network.zerotier_network_id}</p>
|
||||||
{approval && (
|
{approval && (
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<ApprovalStateBadge state={approval.status} />
|
<ApprovalStateBadge state={approval.state} />
|
||||||
{approval.justification && (
|
{approval.justification && (
|
||||||
<span className="text-xs text-muted-foreground">"{approval.justification}"</span>
|
<span className="text-xs text-muted-foreground">"{approval.justification}"</span>
|
||||||
)}
|
)}
|
||||||
@@ -852,7 +661,7 @@ export default function DevicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{network.request_mode === "open" && (
|
{network.request_mode === "open" && !hasMembership && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -866,7 +675,7 @@ export default function DevicesPage() {
|
|||||||
<Plus className="w-3 h-3" /> Join
|
<Plus className="w-3 h-3" /> Join
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{network.request_mode === "approval_required" && (
|
{network.request_mode === "approval_required" && !hasMembership && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -902,11 +711,10 @@ export default function DevicesPage() {
|
|||||||
{devices.find((d) => d.id === m.device_id)?.device_nickname ||
|
{devices.find((d) => d.id === m.device_id)?.device_nickname ||
|
||||||
devices.find((d) => d.id === m.device_id)?.node_id}
|
devices.find((d) => d.id === m.device_id)?.node_id}
|
||||||
</span>
|
</span>
|
||||||
<ApprovedBadge approved={m.status === "approved"} />
|
<MembershipStateBadge state={m.state} />
|
||||||
<ActiveBadge active={m.active} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{m.status === "approved" && !m.active && (
|
{m.approved_for_activation && !m.currently_authorized && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -918,7 +726,7 @@ export default function DevicesPage() {
|
|||||||
Activate
|
Activate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{m.active && (
|
{m.currently_authorized && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -990,7 +798,7 @@ export default function DevicesPage() {
|
|||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
<p className="font-medium truncate">{network?.name || approval.portal_network_id}</p>
|
<p className="font-medium truncate">{network?.name || approval.portal_network_id}</p>
|
||||||
<Badge variant="outline" className="text-xs">{network?.environment}</Badge>
|
<Badge variant="outline" className="text-xs">{network?.environment}</Badge>
|
||||||
<ApprovalStateBadge state={approval.status} />
|
<ApprovalStateBadge state={approval.state} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{approval.grant_type === "requested" ? "You requested" : "Assigned by admin"}
|
{approval.grant_type === "requested" ? "You requested" : "Assigned by admin"}
|
||||||
@@ -1006,14 +814,14 @@ export default function DevicesPage() {
|
|||||||
const dev = devices.find((d) => d.id === m.device_id);
|
const dev = devices.find((d) => d.id === m.device_id);
|
||||||
return (
|
return (
|
||||||
<Badge key={m.id} variant="outline" className="text-xs">
|
<Badge key={m.id} variant="outline" className="text-xs">
|
||||||
{dev?.device_nickname || dev?.node_id}: {m.active ? "Active" : "Inactive"}
|
{dev?.device_nickname || dev?.node_id}: <MembershipStateBadge state={m.state} />
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{approval.status === "pending" && (
|
{approval.state === "pending" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1230,7 +1038,122 @@ export default function DevicesPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Device Detail Drawer */}
|
||||||
|
<Sheet open={!!selectedDevice} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
|
||||||
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||||
|
{selectedDevice && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
<DeviceTypeIcon nickname={selectedDevice.device_nickname} hostname={selectedDevice.hostname} />
|
||||||
|
</div>
|
||||||
|
{selectedDevice.device_nickname || selectedDevice.node_id}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="font-mono">{selectedDevice.node_id}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
{selectedDevice.hostname && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">Hostname</span>
|
||||||
|
<span>{selectedDevice.hostname}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedDevice.asset_tag && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">Asset Tag</span>
|
||||||
|
<span>{selectedDevice.asset_tag}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedDevice.serial_number && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">Serial</span>
|
||||||
|
<span>{selectedDevice.serial_number}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground">Registered</span>
|
||||||
|
<span>{formatDate(selectedDevice.created_at)}</span>
|
||||||
|
<span className="text-muted-foreground">Status</span>
|
||||||
|
<Badge variant={selectedDevice.status === "active" ? "default" : "outline"} className="w-fit">{selectedDevice.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4" />
|
||||||
|
Network Memberships ({deviceMemberships.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isDrawerLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : deviceMemberships.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground text-sm">
|
||||||
|
No memberships found. Request network access to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{deviceMemberships.map((m) => {
|
||||||
|
const session = getActiveSession(m.id);
|
||||||
|
const network = networks.find((n) => n.id === m.portal_network_id);
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="p-3 border rounded-lg space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
|
||||||
|
<MembershipStateBadge state={m.state} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{m.approved_for_activation && !m.currently_authorized && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowActivateDialog(m.id)}
|
||||||
|
disabled={activatingId === m.id}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{activatingId === 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={() => handleDeactivate(m.id)}
|
||||||
|
disabled={deactivatingId === m.id}
|
||||||
|
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{session && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Session expires: {formatExpiry(session.expires_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{m.join_seen ? (
|
||||||
|
<><CheckCircle className="w-3 h-3 text-green-500" /> Joined network</>
|
||||||
|
) : (
|
||||||
|
<><XCircle className="w-3 h-3" /> Not yet joined</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,904 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
|
||||||
import { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, ZapOff, Ban, Shield, Monitor, CheckCircle, ChevronDown, ChevronRight, Plus, Search, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, OrgMember, Device, ActivationSession } from "@/lib/api";
|
|
||||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
|
|
||||||
function cn(...classes: (string | boolean | undefined | null)[]) {
|
|
||||||
return classes.filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 formatDate(d: string | null | undefined) {
|
|
||||||
if (!d) return "—";
|
|
||||||
return new Date(d).toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupMembersByUser(memberships: DeviceNetworkMembership[]): Map<string, DeviceNetworkMembership[]> {
|
|
||||||
const grouped = new Map<string, DeviceNetworkMembership[]>();
|
|
||||||
for (const m of memberships) {
|
|
||||||
const existing = grouped.get(m.user_id) || [];
|
|
||||||
existing.push(m);
|
|
||||||
grouped.set(m.user_id, existing);
|
|
||||||
}
|
|
||||||
return grouped;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActiveBadge({ active }: { active: boolean }) {
|
|
||||||
if (active) {
|
|
||||||
return (
|
|
||||||
<Badge className="text-xs bg-green-500/15 text-green-700 border-green-400">
|
|
||||||
<Zap className="w-3 h-3 mr-1" />Active
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
<ZapOff className="w-3 h-3 mr-1" />Inactive
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionProgress({ session }: { session: ActivationSession }) {
|
|
||||||
const now = Date.now();
|
|
||||||
const expires = new Date(session.expires_at).getTime();
|
|
||||||
const created = new Date(session.authenticated_at).getTime();
|
|
||||||
const total = expires - created;
|
|
||||||
const elapsed = now - created;
|
|
||||||
const ratio = Math.min(Math.max(elapsed / total, 0), 1);
|
|
||||||
const remaining = Math.max(expires - now, 0);
|
|
||||||
const remainingMin = Math.floor(remaining / 60000);
|
|
||||||
const barColor = ratio < 0.5 ? "bg-green-500" : ratio < 0.8 ? "bg-yellow-500" : "bg-red-500";
|
|
||||||
|
|
||||||
const remainingText = remainingMin >= 60
|
|
||||||
? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m remaining`
|
|
||||||
: `${remainingMin}m remaining`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1 w-full">
|
|
||||||
<div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={cn("h-full transition-all rounded-full", barColor)}
|
|
||||||
style={{ width: `${ratio * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{remainingText}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NetworkManagementPage() {
|
|
||||||
const { networkId } = useParams<{ networkId: string }>();
|
|
||||||
const { orgId } = useCurrentOrganizationId();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [network, setNetwork] = useState<PortalNetwork | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [members, setMembers] = useState<DeviceNetworkMembership[]>([]);
|
|
||||||
const [isMembersLoading, setIsMembersLoading] = useState(false);
|
|
||||||
const [membersError, setMembersError] = useState<string | null>(null);
|
|
||||||
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
|
|
||||||
const [activatingMembership, setActivatingMembership] = useState<string | null>(null);
|
|
||||||
const [deactivatingMembership, setDeactivatingMembership] = useState<string | null>(null);
|
|
||||||
const [removingMembership, setRemovingMembership] = useState<string | null>(null);
|
|
||||||
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
|
|
||||||
const [confirmRemoveDevice, setConfirmRemoveDevice] = useState<string | null>(null);
|
|
||||||
const [confirmRemoveUser, setConfirmRemoveUser] = useState<string | null>(null);
|
|
||||||
const [showActivateDialog, setShowActivateDialog] = useState<string | null>(null);
|
|
||||||
const [activateLifetime, setActivateLifetime] = useState("480");
|
|
||||||
const [deactivatingAll, setDeactivatingAll] = useState(false);
|
|
||||||
const [deactivateReason, setDeactivateReason] = useState("");
|
|
||||||
|
|
||||||
// Add New Membership dialog state
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
||||||
const [addStep, setAddStep] = useState<1 | 2 | 3>(1);
|
|
||||||
const [selectedUser, setSelectedUser] = useState<OrgMember | null>(null);
|
|
||||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
|
||||||
const [orgMembers, setOrgMembers] = useState<OrgMember[]>([]);
|
|
||||||
const [userDevices, setUserDevices] = useState<Device[]>([]);
|
|
||||||
const [isLoadingMembers, setIsLoadingMembers] = useState(false);
|
|
||||||
const [isLoadingDevices, setIsLoadingDevices] = useState(false);
|
|
||||||
const [isJoining, setIsJoining] = useState(false);
|
|
||||||
const [userSearch, setUserSearch] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchNetwork() {
|
|
||||||
if (!orgId || !networkId) {
|
|
||||||
setError("Organization or network ID is missing.");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await api.zerotier.getNetwork(orgId, networkId);
|
|
||||||
setNetwork(result.network);
|
|
||||||
} catch (err) {
|
|
||||||
let message = "Failed to load network details.";
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
message = err.message;
|
|
||||||
}
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchNetwork();
|
|
||||||
}, [orgId, networkId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchMembers() {
|
|
||||||
if (!orgId || !networkId) return;
|
|
||||||
setIsMembersLoading(true);
|
|
||||||
setMembersError(null);
|
|
||||||
try {
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
} catch (err) {
|
|
||||||
setMembersError(err instanceof ApiError ? err.message : "Failed to load members.");
|
|
||||||
} finally {
|
|
||||||
setIsMembersLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchMembers();
|
|
||||||
}, [orgId, networkId]);
|
|
||||||
|
|
||||||
// Fetch org members when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAddDialogOpen || !orgId) return;
|
|
||||||
async function fetchMembers() {
|
|
||||||
setIsLoadingMembers(true);
|
|
||||||
try {
|
|
||||||
const result = await api.zerotier.getOrgMembers(orgId);
|
|
||||||
setOrgMembers(result.members || []);
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to load members" });
|
|
||||||
} finally {
|
|
||||||
setIsLoadingMembers(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchMembers();
|
|
||||||
}, [isAddDialogOpen, orgId]);
|
|
||||||
|
|
||||||
// Fetch user devices when user is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedUser || !orgId) return;
|
|
||||||
async function fetchDevices() {
|
|
||||||
setIsLoadingDevices(true);
|
|
||||||
try {
|
|
||||||
const result = await api.zerotier.getUserDevices(orgId, selectedUser.user_id);
|
|
||||||
setUserDevices(result.devices || []);
|
|
||||||
// Auto-select if only one device
|
|
||||||
if (result.devices?.length === 1) {
|
|
||||||
setSelectedDevice(result.devices[0]);
|
|
||||||
setAddStep(3);
|
|
||||||
} else {
|
|
||||||
setSelectedDevice(null);
|
|
||||||
setAddStep(2);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to load devices" });
|
|
||||||
} finally {
|
|
||||||
setIsLoadingDevices(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchDevices();
|
|
||||||
}, [selectedUser, orgId]);
|
|
||||||
|
|
||||||
const handleAddMembership = async () => {
|
|
||||||
if (!orgId || !selectedDevice || !networkId) return;
|
|
||||||
setIsJoining(true);
|
|
||||||
try {
|
|
||||||
// Check for duplicate
|
|
||||||
const exists = members.some(m => m.user_id === selectedUser?.user_id && m.device_id === selectedDevice.id);
|
|
||||||
if (exists) {
|
|
||||||
toast({ variant: "destructive", title: "Duplicate membership", description: "This user/device combination is already a member." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await api.zerotier.joinNetworkForDevice(orgId, selectedDevice.id, networkId);
|
|
||||||
toast({ title: "Membership added successfully" });
|
|
||||||
// Refresh members
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
// Reset dialog
|
|
||||||
setIsAddDialogOpen(false);
|
|
||||||
setAddStep(1);
|
|
||||||
setSelectedUser(null);
|
|
||||||
setSelectedDevice(null);
|
|
||||||
setUserSearch("");
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to add membership", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
|
||||||
} finally {
|
|
||||||
setIsJoining(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivate = async (membershipId: string) => {
|
|
||||||
if (!orgId) return;
|
|
||||||
setShowActivateDialog(membershipId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivateConfirm = async (membershipId: string) => {
|
|
||||||
if (!orgId) return;
|
|
||||||
setActivatingMembership(membershipId);
|
|
||||||
try {
|
|
||||||
const lifetime = parseInt(activateLifetime);
|
|
||||||
await api.zerotier.activateMembership(orgId, membershipId, lifetime);
|
|
||||||
toast({ title: "Membership activated", description: `Active for ${lifetime} minutes.` });
|
|
||||||
setShowActivateDialog(null);
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to activate", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
|
||||||
} finally {
|
|
||||||
setActivatingMembership(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeactivate = async (membershipId: string) => {
|
|
||||||
if (!orgId) return;
|
|
||||||
setDeactivatingMembership(membershipId);
|
|
||||||
try {
|
|
||||||
await api.zerotier.deactivateMembership(orgId, membershipId);
|
|
||||||
toast({ title: "Membership deactivated" });
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to deactivate", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
|
||||||
} finally {
|
|
||||||
setDeactivatingMembership(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeactivateAll = async () => {
|
|
||||||
if (!orgId) return;
|
|
||||||
setDeactivatingAll(true);
|
|
||||||
try {
|
|
||||||
const res = await api.zerotier.networkKillSwitch(orgId, networkId!, { reason: deactivateReason.trim() || undefined });
|
|
||||||
toast({ title: "All memberships deactivated", description: `${res.count} memberships deactivated.` });
|
|
||||||
setDeactivateReason("");
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to deactivate all", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
|
||||||
} finally {
|
|
||||||
setDeactivatingAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveDevice = async (membershipId: string) => {
|
|
||||||
if (!orgId) return;
|
|
||||||
setRemovingMembership(membershipId);
|
|
||||||
try {
|
|
||||||
await api.zerotier.adminDeleteMembership(orgId, membershipId);
|
|
||||||
toast({ title: "Device removed from network" });
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to remove device", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
|
||||||
} finally {
|
|
||||||
setRemovingMembership(null);
|
|
||||||
setConfirmRemoveDevice(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveUserDevices = async (userId: string) => {
|
|
||||||
if (!orgId) return;
|
|
||||||
setRemovingUserId(userId);
|
|
||||||
try {
|
|
||||||
const userMemberships = members.filter(m => m.user_id === userId);
|
|
||||||
await Promise.all(
|
|
||||||
userMemberships.map(m => api.zerotier.adminDeleteMembership(orgId, m.id))
|
|
||||||
);
|
|
||||||
toast({ title: "All devices removed", description: `Removed ${userMemberships.length} device(s) for this user.` });
|
|
||||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
|
||||||
setMembers(result.memberships || []);
|
|
||||||
} catch (err) {
|
|
||||||
toast({ variant: "destructive", title: "Failed to remove devices", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
|
||||||
} finally {
|
|
||||||
setRemovingUserId(null);
|
|
||||||
setConfirmRemoveUser(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── SKELETON STATE ──────────────────────────────────────────────────────────
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<div className="page-header">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-4 w-96 mt-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-40" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ERROR STATE ─────────────────────────────────────────────────────────────
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<Card className="mx-auto max-w-lg">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 space-y-4">
|
|
||||||
<AlertTriangle className="h-12 w-12 text-muted-foreground" />
|
|
||||||
<p className="text-lg font-medium text-center">{error}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate("/org/zerotier/networks")}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Networks
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SUCCESS STATE ───────────────────────────────────────────────────────────
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<div className="page-header">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate("/org/zerotier/networks")}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Networks
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<h1 className="page-title flex items-center gap-3">
|
|
||||||
<Network className="h-6 w-6" />
|
|
||||||
{network?.name ?? "Network"}
|
|
||||||
</h1>
|
|
||||||
<p className="page-description">
|
|
||||||
Manage network members, devices, and access requests
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="members">Members</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="overview">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-3">
|
|
||||||
<Network className="w-5 h-5" />
|
|
||||||
Network Details
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Status and badges row */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{network!.description && (
|
|
||||||
<p className="text-sm text-muted-foreground">{network!.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metadata grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground">ZeroTier Network ID</p>
|
|
||||||
<p className="font-mono text-sm">{network!.zerotier_network_id}</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground">Default Activation</p>
|
|
||||||
<p className="text-sm font-medium">{network!.default_activation_lifetime_minutes} min</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground">Max Activation</p>
|
|
||||||
<p className="text-sm font-medium">{network!.max_activation_lifetime_minutes ? `${network!.max_activation_lifetime_minutes} min` : "No limit"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground">Created</p>
|
|
||||||
<p className="text-sm font-medium">{formatDate(network!.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
||||||
<Card className="bg-muted/30">
|
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<p className="text-xs text-muted-foreground">Approved Users</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">{network!.approved_user_count ?? 0}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-muted/30">
|
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Zap className="w-4 h-4 text-green-500" />
|
|
||||||
<p className="text-xs text-muted-foreground">Active Devices</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1 text-green-600">{network!.active_membership_count ?? 0}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-muted/30">
|
|
||||||
<CardContent className="pt-4 pb-3 px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<p className="text-xs text-muted-foreground">Request Mode</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium mt-1 capitalize">{network!.request_mode.replace(/_/g, " ")}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="members">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
Network Members
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button size="sm" onClick={() => { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); setUserSearch(""); setIsAddDialogOpen(true); }}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
Add New Membership
|
|
||||||
</Button>
|
|
||||||
<Badge variant="secondary">{members.length} memberships</Badge>
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isMembersLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-2 text-muted-foreground">Loading members…</span>
|
|
||||||
</div>
|
|
||||||
) : membersError ? (
|
|
||||||
<div className="p-6 text-center text-destructive">{membersError}</div>
|
|
||||||
) : members.length === 0 ? (
|
|
||||||
<div className="p-6 text-center text-muted-foreground">No members on this network yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from(groupMembersByUser(members).entries()).map(([userId, userMemberships]) => {
|
|
||||||
const isExpanded = expandedUsers.has(userId);
|
|
||||||
const activeCount = userMemberships.filter(m => m.active).length;
|
|
||||||
const approvedCount = userMemberships.filter(m => m.status === "approved" && !m.active).length;
|
|
||||||
return (
|
|
||||||
<div key={userId} className="border rounded-lg overflow-hidden">
|
|
||||||
{/* User header - clickable to expand/collapse */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<button
|
|
||||||
className="flex-1 flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setExpandedUsers(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(userId)) next.delete(userId);
|
|
||||||
else next.add(userId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronDown className="w-4 h-4 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 flex-shrink-0" />}
|
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Users className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium truncate text-sm">{userMemberships[0]?.user_name || userId}</p>
|
|
||||||
{userMemberships[0]?.user_email && (
|
|
||||||
<p className="text-xs text-muted-foreground">{userMemberships[0].user_email}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""}
|
|
||||||
{activeCount > 0 && <span className="text-green-600 ml-2">{activeCount} active</span>}
|
|
||||||
{approvedCount > 0 && <span className="text-blue-600 ml-2">{approvedCount} ready</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setConfirmRemoveUser(userId)}
|
|
||||||
disabled={removingUserId === userId}
|
|
||||||
className="mr-2 text-destructive hover:text-destructive flex-shrink-0"
|
|
||||||
>
|
|
||||||
{removingUserId === userId ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1">Remove All</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Device list - shown when expanded */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-t divide-y bg-muted/20">
|
|
||||||
{userMemberships.map((m) => (
|
|
||||||
<div key={m.id} className="flex items-start gap-3 p-3 pl-11">
|
|
||||||
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
|
||||||
<p className="text-sm font-medium truncate">{m.device_name || m.device_id}</p>
|
|
||||||
{m.device_node_id && (
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">{m.device_node_id}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
<ActiveBadge active={m.active} />
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
Joined: {m.join_seen ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{m.active_session && m.active_session.is_active && (
|
|
||||||
<SessionProgress session={m.active_session} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0 pt-0.5">
|
|
||||||
{m.active ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeactivate(m.id)}
|
|
||||||
disabled={deactivatingMembership === m.id}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{deactivatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
|
||||||
Deactivate
|
|
||||||
</Button>
|
|
||||||
) : m.status === "approved" ? (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleActivate(m.id)}
|
|
||||||
disabled={activatingMembership === m.id}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{activatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
|
||||||
Activate
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
Not eligible
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setConfirmRemoveDevice(m.id)}
|
|
||||||
disabled={removingMembership === m.id}
|
|
||||||
className="text-destructive hover:text-destructive flex-shrink-0"
|
|
||||||
>
|
|
||||||
{removingMembership === m.id ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Deactivate All section */}
|
|
||||||
{members.filter(m => m.active).length > 0 && (
|
|
||||||
<Card className="mt-4 border-orange-200">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ZapOff className="w-4 h-4 text-orange-500" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{members.filter(m => m.active).length} active {members.filter(m => m.active).length === 1 ? "membership" : "memberships"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Input
|
|
||||||
placeholder="Reason (optional)"
|
|
||||||
value={deactivateReason}
|
|
||||||
onChange={(e) => setDeactivateReason(e.target.value)}
|
|
||||||
className="h-8 w-44 text-xs"
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="destructive" onClick={handleDeactivateAll} disabled={deactivatingAll} className="gap-1">
|
|
||||||
{deactivatingAll ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
|
||||||
Deactivate All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Add New Membership Dialog */}
|
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={(open) => { setIsAddDialogOpen(open); if (!open) { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); setUserSearch(""); } }}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{addStep === 1 && "Add New Membership - Select User"}
|
|
||||||
{addStep === 2 && "Add New Membership - Select Device"}
|
|
||||||
{addStep === 3 && "Add New Membership - Confirm"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{addStep === 1 && "Search and select a user to add to this network."}
|
|
||||||
{addStep === 2 && selectedUser && `Select a device for ${selectedUser.user?.full_name || selectedUser.user_id}.`}
|
|
||||||
{addStep === 3 && "Review and confirm the membership details."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{addStep === 1 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search users by name or email..."
|
|
||||||
value={userSearch}
|
|
||||||
onChange={(e) => setUserSearch(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
|
||||||
{isLoadingMembers ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
orgMembers
|
|
||||||
.filter(m => {
|
|
||||||
const search = userSearch.toLowerCase();
|
|
||||||
if (!search) return true;
|
|
||||||
const name = m.user?.full_name?.toLowerCase() || "";
|
|
||||||
const email = m.user?.email?.toLowerCase() || "";
|
|
||||||
const id = m.user_id.toLowerCase();
|
|
||||||
return name.includes(search) || email.includes(search) || id.includes(search);
|
|
||||||
})
|
|
||||||
.map(m => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
className="w-full text-left px-3 py-2 rounded hover:bg-accent flex items-center gap-3"
|
|
||||||
onClick={() => setSelectedUser(m)}
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Users className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{m.user?.full_name || "Unnamed User"}</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">{m.user?.email || m.user_id}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{addStep === 2 && selectedUser && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); }}>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{selectedUser.user?.full_name || selectedUser.user_id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isLoadingDevices ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : userDevices.length === 0 ? (
|
|
||||||
<p className="text-center text-muted-foreground py-4">No devices found for this user.</p>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
|
||||||
{userDevices.map(d => (
|
|
||||||
<button
|
|
||||||
key={d.id}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left px-3 py-2 rounded hover:bg-accent flex items-center gap-3",
|
|
||||||
selectedDevice?.id === d.id && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setSelectedDevice(d); setAddStep(3); }}
|
|
||||||
>
|
|
||||||
<Monitor className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{d.device_nickname || d.hostname || d.node_id}</p>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">{d.node_id}</p>
|
|
||||||
</div>
|
|
||||||
{selectedDevice?.id === d.id && <CheckCircle className="w-4 h-4 text-primary" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{addStep === 3 && selectedUser && selectedDevice && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-muted/50 p-4 rounded-lg space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">User</p>
|
|
||||||
<p className="text-sm font-medium">{selectedUser.user?.full_name || "Unnamed User"}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{selectedUser.user?.email || selectedUser.user_id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Device</p>
|
|
||||||
<p className="text-sm font-medium">{selectedDevice.device_nickname || selectedDevice.hostname || "Unnamed Device"}</p>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">{selectedDevice.node_id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Network</p>
|
|
||||||
<p className="text-sm font-medium">{network?.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{addStep === 1 && (
|
|
||||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
|
||||||
)}
|
|
||||||
{addStep === 2 && (
|
|
||||||
<Button variant="outline" onClick={() => { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); }}>Back</Button>
|
|
||||||
)}
|
|
||||||
{addStep === 3 && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => { setAddStep(2); setSelectedDevice(null); }}>Back</Button>
|
|
||||||
<Button onClick={handleAddMembership} disabled={isJoining}>
|
|
||||||
{isJoining && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Add Membership
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ── Activation Lifetime Dialog ──────────────────────────────────── */}
|
|
||||||
<Dialog open={!!showActivateDialog} onOpenChange={(open) => { if (!open) setShowActivateDialog(null); }}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Set Activation Duration</DialogTitle>
|
|
||||||
<DialogDescription>How long should this membership be active?</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Duration (minutes)</Label>
|
|
||||||
<Input type="number" value={activateLifetime} onChange={(e) => setActivateLifetime(e.target.value)} placeholder="480" />
|
|
||||||
<p className="text-xs text-muted-foreground">e.g. 480 = 8 hours, 60 = 1 hour</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowActivateDialog(null)}>Cancel</Button>
|
|
||||||
<Button onClick={() => { if (showActivateDialog) handleActivateConfirm(showActivateDialog); }} disabled={activatingMembership !== null}>
|
|
||||||
{activatingMembership !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Activate
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ── Confirm Remove Single Device ─────────────────────────────────────── */}
|
|
||||||
<AlertDialog open={!!confirmRemoveDevice} onOpenChange={() => setConfirmRemoveDevice(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Remove device from network?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will remove this device's membership from the network. The user will need to re-join if they want access again.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={removingMembership !== null}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
disabled={removingMembership !== null}
|
|
||||||
onClick={() => confirmRemoveDevice && handleRemoveDevice(confirmRemoveDevice)}
|
|
||||||
>
|
|
||||||
{removingMembership !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Remove
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{/* ── Confirm Remove All User Devices ────────────────────────────────── */}
|
|
||||||
<AlertDialog open={!!confirmRemoveUser} onOpenChange={() => setConfirmRemoveUser(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Remove all devices for this user?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will remove all memberships for user <span className="font-medium">{members.find(m => m.user_id === confirmRemoveUser)?.user_name || confirmRemoveUser}</span>. All of their devices will lose access to this network.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={removingUserId !== null}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
disabled={removingUserId !== null}
|
|
||||||
onClick={() => confirmRemoveUser && handleRemoveUserDevices(confirmRemoveUser)}
|
|
||||||
>
|
|
||||||
{removingUserId !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Remove All
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -8,10 +7,14 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Users,
|
Users,
|
||||||
|
Monitor,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Eye,
|
Eye,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
Ban,
|
Ban,
|
||||||
Zap,
|
Zap,
|
||||||
Download,
|
Download,
|
||||||
@@ -52,6 +55,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +63,8 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
AvailableZtNetwork,
|
AvailableZtNetwork,
|
||||||
PortalNetwork,
|
PortalNetwork,
|
||||||
|
DeviceNetworkMembership,
|
||||||
|
UserNetworkApproval,
|
||||||
NetworkEnvironment,
|
NetworkEnvironment,
|
||||||
NetworkRequestMode,
|
NetworkRequestMode,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -113,7 +119,6 @@ function cn(...classes: (string | boolean | undefined | null)[]) {
|
|||||||
export default function NetworksPage() {
|
export default function NetworksPage() {
|
||||||
const { orgId } = useCurrentOrganizationId();
|
const { orgId } = useCurrentOrganizationId();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -131,6 +136,11 @@ export default function NetworksPage() {
|
|||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
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 [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
@@ -170,6 +180,31 @@ export default function NetworksPage() {
|
|||||||
fetchNetworks();
|
fetchNetworks();
|
||||||
}, [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 openZtPicker = async () => {
|
const openZtPicker = async () => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setShowZtPicker(true);
|
setShowZtPicker(true);
|
||||||
@@ -291,7 +326,7 @@ export default function NetworksPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">ZeroTier Networks</h1>
|
<h1 className="page-title">Networks</h1>
|
||||||
<p className="page-description">Manage ZeroTier portal networks and monitor access</p>
|
<p className="page-description">Manage ZeroTier portal networks and monitor access</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,7 +355,7 @@ export default function NetworksPage() {
|
|||||||
Portal Networks
|
Portal Networks
|
||||||
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
|
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Click a network to manage members, devices, and access requests</CardDescription>
|
<CardDescription>Click a network to view members, requests, and manage access</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -340,7 +375,7 @@ export default function NetworksPage() {
|
|||||||
<button
|
<button
|
||||||
key={network.id}
|
key={network.id}
|
||||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||||
onClick={() => navigate(`/org/zerotier/networks/${network.id}`)}
|
onClick={() => openNetworkDrawer(network)}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<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" />
|
<Network className="w-5 h-5 text-primary" />
|
||||||
@@ -375,7 +410,7 @@ export default function NetworksPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
|
||||||
<Eye className="w-4 h-4 mr-2" /> View details
|
<Eye className="w-4 h-4 mr-2" /> View details
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
|
||||||
@@ -661,6 +696,118 @@ export default function NetworksPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export default function OIDCClientsPage() {
|
|||||||
// Generic form
|
// Generic form
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const urisRef = useRef<HTMLTextAreaElement>(null);
|
const urisRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const corsRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
// Proxy form
|
// Proxy form
|
||||||
const proxyNameRef = useRef<HTMLInputElement>(null);
|
const proxyNameRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -132,7 +131,6 @@ export default function OIDCClientsPage() {
|
|||||||
const [editingClient, setEditingClient] = useState<OIDCClient | null>(null);
|
const [editingClient, setEditingClient] = useState<OIDCClient | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editUris, setEditUris] = useState("");
|
const [editUris, setEditUris] = useState("");
|
||||||
const [editCors, setEditCors] = useState("");
|
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,16 +149,10 @@ export default function OIDCClientsPage() {
|
|||||||
let uris: string[];
|
let uris: string[];
|
||||||
let proxyHost: string | undefined;
|
let proxyHost: string | undefined;
|
||||||
|
|
||||||
let corsOrigins: string[] | null = null;
|
|
||||||
|
|
||||||
if (dialogMode === "generic") {
|
if (dialogMode === "generic") {
|
||||||
name = nameRef.current?.value.trim() ?? "";
|
name = nameRef.current?.value.trim() ?? "";
|
||||||
uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||||
if (!name || !uris.length) return;
|
if (!name || !uris.length) return;
|
||||||
const corsRaw = (corsRef.current?.value ?? "").trim();
|
|
||||||
if (corsRaw) {
|
|
||||||
corsOrigins = corsRaw.split(/[\n,]+/).map((o) => o.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
name = proxyNameRef.current?.value.trim() ?? "";
|
name = proxyNameRef.current?.value.trim() ?? "";
|
||||||
proxyHost = proxyHostRef.current?.value.trim() ?? "";
|
proxyHost = proxyHostRef.current?.value.trim() ?? "";
|
||||||
@@ -174,7 +166,7 @@ export default function OIDCClientsPage() {
|
|||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.organizations.createClient(orgId, name, uris, corsOrigins);
|
const result = await api.organizations.createClient(orgId, name, uris);
|
||||||
const created = result.client as OIDCClientWithSecret;
|
const created = result.client as OIDCClientWithSecret;
|
||||||
setClients((prev) => [...prev, created]);
|
setClients((prev) => [...prev, created]);
|
||||||
setNewSecret({
|
setNewSecret({
|
||||||
@@ -210,7 +202,6 @@ export default function OIDCClientsPage() {
|
|||||||
setEditingClient(client);
|
setEditingClient(client);
|
||||||
setEditName(client.name);
|
setEditName(client.name);
|
||||||
setEditUris((client.redirect_uris ?? []).join("\n"));
|
setEditUris((client.redirect_uris ?? []).join("\n"));
|
||||||
setEditCors((client.allowed_cors_origins ?? []).join("\n"));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
const handleSaveEdit = async () => {
|
||||||
@@ -219,14 +210,9 @@ export default function OIDCClientsPage() {
|
|||||||
const uris = editUris.split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
const uris = editUris.split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||||
if (!name || !uris.length) return;
|
if (!name || !uris.length) return;
|
||||||
|
|
||||||
const corsRaw = editCors.trim();
|
|
||||||
const corsOrigins: string[] | null = corsRaw
|
|
||||||
? corsRaw.split(/[\n,]+/).map((o) => o.trim()).filter(Boolean)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
setIsSavingEdit(true);
|
setIsSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.organizations.updateClient(orgId, editingClient.id, { name, redirect_uris: uris, allowed_cors_origins: corsOrigins });
|
const result = await api.organizations.updateClient(orgId, editingClient.id, { name, redirect_uris: uris });
|
||||||
setClients((prev) =>
|
setClients((prev) =>
|
||||||
prev.map((c) => (c.id === editingClient.id ? result.client : c))
|
prev.map((c) => (c.id === editingClient.id ? result.client : c))
|
||||||
);
|
);
|
||||||
@@ -404,16 +390,6 @@ export default function OIDCClientsPage() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
{client.allowed_cors_origins && client.allowed_cors_origins.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
<span className="text-xs text-muted-foreground mr-1">CORS:</span>
|
|
||||||
{client.allowed_cors_origins.map((origin) => (
|
|
||||||
<Badge key={origin} variant="outline" className="text-xs font-mono">
|
|
||||||
{origin}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-3 pt-3 border-t flex items-center justify-between text-xs text-muted-foreground">
|
<div className="mt-3 pt-3 border-t flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>Created {new Date(client.created_at).toLocaleDateString()}</span>
|
<span>Created {new Date(client.created_at).toLocaleDateString()}</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -463,19 +439,6 @@ export default function OIDCClientsPage() {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">One URI per line</p>
|
<p className="text-xs text-muted-foreground">One URI per line</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="corsOrigins">Allowed CORS origins</Label>
|
|
||||||
<Textarea
|
|
||||||
id="corsOrigins"
|
|
||||||
placeholder={"https://myapp.example.com\nhttps://staging.myapp.example.com"}
|
|
||||||
className="min-h-[60px] font-mono text-sm"
|
|
||||||
ref={corsRef}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
One origin per line (scheme + host + optional port, no path). Leave empty to use the server default.
|
|
||||||
Use <code className="bg-muted px-1 rounded">+</code> to auto-derive from redirect URIs, or <code className="bg-muted px-1 rounded">*</code> to allow any origin.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* oauth2-proxy tab */}
|
{/* oauth2-proxy tab */}
|
||||||
@@ -558,7 +521,7 @@ export default function OIDCClientsPage() {
|
|||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit OIDC Client</DialogTitle>
|
<DialogTitle>Edit OIDC Client</DialogTitle>
|
||||||
<DialogDescription>Update the client name, redirect URIs, and CORS origins.</DialogDescription>
|
<DialogDescription>Update the client name and redirect URIs.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -581,20 +544,6 @@ export default function OIDCClientsPage() {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">One URI per line</p>
|
<p className="text-xs text-muted-foreground">One URI per line</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="editCors">Allowed CORS origins</Label>
|
|
||||||
<Textarea
|
|
||||||
id="editCors"
|
|
||||||
value={editCors}
|
|
||||||
onChange={(e) => setEditCors(e.target.value)}
|
|
||||||
placeholder={"https://myapp.example.com\nhttps://staging.myapp.example.com"}
|
|
||||||
className="min-h-[60px] font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
One origin per line. Leave empty to use the server default.
|
|
||||||
Use <code className="bg-muted px-1 rounded">+</code> to auto-derive from redirect URIs, or <code className="bg-muted px-1 rounded">*</code> to allow any origin.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{editingClient && (
|
{editingClient && (
|
||||||
<div className="rounded-md bg-muted/50 border px-3 py-2 space-y-1">
|
<div className="rounded-md bg-muted/50 border px-3 py-2 space-y-1">
|
||||||
<p className="text-xs text-muted-foreground font-medium">Client ID (read-only)</p>
|
<p className="text-xs text-muted-foreground font-medium">Client ID (read-only)</p>
|
||||||
|
|||||||
+20
-233
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Download, Globe, Lock, Search, Filter, RefreshCw, ChevronLeft, ChevronRight,
|
Search, Filter, RefreshCw, ChevronLeft, ChevronRight,
|
||||||
LogIn, Key, UserPlus, Shield, Settings,
|
LogIn, Key, UserPlus, Shield, Settings,
|
||||||
AlertTriangle, Terminal, Loader2, X,
|
AlertTriangle, Terminal, Loader2,
|
||||||
CheckCircle2, XCircle, Link2, UserCog,
|
CheckCircle2, XCircle, Link2, UserCog,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import {
|
import {
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { api, AuditLogEntry, ApiError, OrganizationMember } from "@/lib/api";
|
import { api, AuditLogEntry } from "@/lib/api";
|
||||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
import { formatDateTime } from "@/lib/date";
|
import { formatDateTime } from "@/lib/date";
|
||||||
|
|
||||||
@@ -155,16 +155,6 @@ const ACTION_FILTER_OPTIONS = [
|
|||||||
|
|
||||||
const PER_PAGE = 50;
|
const PER_PAGE = 50;
|
||||||
|
|
||||||
const getUserLabel = (log: AuditLogEntry) =>
|
|
||||||
log.user?.email || (log.user_id ? `${log.user_id.slice(0, 8)}…` : null);
|
|
||||||
|
|
||||||
// ─── filter chip helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ACTION_CHIP_LABELS: Record<string, string> = {
|
|
||||||
...ACTION_LABELS,
|
|
||||||
all: "All actions",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── cert metadata detail ─────────────────────────────────────────────────────
|
// ─── cert metadata detail ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CertDetail({ metadata }: { metadata?: Record<string, unknown> | null }) {
|
function CertDetail({ metadata }: { metadata?: Record<string, unknown> | null }) {
|
||||||
@@ -192,20 +182,11 @@ export default function OrgAuditPage() {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [actionFilter, setActionFilter] = useState("all");
|
const [actionFilter, setActionFilter] = useState("all");
|
||||||
const [successFilter, setSuccessFilter] = useState("all");
|
const [successFilter, setSuccessFilter] = useState("all");
|
||||||
const [userFilter, setUserFilter] = useState<string | null>(null);
|
|
||||||
const [userFilterLabel, setUserFilterLabel] = useState<string | null>(null);
|
|
||||||
const [viewMode, setViewMode] = useState<"org" | "user">("org");
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
||||||
const [selectedUserLabel, setSelectedUserLabel] = useState<string | null>(null);
|
|
||||||
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
|
||||||
const [isMembersLoading, setIsMembersLoading] = useState(false);
|
|
||||||
const [accessDenied, setAccessDenied] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// debounce search
|
// debounce search
|
||||||
@@ -215,22 +196,12 @@ export default function OrgAuditPage() {
|
|||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// reset page on filter change
|
// reset page on filter change
|
||||||
useEffect(() => { setPage(1); }, [actionFilter, successFilter, userFilter, debouncedSearch, viewMode, selectedUserId]);
|
useEffect(() => { setPage(1); }, [actionFilter, successFilter, debouncedSearch]);
|
||||||
|
|
||||||
// fetch org members for user selector
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode !== "user" || !orgId) return;
|
|
||||||
setIsMembersLoading(true);
|
|
||||||
api.organizations.getMembers(orgId)
|
|
||||||
.then((resp) => setOrgMembers(resp.members ?? []))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setIsMembersLoading(false));
|
|
||||||
}, [viewMode, orgId]);
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
|
if (!orgId) { setIsLoading(false); return; }
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setAccessDenied(false);
|
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
page: String(page),
|
page: String(page),
|
||||||
@@ -238,157 +209,39 @@ export default function OrgAuditPage() {
|
|||||||
};
|
};
|
||||||
if (actionFilter !== "all") params.action = actionFilter;
|
if (actionFilter !== "all") params.action = actionFilter;
|
||||||
if (successFilter !== "all") params.success = successFilter;
|
if (successFilter !== "all") params.success = successFilter;
|
||||||
if (userFilter) params.user_id = userFilter;
|
|
||||||
if (debouncedSearch) params.q = debouncedSearch;
|
if (debouncedSearch) params.q = debouncedSearch;
|
||||||
|
|
||||||
if (viewMode === "user") {
|
const resp = await api.organizations.getAuditLogs(orgId, params);
|
||||||
if (!selectedUserId) { setIsLoading(false); return; }
|
setAuditLogs(resp.audit_logs ?? []);
|
||||||
const resp = await api.superadmin.getUserAuditLogs(selectedUserId, params);
|
setTotalCount(resp.count ?? 0);
|
||||||
setAuditLogs(resp.audit_logs ?? []);
|
setTotalPages(resp.pages ?? 1);
|
||||||
setTotalCount(resp.count ?? 0);
|
|
||||||
setTotalPages(resp.pages ?? 1);
|
|
||||||
} else {
|
|
||||||
if (!orgId) { setIsLoading(false); return; }
|
|
||||||
const resp = await api.organizations.getAuditLogs(orgId, params);
|
|
||||||
setAuditLogs(resp.audit_logs ?? []);
|
|
||||||
setTotalCount(resp.count ?? 0);
|
|
||||||
setTotalPages(resp.pages ?? 1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 403) {
|
console.error("Failed to fetch org audit logs:", err);
|
||||||
setAccessDenied(true);
|
setError("Failed to load audit logs. Please try again.");
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch audit logs:", err);
|
|
||||||
setError("Failed to load audit logs. Please try again.");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [orgId, page, actionFilter, successFilter, userFilter, debouncedSearch, viewMode, selectedUserId]);
|
}, [orgId, page, actionFilter, successFilter, debouncedSearch]);
|
||||||
|
|
||||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
|
||||||
setIsExporting(true);
|
|
||||||
try {
|
|
||||||
const EXPORT_PER_PAGE = 200;
|
|
||||||
const buildParams = (p: number) => {
|
|
||||||
const params: Record<string, string> = { page: String(p), per_page: String(EXPORT_PER_PAGE) };
|
|
||||||
if (actionFilter !== "all") params.action = actionFilter;
|
|
||||||
if (successFilter !== "all") params.success = successFilter;
|
|
||||||
if (userFilter) params.user_id = userFilter;
|
|
||||||
if (debouncedSearch) params.q = debouncedSearch;
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (viewMode === "user") {
|
|
||||||
if (!selectedUserId) return;
|
|
||||||
await api.superadmin.exportUserAuditLogs(selectedUserId, buildParams(1));
|
|
||||||
} else {
|
|
||||||
if (!orgId) return;
|
|
||||||
const first = await api.organizations.getAuditLogs(orgId, buildParams(1));
|
|
||||||
const allLogs = [...(first.audit_logs ?? [])];
|
|
||||||
const totalPages = first.pages ?? 1;
|
|
||||||
|
|
||||||
if (totalPages > 1) {
|
|
||||||
const remaining = await Promise.all(
|
|
||||||
Array.from({ length: totalPages - 1 }, (_, i) =>
|
|
||||||
api.organizations.getAuditLogs(orgId, buildParams(i + 2))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
for (const r of remaining) allLogs.push(...(r.audit_logs ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
|
||||||
const header = ["ID","Action","Description","User Email","User ID","Resource Type","Resource ID","IP Address","User Agent","Success","Error Message","Created At","Updated At"];
|
|
||||||
const rows = allLogs.map((l) => [
|
|
||||||
l.id, l.action, l.description ?? "",
|
|
||||||
l.user?.email ?? "", l.user_id ?? "",
|
|
||||||
l.resource_type ?? "", l.resource_id ?? "",
|
|
||||||
l.ip_address ?? "", l.user_agent ?? "",
|
|
||||||
l.success ? "Yes" : "No",
|
|
||||||
l.error_message ?? "",
|
|
||||||
l.created_at, l.updated_at ?? "",
|
|
||||||
].map(esc).join(","));
|
|
||||||
const csv = [header.map(esc).join(","), ...rows].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Export failed:", err);
|
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
}
|
|
||||||
}, [orgId, viewMode, selectedUserId, actionFilter, successFilter, userFilter, debouncedSearch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Admin Audit Log</h1>
|
<h1 className="page-title">Org Audit Log</h1>
|
||||||
<p className="page-description">
|
<p className="page-description">
|
||||||
{viewMode === "user"
|
All organisation activity — user events, admin actions, policy changes
|
||||||
? `User events for ${selectedUserLabel ?? "selected user"}`
|
|
||||||
: "Organisation activity — user events, admin actions, policy changes"
|
|
||||||
}
|
|
||||||
{totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}
|
{totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={isLoading}>
|
||||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting || isLoading}>
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
<Download className="w-4 h-4 mr-2" />
|
Refresh
|
||||||
{isExporting ? "Exporting…" : "Export CSV"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={isLoading}>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View mode toggle */}
|
|
||||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit mb-4">
|
|
||||||
<Button variant={viewMode === "org" ? "default" : "ghost"} size="sm" onClick={() => setViewMode("org")}>
|
|
||||||
Org events
|
|
||||||
</Button>
|
|
||||||
<Button variant={viewMode === "user" ? "default" : "ghost"} size="sm" onClick={() => setViewMode("user")}>
|
|
||||||
User events
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User selector (user mode only) */}
|
|
||||||
{viewMode === "user" && (
|
|
||||||
<div className="flex gap-3 mb-4">
|
|
||||||
<Select
|
|
||||||
value={selectedUserId ?? ""}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
const member = orgMembers.find((m) => m.user_id === v);
|
|
||||||
setSelectedUserId(v);
|
|
||||||
setSelectedUserLabel(member?.user?.email ?? member?.user?.full_name ?? `${v.slice(0, 8)}…`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[320px]">
|
|
||||||
<Globe className="w-4 h-4 mr-2" />
|
|
||||||
<SelectValue placeholder={isMembersLoading ? "Loading users…" : "Select a user…"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{orgMembers.map((m) => (
|
|
||||||
<SelectItem key={m.user_id} value={m.user_id}>
|
|
||||||
{m.user?.email || m.user?.full_name || m.user_id.slice(0, 8)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -423,39 +276,6 @@ export default function OrgAuditPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filter chips */}
|
|
||||||
{(actionFilter !== "all" || successFilter !== "all" || userFilter) && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
||||||
{actionFilter !== "all" && (
|
|
||||||
<Badge variant="secondary" className="gap-1 px-3 py-1">
|
|
||||||
<span className="text-xs">Action: {ACTION_CHIP_LABELS[actionFilter] ?? actionFilter}</span>
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => setActionFilter("all")}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{userFilter && (
|
|
||||||
<Badge variant="secondary" className="gap-1 px-3 py-1">
|
|
||||||
<span className="text-xs">User: {userFilterLabel ?? userFilter.slice(0, 8) + "…"}</span>
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => { setUserFilter(null); setUserFilterLabel(null); }}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{successFilter !== "all" && (
|
|
||||||
<Badge variant="secondary" className="gap-1 px-3 py-1">
|
|
||||||
<span className="text-xs">Status: {successFilter === "true" ? "Success only" : "Failures only"}</span>
|
|
||||||
<X
|
|
||||||
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => setSuccessFilter("all")}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -464,25 +284,11 @@ export default function OrgAuditPage() {
|
|||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-muted-foreground">Loading…</span>
|
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
) : accessDenied ? (
|
|
||||||
<div className="py-16 text-center text-muted-foreground">
|
|
||||||
<Lock className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
|
||||||
<p className="font-medium text-base">Access Restricted</p>
|
|
||||||
<p className="text-sm mt-1 max-w-sm mx-auto">
|
|
||||||
You don't have permission to view user audit logs. Contact your administrator to request access.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="py-12 text-center text-destructive">
|
<div className="py-12 text-center text-destructive">
|
||||||
<AlertTriangle className="w-8 h-8 mx-auto mb-2" />
|
<AlertTriangle className="w-8 h-8 mx-auto mb-2" />
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "user" && !selectedUserId ? (
|
|
||||||
<div className="py-16 text-center text-muted-foreground">
|
|
||||||
<UserCog className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
|
||||||
<p className="font-medium text-base">No user selected</p>
|
|
||||||
<p className="text-sm mt-1">Select a user above to view their audit events.</p>
|
|
||||||
</div>
|
|
||||||
) : auditLogs.length === 0 ? (
|
) : auditLogs.length === 0 ? (
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
No audit events match the current filters.
|
No audit events match the current filters.
|
||||||
@@ -507,12 +313,7 @@ export default function OrgAuditPage() {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span className="font-medium text-sm text-foreground">
|
||||||
className="font-medium text-sm text-foreground cursor-pointer hover:text-primary transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
setActionFilter((prev) => (prev === log.action ? "all" : log.action))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getActionLabel(log.action)}
|
{getActionLabel(log.action)}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
|
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
|
||||||
@@ -537,23 +338,9 @@ export default function OrgAuditPage() {
|
|||||||
{/* Actor / meta row */}
|
{/* Actor / meta row */}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
{log.user?.email ? (
|
{log.user?.email ? (
|
||||||
<span
|
<span className="font-medium text-foreground/70">{log.user.email}</span>
|
||||||
className="font-medium text-foreground/70 cursor-pointer hover:text-foreground transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
if (log.user_id) {
|
|
||||||
setUserFilter((prev) => (prev === log.user_id ? null : log.user_id));
|
|
||||||
setUserFilterLabel((prev) => (prev === log.user.email ? null : log.user.email));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{log.user.email}</span>
|
|
||||||
) : log.user_id ? (
|
) : log.user_id ? (
|
||||||
<span
|
<span className="font-mono">{log.user_id.slice(0, 8)}…</span>
|
||||||
className="font-mono cursor-pointer hover:text-foreground transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setUserFilter((prev) => (prev === log.user_id ? null : log.user_id));
|
|
||||||
setUserFilterLabel((prev) => prev === log.user_id ? null : `${log.user_id!.slice(0, 8)}…`);
|
|
||||||
}}
|
|
||||||
>{log.user_id.slice(0, 8)}…</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="italic">System</span>
|
<span className="italic">System</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -47,43 +47,13 @@ export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardPro
|
|||||||
const isSystem = !!ca.is_system;
|
const isSystem = !!ca.is_system;
|
||||||
|
|
||||||
// ── User CA: server trusts this public key so it accepts user certs ──────
|
// ── User CA: server trusts this public key so it accepts user certs ──────
|
||||||
const userCaServerSnippet = `#!/usr/bin/env bash
|
const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
|
||||||
set -euo pipefail
|
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca
|
||||||
|
|
||||||
CA_KEY='${ca.public_key.trim()}'
|
# /etc/ssh/sshd_config (add once, then reload sshd):
|
||||||
UNIX_USER="ubuntu" # ← change to the server's unix user
|
TrustedUserCAKeys /etc/ssh/trusted_user_ca
|
||||||
PRINCIPAL="<Your principal>" # ← change to the principal for this user
|
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
|
||||||
|
# Create /etc/ssh/auth_principals/<unix-user> containing one principal per line.`;
|
||||||
CA_FILE="/etc/ssh/trusted_user_ca"
|
|
||||||
PRINCIPALS_DIR="/etc/ssh/auth_principals"
|
|
||||||
SSHD_DROP_IN="/etc/ssh/sshd_config.d/99-ca-auth.conf"
|
|
||||||
|
|
||||||
if [[ "\$(id -u)" -ne 0 ]]; then
|
|
||||||
echo "error: must be run as root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m 0644 -o root -g root /dev/null "\${CA_FILE}"
|
|
||||||
echo "\${CA_KEY}" > "\${CA_FILE}"
|
|
||||||
|
|
||||||
install -d -m 0755 -o root -g root "\${PRINCIPALS_DIR}"
|
|
||||||
install -m 0644 -o root -g root /dev/null "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
|
||||||
echo "\${PRINCIPAL}" > "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
|
||||||
|
|
||||||
install -d -m 0755 -o root -g root "/etc/ssh/sshd_config.d"
|
|
||||||
install -m 0600 -o root -g root /dev/null "\${SSHD_DROP_IN}"
|
|
||||||
cat > "\${SSHD_DROP_IN}" <<EOF
|
|
||||||
TrustedUserCAKeys \${CA_FILE}
|
|
||||||
AuthorizedPrincipalsFile \${PRINCIPALS_DIR}/%u
|
|
||||||
EOF
|
|
||||||
|
|
||||||
if sshd -t; then
|
|
||||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
||||||
echo "done — CA trust and principal '\${PRINCIPAL}' configured for '\${UNIX_USER}'"
|
|
||||||
else
|
|
||||||
echo "error: sshd configuration test failed — SSH was NOT reloaded" >&2
|
|
||||||
exit 1
|
|
||||||
fi`;
|
|
||||||
|
|
||||||
// ── Host CA: clients trust this public key so they can verify server certs ─
|
// ── Host CA: clients trust this public key so they can verify server certs ─
|
||||||
const hostCaClientSnippet = `# On SSH clients — trust host certificates signed by this CA:
|
const hostCaClientSnippet = `# On SSH clients — trust host certificates signed by this CA:
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useOrg } from "@/contexts/OrgContext";
|
|
||||||
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
|
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
|
||||||
import { formatDate as _formatDate } from "@/lib/date";
|
import { formatDate as _formatDate } from "@/lib/date";
|
||||||
|
|
||||||
@@ -88,7 +87,6 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
|
|
||||||
export default function SSHKeysPage() {
|
export default function SSHKeysPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { selectedOrgId } = useOrg();
|
|
||||||
|
|
||||||
// Key list state
|
// Key list state
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||||
@@ -381,7 +379,6 @@ export default function SSHKeysPage() {
|
|||||||
principals.length > 0 ? principals : undefined,
|
principals.length > 0 ? principals : undefined,
|
||||||
certType,
|
certType,
|
||||||
parsedExpiry,
|
parsedExpiry,
|
||||||
selectedOrgId ?? undefined,
|
|
||||||
);
|
);
|
||||||
setCertResult(result.certificate);
|
setCertResult(result.certificate);
|
||||||
fetchCerts(); // refresh certs list
|
fetchCerts(); // refresh certs list
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,678 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react";
|
|
||||||
import React from "react";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
|
|
||||||
// ── Shared mock state (vi.hoisted avoids TDZ with vi.mock hoisting) ────────────
|
|
||||||
|
|
||||||
const H = vi.hoisted(() => ({
|
|
||||||
mockNavigate: vi.fn(),
|
|
||||||
mockListNetworks: vi.fn(),
|
|
||||||
mockListAvailableZtNetworks: vi.fn(),
|
|
||||||
mockToast: vi.fn(),
|
|
||||||
state: {
|
|
||||||
orgId: "org-test-123" as string | null,
|
|
||||||
},
|
|
||||||
navigateCalls: [] as string[],
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-router-dom", async () => {
|
|
||||||
const actual = await vi.importActual("react-router-dom");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useNavigate: () => {
|
|
||||||
const fn = (path: string) => {
|
|
||||||
H.navigateCalls.push(path);
|
|
||||||
H.mockNavigate(path);
|
|
||||||
};
|
|
||||||
return fn;
|
|
||||||
},
|
|
||||||
useParams: () => ({ orgId: H.state.orgId }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/hooks/useCurrentOrganization", () => ({
|
|
||||||
useCurrentOrganizationId: () => ({
|
|
||||||
orgId: H.state.orgId,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
useCurrentOrganization: () => ({
|
|
||||||
org: {
|
|
||||||
id: H.state.orgId,
|
|
||||||
name: "Test Org",
|
|
||||||
slug: "test-org",
|
|
||||||
description: null,
|
|
||||||
logo_url: null,
|
|
||||||
is_active: true,
|
|
||||||
role: "admin",
|
|
||||||
created_at: "2024-01-01",
|
|
||||||
updated_at: "2024-01-01",
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-toast", () => ({
|
|
||||||
useToast: () => ({
|
|
||||||
toast: H.mockToast,
|
|
||||||
dismiss: () => {},
|
|
||||||
toasts: [],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
|
||||||
api: {
|
|
||||||
zerotier: {
|
|
||||||
listNetworks: H.mockListNetworks,
|
|
||||||
listAvailableZtNetworks: H.mockListAvailableZtNetworks,
|
|
||||||
createNetwork: vi.fn(),
|
|
||||||
updateNetwork: vi.fn(),
|
|
||||||
deleteNetwork: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ApiError: class ApiError extends Error {
|
|
||||||
code: number;
|
|
||||||
type: string;
|
|
||||||
details: Record<string, unknown>;
|
|
||||||
constructor(message: string, code: number, type: string, details: Record<string, unknown> = {}) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
this.code = code;
|
|
||||||
this.type = type;
|
|
||||||
this.details = details;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import NetworksPage from "../src/pages/org/NetworksPage";
|
|
||||||
|
|
||||||
// ── Test data ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MOCK_NETWORKS = [
|
|
||||||
{
|
|
||||||
id: "net-001",
|
|
||||||
organization_id: "org-test-123",
|
|
||||||
name: "Production VPN",
|
|
||||||
description: "Main production network",
|
|
||||||
owner_user_id: "user-1",
|
|
||||||
zerotier_network_id: "d6578dd03c894448",
|
|
||||||
environment: "production" as const,
|
|
||||||
request_mode: "approval_required" as const,
|
|
||||||
default_activation_lifetime_minutes: 480,
|
|
||||||
max_activation_lifetime_minutes: null,
|
|
||||||
is_active: true,
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
deleted_at: null,
|
|
||||||
approved_user_count: 25,
|
|
||||||
active_membership_count: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "net-002",
|
|
||||||
organization_id: "org-test-123",
|
|
||||||
name: "Dev Network",
|
|
||||||
description: "Development and staging",
|
|
||||||
owner_user_id: "user-1",
|
|
||||||
zerotier_network_id: "abcdef1234567890",
|
|
||||||
environment: "development" as const,
|
|
||||||
request_mode: "open" as const,
|
|
||||||
default_activation_lifetime_minutes: 240,
|
|
||||||
max_activation_lifetime_minutes: 1440,
|
|
||||||
is_active: false,
|
|
||||||
created_at: "2024-01-02T00:00:00Z",
|
|
||||||
updated_at: "2024-01-02T00:00:00Z",
|
|
||||||
deleted_at: null,
|
|
||||||
approved_user_count: 5,
|
|
||||||
active_membership_count: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOCK_ZT_NETWORKS = [
|
|
||||||
{
|
|
||||||
id: "zt-net-001",
|
|
||||||
name: "External ZeroTier",
|
|
||||||
description: "An external ZT network",
|
|
||||||
owner_id: null,
|
|
||||||
online_member_count: 3,
|
|
||||||
authorized_member_count: 10,
|
|
||||||
total_member_count: 10,
|
|
||||||
already_managed: false,
|
|
||||||
portal_network_id: null,
|
|
||||||
portal_network_name: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
return render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<NetworksPage />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: all API calls return never-resolving promise (loading state)
|
|
||||||
// Individual tests override BEFORE calling renderPage().
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
H.navigateCalls.length = 0;
|
|
||||||
H.mockListNetworks.mockImplementation(() => new Promise(() => {}));
|
|
||||||
H.mockListAvailableZtNetworks.mockImplementation(() => new Promise(() => {}));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// HAPPY PATH: Data Loading
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — Data Loading", () => {
|
|
||||||
test("renders loading state while fetching networks", () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(screen.getByText("Loading networks…")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders network data when API resolves", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
||||||
expect(screen.getByText("d6578dd03c894448")).toBeDefined();
|
|
||||||
expect(screen.getByText("abcdef1234567890")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders error state when API fails", async () => {
|
|
||||||
H.mockListNetworks.mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText("Failed to load networks. Please try again."),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders empty state when no networks exist", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({ networks: [], count: 0 });
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText("No networks configured yet. Add one to get started."),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// NAVIGATION: Row Click
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — Row Click Navigation", () => {
|
|
||||||
test("clicking a network row navigates to /org/zerotier/networks/{networkId}", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const productionRow = screen.getByText("Production VPN").closest("button");
|
|
||||||
expect(productionRow).not.toBeNull();
|
|
||||||
|
|
||||||
fireEvent.click(productionRow!);
|
|
||||||
|
|
||||||
expect(H.mockNavigate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-001");
|
|
||||||
expect(H.navigateCalls).toEqual(["/org/zerotier/networks/net-001"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking second network row navigates to its URL", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const devRow = screen.getByText("Dev Network").closest("button");
|
|
||||||
expect(devRow).not.toBeNull();
|
|
||||||
|
|
||||||
fireEvent.click(devRow!);
|
|
||||||
|
|
||||||
expect(H.mockNavigate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-002");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("navigate NOT called before any click", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(H.mockNavigate).not.toHaveBeenCalled();
|
|
||||||
expect(H.navigateCalls).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// NAVIGATION: Dropdown "View details"
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — Dropdown View details Navigation", () => {
|
|
||||||
test('"View details" dropdown item navigates to /org/zerotier/networks/{networkId}', async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The MoreHorizontal button is a CHILD of the row button (nested inside it).
|
|
||||||
// Find it by looking for the button with the MoreHorizontal icon inside the row.
|
|
||||||
const productionRow = screen.getByText("Production VPN").closest("button")!;
|
|
||||||
// Find ALL nested buttons within the row
|
|
||||||
const nestedButtons = productionRow.querySelectorAll("button");
|
|
||||||
// The first nested button should be the MoreHorizontal dropdown trigger
|
|
||||||
expect(nestedButtons.length).toBeGreaterThan(0);
|
|
||||||
// Radix DropdownMenu opens on pointerdown
|
|
||||||
fireEvent.pointerDown(nestedButtons[0]);
|
|
||||||
|
|
||||||
// DropdownMenuContent renders in a portal, screen.getByText searches the whole document
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("View details")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("View details"));
|
|
||||||
|
|
||||||
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-001");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"View details" for second network navigates to its URL', async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const devRow = screen.getByText("Dev Network").closest("button")!;
|
|
||||||
const nestedButtons = devRow.querySelectorAll("button");
|
|
||||||
expect(nestedButtons.length).toBeGreaterThan(0);
|
|
||||||
fireEvent.pointerDown(nestedButtons[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("View details")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("View details"));
|
|
||||||
|
|
||||||
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-002");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// CARD DESCRIPTION TEXT
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — Card Description", () => {
|
|
||||||
test("CardDescription reflects page navigation (not old drawer text)", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const description = screen.getByText(
|
|
||||||
"Click a network to manage members, devices, and access requests",
|
|
||||||
);
|
|
||||||
expect(description).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("old drawer-related text is absent", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The old Sheet content (Network Details, member list) should NOT be present
|
|
||||||
expect(screen.queryByText("Network Details")).toBeNull();
|
|
||||||
expect(screen.queryByText("Members")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// ZERO TIER NETWORK PICKER SHEET
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — ZeroTier Picker Sheet", () => {
|
|
||||||
test('"Import from ZeroTier" button is present', async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const importButton = screen.getByRole("button", {
|
|
||||||
name: /import from zerotier/i,
|
|
||||||
});
|
|
||||||
expect(importButton).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking "Import from ZeroTier" opens the ZT Picker Sheet', async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
H.mockListAvailableZtNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_ZT_NETWORKS,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const importButton = screen.getByRole("button", {
|
|
||||||
name: /import from zerotier/i,
|
|
||||||
});
|
|
||||||
fireEvent.click(importButton);
|
|
||||||
|
|
||||||
// Wait for the Sheet to render with its content
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("External ZeroTier")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("zt-net-001")).toBeDefined();
|
|
||||||
expect(screen.getByText("Import")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ZT Picker calls API with correct orgId", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
H.mockListAvailableZtNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_ZT_NETWORKS,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const importButton = screen.getByRole("button", {
|
|
||||||
name: /import from zerotier/i,
|
|
||||||
});
|
|
||||||
fireEvent.click(importButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("External ZeroTier")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(H.mockListAvailableZtNetworks).toHaveBeenCalledTimes(1);
|
|
||||||
expect(H.mockListAvailableZtNetworks).toHaveBeenCalledWith("org-test-123");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// DATA DISPLAY
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — Data Display", () => {
|
|
||||||
test("displays network count badge", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The CardTitle contains "Portal Networks" and the count badge
|
|
||||||
expect(screen.getByText("Portal Networks")).toBeDefined();
|
|
||||||
// Badge with count "2" should be present
|
|
||||||
expect(screen.getByText("2")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays approved user counts", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("25")).toBeDefined();
|
|
||||||
expect(screen.getByText("5")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays active device counts", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("12")).toBeDefined();
|
|
||||||
expect(screen.getByText("0")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays environment badges", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Production")).toBeDefined();
|
|
||||||
expect(screen.getByText("Development")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays request mode badges", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Approval Required")).toBeDefined();
|
|
||||||
expect(screen.getByText("Open")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('displays "Inactive" badge for inactive networks', async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Inactive")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders search-empty state when filter matches nothing", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: MOCK_NETWORKS,
|
|
||||||
count: MOCK_NETWORKS.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText("Search networks…");
|
|
||||||
fireEvent.change(searchInput, { target: { value: "zzzz_nonexistent" } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText("No networks match your search."),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// EDGE CASES / ADVERSARIAL INPUTS
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
describe("NetworksPage — Adversarial Inputs", () => {
|
|
||||||
test("handles XSS-like network name as text", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: [
|
|
||||||
{
|
|
||||||
...MOCK_NETWORKS[0],
|
|
||||||
name: 'VPN <script>alert("xss")</script>',
|
|
||||||
description: "desc ${injection}",
|
|
||||||
zerotier_network_id: "../../etc/passwd",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText('VPN <script>alert("xss")</script>'),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles very long network name", async () => {
|
|
||||||
const longName = "A".repeat(500);
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: [{ ...MOCK_NETWORKS[0], name: longName, id: "net-long" }],
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(longName)).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles Unicode network name", async () => {
|
|
||||||
const unicodeName = "ネットワーク \u{1F525} 测试";
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: [
|
|
||||||
{ ...MOCK_NETWORKS[0], name: unicodeName, id: "net-unicode" },
|
|
||||||
],
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(unicodeName)).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles missing optional counts (undefined)", async () => {
|
|
||||||
H.mockListNetworks.mockResolvedValue({
|
|
||||||
networks: [
|
|
||||||
{
|
|
||||||
...MOCK_NETWORKS[0],
|
|
||||||
approved_user_count: undefined,
|
|
||||||
active_membership_count: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Production VPN")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show "0" for undefined counts (nullish coalescing: ?? 0)
|
|
||||||
const zeros = screen.getAllByText("0");
|
|
||||||
expect(zeros.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { api, tokenManager } from '../src/lib/api';
|
||||||
|
|
||||||
|
// Mock localStorage for Node.js test environment
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(global, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session API', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshSession', () => {
|
||||||
|
it('calls the correct endpoint and updates token expiry', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Session refreshed',
|
||||||
|
data: { expires_at: '2026-04-26T18:30:00Z' },
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a token first
|
||||||
|
tokenManager.setToken('test-token', '2026-04-26T17:00:00Z');
|
||||||
|
|
||||||
|
const result = await api.auth.refreshSession();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions/refresh'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.expires_at).toBe('2026-04-26T18:30:00Z');
|
||||||
|
expect(tokenManager.getExpiry()).toBe('2026-04-26T18:30:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSessions', () => {
|
||||||
|
it('calls the correct endpoint', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Sessions listed',
|
||||||
|
data: {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'sess-1',
|
||||||
|
created_at: '2026-04-26T10:00:00Z',
|
||||||
|
expires_at: '2026-04-26T18:00:00Z',
|
||||||
|
last_used_at: '2026-04-26T17:00:00Z',
|
||||||
|
ip_address: '127.0.0.1',
|
||||||
|
user_agent: 'Mozilla/5.0',
|
||||||
|
is_current: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
tokenManager.setToken('test-token');
|
||||||
|
|
||||||
|
const result = await api.auth.listSessions();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions'),
|
||||||
|
expect.objectContaining({
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.sessions).toHaveLength(1);
|
||||||
|
expect(result.sessions[0].id).toBe('sess-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeSession', () => {
|
||||||
|
it('encodes session ID and calls the correct endpoint', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Session revoked',
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
tokenManager.setToken('test-token');
|
||||||
|
|
||||||
|
await api.auth.revokeSession('sess-abc/123');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions/sess-abc%2F123'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tokenManager', () => {
|
||||||
|
it('updateExpiry sets the expiry in localStorage', () => {
|
||||||
|
tokenManager.setToken('test-token');
|
||||||
|
tokenManager.updateExpiry('2026-04-26T20:00:00Z');
|
||||||
|
expect(tokenManager.getExpiry()).toBe('2026-04-26T20:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getExpiry returns null when no expiry is set', () => {
|
||||||
|
localStorage.clear();
|
||||||
|
expect(tokenManager.getExpiry()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useSessionTimeout } from '../src/hooks/useSessionTimeout';
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(global, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
describe('useSessionTimeout', () => {
|
||||||
|
const mockLogout = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
mockLogout.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show warning when user is active', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show warning after idle period approaches timeout', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward 12 minutes (warning threshold is 3 min before 15 min timeout)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(12 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast-forward past the activity check interval (5 seconds)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(true);
|
||||||
|
expect(result.current.countdownSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call logout when session expires', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward past the 15-minute timeout
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(16 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for async logout
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extend session when extendSession is called', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Session refreshed',
|
||||||
|
data: { expires_at: '2026-04-26T20:00:00Z' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger warning
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(12 * 60 * 1000);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(true);
|
||||||
|
|
||||||
|
// Extend session
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.extendSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(false);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions/refresh'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send heartbeat periodically', () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'OK',
|
||||||
|
data: { user: { id: '1', email: 'test@example.com' } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderHook(() => useSessionTimeout(true, mockLogout));
|
||||||
|
|
||||||
|
// Fast-forward past heartbeat interval (5 minutes)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/users/me'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout on 401 heartbeat response', async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce({ code: 401, message: 'Unauthorized' });
|
||||||
|
|
||||||
|
renderHook(() => useSessionTimeout(true, mockLogout));
|
||||||
|
|
||||||
|
// Fast-forward past heartbeat interval
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the async catch block
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user