Compare commits
22 Commits
oidc-client
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 291c68eb26 | |||
| c4110d27ca | |||
| 0bb38a2fb4 | |||
| b7211ee43f | |||
| 3161ef7d91 | |||
| 7aa550f62a | |||
| 0bc18364d4 | |||
| 087b8f002f | |||
| ff2beae9d0 | |||
| 71f9e8b7ac | |||
| fe0b114ebf | |||
| a13e298d8a | |||
| dc4e9fe366 | |||
| 944a907080 | |||
| c6fbec6442 | |||
| 91f82fa101 | |||
| 2366847151 | |||
| 63db8975a5 | |||
| c217b36799 | |||
| 16fb2b4e41 | |||
| 9a5e023ec3 | |||
| 6fe039c515 |
+1
-2
@@ -1,8 +1,7 @@
|
||||
# ===========================================
|
||||
# Secuird UI Configuration
|
||||
# ===========================================
|
||||
# Copy this file to .env.local for local development
|
||||
# or use mode-specific env files (.env.development, .env.staging, .env.production)
|
||||
# Copy this file to .env for your environment
|
||||
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=https://api.gatehouse.local/api/v1
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: PR -> develop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
env:
|
||||
GITLEAKS_VERSION: "8.30.1"
|
||||
|
||||
jobs:
|
||||
|
||||
# ── 1. Secret scan ────────────────────────────────────────────────────────────
|
||||
gitleaks:
|
||||
name: Scan for secrets (Gitleaks)
|
||||
runs-on: stage-gatehouse-ui
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Gitleaks
|
||||
run: |
|
||||
if command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "gitleaks already installed: $(gitleaks version)"
|
||||
exit 0
|
||||
fi
|
||||
curl -sSfL \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
| tar xz gitleaks
|
||||
mv gitleaks /usr/local/bin/gitleaks
|
||||
|
||||
- name: Run secret scan
|
||||
# Scan only the commits this PR introduces (base..head), not the whole history.
|
||||
run: |
|
||||
gitleaks detect --source . \
|
||||
--log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \
|
||||
--exit-code 1 --redact --verbose --log-level debug
|
||||
|
||||
# ── 2. CVE scan ───────────────────────────────────────────────────────────────
|
||||
trivy:
|
||||
name: Scan for CVEs (Trivy)
|
||||
runs-on: stage-gatehouse-ui
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
command -v trivy >/dev/null 2>&1 || \
|
||||
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
|
||||
| sh -s -- -b /usr/local/bin
|
||||
|
||||
- name: Run filesystem scan
|
||||
run: |
|
||||
trivy fs \
|
||||
--exit-code 1 \
|
||||
--severity HIGH,CRITICAL \
|
||||
--no-progress \
|
||||
.
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Push -> develop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
|
||||
# ── 1. Build ──────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build Docker image
|
||||
runs-on: stage-gatehouse-ui
|
||||
outputs:
|
||||
tag: ${{ steps.sha.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: sha
|
||||
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build ui image
|
||||
run: |
|
||||
# VITE_API_BASE_URL is baked into the static bundle at build time.
|
||||
# Source it from the deployed env on this (stage) runner.
|
||||
set -a; . /opt/gatehouse-ui/.env; set +a
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL="${VITE_API_BASE_URL}" \
|
||||
-t "gatehouse-ui:${{ steps.sha.outputs.tag }}" \
|
||||
-t "gatehouse-ui:latest" \
|
||||
.
|
||||
|
||||
- name: Scan ui image for vulnerabilities (Trivy)
|
||||
run: |
|
||||
command -v trivy >/dev/null 2>&1 || \
|
||||
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
|
||||
| sh -s -- -b /usr/local/bin
|
||||
|
||||
trivy image \
|
||||
--exit-code 0 \
|
||||
--severity HIGH,CRITICAL \
|
||||
--no-progress \
|
||||
"gatehouse-ui:${{ steps.sha.outputs.tag }}"
|
||||
|
||||
# ── 2. Deploy ─────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: stage-gatehouse-ui
|
||||
needs: build
|
||||
env:
|
||||
COMPOSE_DIR: /opt/gatehouse-ui
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy (docker compose up)
|
||||
run: |
|
||||
cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml"
|
||||
cd "${COMPOSE_DIR}"
|
||||
IMAGE_TAG="${{ needs.build.outputs.tag }}" docker compose up -d --remove-orphans
|
||||
|
||||
# ── 3. Alert ──────────────────────────────────────────────────────────────────
|
||||
alert:
|
||||
name: Notify on result
|
||||
runs-on: stage-gatehouse-ui
|
||||
needs: deploy
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Send notification
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
echo "TODO: send alert — deploy status: ${STATUS}"
|
||||
# curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d "{\"text\": \"[gatehouse-ui] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}"
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Push -> main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
# ── 1. Build ──────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build Docker image
|
||||
runs-on: prod-gatehouse-ui
|
||||
outputs:
|
||||
tag: ${{ steps.sha.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: sha
|
||||
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build ui image
|
||||
run: |
|
||||
# VITE_API_BASE_URL is baked into the static bundle at build time.
|
||||
# Source it from the deployed env on this (prod) runner.
|
||||
set -a; . /opt/gatehouse-ui/.env; set +a
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL="${VITE_API_BASE_URL}" \
|
||||
-t "gatehouse-ui:${{ steps.sha.outputs.tag }}" \
|
||||
-t "gatehouse-ui:latest" \
|
||||
.
|
||||
|
||||
- name: Scan ui image for vulnerabilities (Trivy)
|
||||
run: |
|
||||
command -v trivy >/dev/null 2>&1 || \
|
||||
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
|
||||
| sh -s -- -b /usr/local/bin
|
||||
|
||||
trivy image \
|
||||
--exit-code 0 \
|
||||
--severity HIGH,CRITICAL \
|
||||
--no-progress \
|
||||
"gatehouse-ui:${{ steps.sha.outputs.tag }}"
|
||||
|
||||
# ── 2. Deploy ─────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: prod-gatehouse-ui
|
||||
needs: build
|
||||
env:
|
||||
COMPOSE_DIR: /opt/gatehouse-ui
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy (docker compose up)
|
||||
run: |
|
||||
cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml"
|
||||
cd "${COMPOSE_DIR}"
|
||||
IMAGE_TAG="${{ needs.build.outputs.tag }}" docker compose up -d --remove-orphans
|
||||
|
||||
# ── 3. Alert ──────────────────────────────────────────────────────────────────
|
||||
alert:
|
||||
name: Notify on result
|
||||
runs-on: prod-gatehouse-ui
|
||||
needs: deploy
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Send notification
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
echo "TODO: send alert — deploy status: ${STATUS}"
|
||||
# curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d "{\"text\": \"[gatehouse-ui] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}"
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -14,6 +14,9 @@ RUN bun run build
|
||||
# Production stage
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Patch inherited Alpine OS packages to clear known CVEs not yet in the base image
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
services:
|
||||
ui:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- VITE_API_BASE_URL=https://secuird.tech/api/v1
|
||||
image: gatehouse-ui:${IMAGE_TAG:-latest}
|
||||
container_name: gatehouse-ui
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
+4
-2
@@ -51,9 +51,9 @@ import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
|
||||
import CAsPage from "@/pages/org/CAsPage";
|
||||
import DepartmentsPage from "@/pages/org/DepartmentsPage";
|
||||
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
||||
import ApiKeysPage from "@/pages/org/ApiKeysPage";
|
||||
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
||||
import NetworksPage from "@/pages/org/NetworksPage";
|
||||
import NetworkManagementPage from "@/pages/org/NetworkManagementPage";
|
||||
import DevicesPage from "@/pages/org/DevicesPage";
|
||||
import AccessPage from "@/pages/org/AccessPage";
|
||||
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
|
||||
@@ -61,6 +61,7 @@ import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
||||
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
||||
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
||||
|
||||
import SessionTimeoutModal from "@/components/auth/SessionTimeoutModal";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -151,6 +152,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SessionTimeoutModal />
|
||||
<OrgProvider>
|
||||
<Routes>
|
||||
{/* Marketing pages */}
|
||||
@@ -202,13 +204,13 @@ function AppRoutes() {
|
||||
<Route path="/org/members/:userId" element={<RequireAdmin><UserManagementPage /></RequireAdmin>} />
|
||||
<Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></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/compliance" element={<RequireAdmin><CompliancePage /></RequireAdmin>} />
|
||||
<Route path="/org/audit" element={<RequireAdmin><OrgAuditPage /></RequireAdmin>} />
|
||||
<Route path="/org/clients" element={<RequireAdmin><OIDCClientsPage /></RequireAdmin>} />
|
||||
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></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/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -46,13 +46,15 @@ const userNavItems = [
|
||||
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
|
||||
{ title: "Activity", url: "/activity", icon: Activity },
|
||||
{ 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
|
||||
const orgMemberNavItems = [
|
||||
{ title: "Overview", url: "/org", icon: Building2 },
|
||||
{ title: "My Memberships", url: "/org/my-memberships", icon: Layers },
|
||||
{ title: "ZeroTier Devices", url: "/org/zerotier/devices", icon: Monitor },
|
||||
];
|
||||
|
||||
// Visible to org admins/owners only (management)
|
||||
@@ -61,12 +63,10 @@ const orgAdminNavItems = [
|
||||
{ title: "Members", url: "/org/members", icon: Users },
|
||||
{ title: "Departments", url: "/org/departments", icon: Layers },
|
||||
{ title: "Principals", url: "/org/principals", icon: GitBranch },
|
||||
{ title: "API Keys", url: "/org/api-keys", icon: Key },
|
||||
{ title: "Policies", url: "/org/policies", icon: Settings },
|
||||
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
|
||||
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
|
||||
{ title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings },
|
||||
{ title: "ZeroTier Devices", url: "/org/zerotier/devices", icon: Monitor },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
|
||||
+264
-35
@@ -177,6 +177,40 @@ export interface AdminLinkedAccount {
|
||||
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
|
||||
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
|
||||
|
||||
@@ -387,25 +421,42 @@ async function request<T>(
|
||||
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();
|
||||
|
||||
if (!json.success) {
|
||||
const errorType = json.error?.type || 'UNKNOWN_ERROR';
|
||||
|
||||
// Handle 401 token clearing based on configuration
|
||||
// Handle 401 in JSON body (backstop for servers that return 200 with code:401)
|
||||
if (json.code === 401) {
|
||||
const shouldClearToken =
|
||||
clearTokenOn401 === true ||
|
||||
(clearTokenOn401 === 'auto' && SESSION_INVALID_ERROR_TYPES.includes(errorType));
|
||||
|
||||
if (shouldClearToken) {
|
||||
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})`);
|
||||
}
|
||||
} 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
|
||||
@@ -698,6 +749,58 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(policy),
|
||||
}, 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: {
|
||||
@@ -782,6 +885,11 @@ export const api = {
|
||||
},
|
||||
});
|
||||
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();
|
||||
throw new ApiError(
|
||||
error.message || 'Failed to begin registration',
|
||||
@@ -810,6 +918,11 @@ export const api = {
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
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();
|
||||
throw new ApiError(
|
||||
error.message || 'No passkeys found for this account',
|
||||
@@ -833,6 +946,12 @@ export const api = {
|
||||
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();
|
||||
|
||||
if (!json.success) {
|
||||
@@ -984,14 +1103,14 @@ export const api = {
|
||||
request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig),
|
||||
|
||||
// Create department
|
||||
createDepartment: (orgId: string, name: string, description?: string, canSudo?: boolean, requestConfig?: RequestConfig) =>
|
||||
createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) =>
|
||||
request<{ department: Department }>(`/organizations/${orgId}/departments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, can_sudo: canSudo }),
|
||||
body: JSON.stringify({ name, description }),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Update department
|
||||
updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string; can_sudo?: boolean }, requestConfig?: RequestConfig) =>
|
||||
updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) =>
|
||||
request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
@@ -1103,10 +1222,10 @@ export const api = {
|
||||
request<{ clients: OIDCClient[]; count: number }>(`/organizations/${orgId}/clients`, {}, true, requestConfig),
|
||||
|
||||
// Create OIDC client
|
||||
createClient: (orgId: string, name: string, redirect_uris: string[], requestConfig?: RequestConfig) =>
|
||||
createClient: (orgId: string, name: string, redirect_uris: string[], allowed_cors_origins?: string[] | null, requestConfig?: RequestConfig) =>
|
||||
request<{ client: OIDCClientWithSecret }>(`/organizations/${orgId}/clients`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, redirect_uris }),
|
||||
body: JSON.stringify({ name, redirect_uris, allowed_cors_origins }),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Delete OIDC client
|
||||
@@ -1115,8 +1234,8 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
}, true, requestConfig),
|
||||
|
||||
// Update OIDC client (name and/or redirect_uris)
|
||||
updateClient: (orgId: string, clientId: string, data: { name?: string; redirect_uris?: string[] }, requestConfig?: RequestConfig) =>
|
||||
// Update OIDC client (name, redirect_uris, and/or allowed_cors_origins)
|
||||
updateClient: (orgId: string, clientId: string, data: { name?: string; redirect_uris?: string[]; allowed_cors_origins?: string[] | null }, requestConfig?: RequestConfig) =>
|
||||
request<{ client: OIDCClient }>(`/organizations/${orgId}/clients/${clientId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
@@ -1276,10 +1395,10 @@ export const api = {
|
||||
}, true, requestConfig),
|
||||
|
||||
// Sign a certificate for the given key
|
||||
signCertificate: (key_id: string, principals?: string[], cert_type?: 'user' | 'host', expiry_hours?: number, requestConfig?: RequestConfig) =>
|
||||
signCertificate: (key_id: string, principals?: string[], cert_type?: 'user' | 'host', expiry_hours?: number, organization_id?: string, requestConfig?: RequestConfig) =>
|
||||
request<SSHSignResponse>('/ssh/sign', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }),
|
||||
body: JSON.stringify({ key_id, principals, cert_type, expiry_hours, organization_id }),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Issue a host certificate by submitting a raw server host public key
|
||||
@@ -1501,15 +1620,10 @@ export const api = {
|
||||
{ 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 }>(
|
||||
`/organizations/${orgId}/approvals/assign`,
|
||||
{ method: "POST", body: JSON.stringify(data) },
|
||||
true, requestConfig,
|
||||
{ method: "POST", body: JSON.stringify(data) }, true, requestConfig,
|
||||
),
|
||||
|
||||
// ── Memberships ────────────────────────────────────────────────────────────
|
||||
@@ -1568,16 +1682,34 @@ export const api = {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
listSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ sessions: ActivationSession[]; count: number }>(
|
||||
listUserSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ sessions: UserSession[]; count: number }>(
|
||||
`/organizations/${orgId}/sessions`, {}, true, requestConfig,
|
||||
),
|
||||
|
||||
endSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ message: string }>(
|
||||
`/organizations/${orgId}/sessions/${sessionId}`,
|
||||
{ method: "DELETE" }, true, requestConfig,
|
||||
adminListSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ sessions: AdminSession[]; count: number }>(
|
||||
`/organizations/${orgId}/admin/sessions`, {}, 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 ───────────────────────────────────────────────────────────
|
||||
@@ -1593,6 +1725,13 @@ export const api = {
|
||||
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) ─────────────────────────────────
|
||||
getZtStatus: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ status: Record<string, unknown> }>(
|
||||
@@ -1677,7 +1816,6 @@ export interface Department {
|
||||
organization_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
can_sudo: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
@@ -1760,6 +1898,7 @@ export interface OIDCClient {
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
grant_types: string[];
|
||||
allowed_cors_origins: string[] | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -1964,6 +2103,32 @@ export interface AvailableZtNetwork {
|
||||
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 {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -1985,10 +2150,18 @@ export interface UserNetworkApproval {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
user_email: string | null;
|
||||
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;
|
||||
grant_type: ApprovalGrantType;
|
||||
state: ApprovalState;
|
||||
status: ApprovalState;
|
||||
justification: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -2000,19 +2173,35 @@ export interface DeviceNetworkMembership {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
user_email: string | null;
|
||||
device_id: string;
|
||||
device_name: string | null;
|
||||
device_node_id: string | null;
|
||||
portal_network_id: string;
|
||||
user_network_approval_id: string | null;
|
||||
state: MembershipState;
|
||||
active: boolean;
|
||||
status: ApprovalState;
|
||||
grant_type: ApprovalGrantType;
|
||||
granted_by_user_id: string | null;
|
||||
justification: string | null;
|
||||
join_seen: boolean;
|
||||
currently_authorized: boolean;
|
||||
approved_for_activation: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
active_session: ActivationSession | null;
|
||||
}
|
||||
|
||||
export 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 {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -2054,6 +2243,46 @@ export interface ActivationSession {
|
||||
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 {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
|
||||
@@ -19,8 +19,12 @@ import {
|
||||
KeyRound,
|
||||
Link2,
|
||||
Unlink,
|
||||
Award,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
FileKey,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -56,7 +60,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
@@ -99,6 +103,7 @@ function RoleBadge({ role }: { role: string }) {
|
||||
export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// User list
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
@@ -162,6 +167,11 @@ export default function AdminUsersPage() {
|
||||
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||
setIsLoading(true);
|
||||
@@ -203,12 +213,16 @@ export default function AdminUsersPage() {
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
setIsSshCertsLoading(true);
|
||||
setIsDrawerLoading(true);
|
||||
try {
|
||||
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||
const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([
|
||||
api.admin.getUser(user.id),
|
||||
api.admin.getUserMfa(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 (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
|
||||
@@ -216,10 +230,18 @@ export default function AdminUsersPage() {
|
||||
setUserLinkedAccounts(linkedData.value.linked_accounts);
|
||||
setTotalAuthMethods(linkedData.value.total_auth_methods);
|
||||
}
|
||||
if (certsData.status === "fulfilled") {
|
||||
setUserSshCerts(certsData.value.certificates);
|
||||
setSshCertsCount(certsData.value.count);
|
||||
} else {
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
setIsSshCertsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -929,6 +951,70 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
</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 */}
|
||||
{selectedUser.id !== currentUser?.id && (
|
||||
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Download,
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
X,
|
||||
Globe,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
@@ -123,6 +125,7 @@ const ACTION_FILTER_OPTIONS = [
|
||||
export default function SystemAuditPage() {
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState(false);
|
||||
const [isAdminView, setIsAdminView] = useState(false);
|
||||
@@ -132,6 +135,8 @@ export default function SystemAuditPage() {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
const [successFilter, setSuccessFilter] = useState("all");
|
||||
const [userFilter, setUserFilter] = useState<string | null>(null);
|
||||
const [userFilterLabel, setUserFilterLabel] = useState<string | null>(null);
|
||||
|
||||
// pagination
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -156,6 +161,7 @@ export default function SystemAuditPage() {
|
||||
};
|
||||
if (actionFilter !== "all") params.action = actionFilter;
|
||||
if (successFilter !== "all") params.success = successFilter;
|
||||
if (userFilter) params.user_id = userFilter;
|
||||
if (debouncedSearch) params.q = debouncedSearch;
|
||||
|
||||
const resp = await api.admin.getAuditLogs(params);
|
||||
@@ -173,7 +179,7 @@ export default function SystemAuditPage() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [page, actionFilter, successFilter, debouncedSearch]);
|
||||
}, [page, actionFilter, successFilter, userFilter, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
@@ -182,7 +188,7 @@ export default function SystemAuditPage() {
|
||||
// reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [actionFilter, successFilter, debouncedSearch]);
|
||||
}, [actionFilter, successFilter, userFilter, debouncedSearch]);
|
||||
|
||||
const formatDate = (dateString: string) => formatDateTime(dateString);
|
||||
|
||||
@@ -193,6 +199,59 @@ export default function SystemAuditPage() {
|
||||
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 (
|
||||
<div className="page-container">
|
||||
{/* Header */}
|
||||
@@ -205,15 +264,25 @@ export default function SystemAuditPage() {
|
||||
: "Your account events"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchLogs()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || isLoading}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{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>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -250,6 +319,39 @@ export default function SystemAuditPage() {
|
||||
</Select>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
@@ -294,7 +396,12 @@ export default function SystemAuditPage() {
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
|
||||
@@ -323,9 +430,23 @@ export default function SystemAuditPage() {
|
||||
{/* Meta row */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{log.user?.email ? (
|
||||
<span className="font-medium text-foreground/70">{log.user.email}</span>
|
||||
<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 ? (
|
||||
<span className="font-mono">{log.user_id.slice(0, 8)}…</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
UserCheck,
|
||||
ShieldOff,
|
||||
Plus,
|
||||
Award,
|
||||
Clock,
|
||||
FileKey,
|
||||
Globe,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +33,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -38,7 +52,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -155,6 +169,236 @@ 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserManagementPage() {
|
||||
@@ -207,6 +451,16 @@ export default function UserManagementPage() {
|
||||
// Role update state
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
||||
@@ -545,6 +799,38 @@ export default function UserManagementPage() {
|
||||
};
|
||||
}, [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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
@@ -588,6 +874,10 @@ export default function UserManagementPage() {
|
||||
<TabsTrigger value="details">User Details</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
<TabsTrigger value="access">Access</TabsTrigger>
|
||||
<TabsTrigger value="certs">
|
||||
<FileKey className="w-3.5 h-3.5 mr-1.5" />
|
||||
SSH Certificates
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── User Details Tab ────────────────────────────────────────────── */}
|
||||
@@ -918,6 +1208,110 @@ export default function UserManagementPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
|
||||
@@ -969,6 +1363,34 @@ export default function UserManagementPage() {
|
||||
</DialogContent>
|
||||
</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 ──────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={showAddKey}
|
||||
|
||||
@@ -362,6 +362,147 @@ $ systemctl restart sshd`}
|
||||
</div>
|
||||
</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 */}
|
||||
<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">
|
||||
|
||||
+432
-140
@@ -11,11 +11,12 @@ import {
|
||||
Loader2,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Skull,
|
||||
Activity,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -57,7 +58,7 @@ import {
|
||||
api,
|
||||
ApiError,
|
||||
UserNetworkApproval,
|
||||
ActivationSession,
|
||||
AdminSession,
|
||||
KillSwitchEvent,
|
||||
PortalNetwork,
|
||||
OrganizationMember,
|
||||
@@ -92,13 +93,25 @@ function formatExpiry(d: string | null | undefined) {
|
||||
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 }) {
|
||||
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending" },
|
||||
approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: <CheckCircle className="w-3 h-3 mr-1" />, label: "Approved" },
|
||||
rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Rejected" },
|
||||
revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Revoked" },
|
||||
suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: <AlertTriangle className="w-3 h-3 mr-1" />, label: "Suspended" },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
};
|
||||
const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state };
|
||||
return (
|
||||
@@ -114,7 +127,7 @@ export default function AccessPage() {
|
||||
|
||||
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
|
||||
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
|
||||
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
|
||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
||||
@@ -123,17 +136,16 @@ export default function AccessPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
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 [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [revokeId, setRevokeId] = useState<string | null>(null);
|
||||
const [unsuspendId, setUnsuspendId] = useState<string | null>(null);
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
|
||||
const [showAssign, setShowAssign] = useState(false);
|
||||
const [assignUserId, setAssignUserId] = useState("");
|
||||
const [assignNetworkId, setAssignNetworkId] = useState("");
|
||||
const [assignJustification, setAssignJustification] = useState("");
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const [rejectConfirmId, setRejectConfirmId] = useState<string | null>(null);
|
||||
|
||||
const [showKillSwitch, setShowKillSwitch] = useState(false);
|
||||
const [killTargetUserId, setKillTargetUserId] = useState("");
|
||||
@@ -143,7 +155,8 @@ export default function AccessPage() {
|
||||
const [killError, setKillError] = useState<string | null>(null);
|
||||
|
||||
const [endSessionId, setEndSessionId] = useState<string | null>(null);
|
||||
const [isEndingSession, setIsEndingSession] = useState(false);
|
||||
const [showEndSessionConfirm, setShowEndSessionConfirm] = useState(false);
|
||||
const [endSessionTarget, setEndSessionTarget] = useState<AdminSession | null>(null);
|
||||
|
||||
const [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
|
||||
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
|
||||
@@ -164,7 +177,7 @@ export default function AccessPage() {
|
||||
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
||||
api.zerotier.listPendingApprovals(orgId),
|
||||
api.zerotier.adminListAllApprovals(orgId),
|
||||
api.zerotier.listSessions(orgId),
|
||||
api.zerotier.adminListSessions(orgId),
|
||||
api.zerotier.listNetworks(orgId),
|
||||
api.organizations.getMembers(orgId),
|
||||
api.zerotier.adminListAllMemberships(orgId),
|
||||
@@ -188,6 +201,21 @@ export default function AccessPage() {
|
||||
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) => {
|
||||
if (!orgId) return;
|
||||
setApproveId(approvalId);
|
||||
@@ -233,26 +261,21 @@ export default function AccessPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
const handleUnsuspend = async (approval: UserNetworkApproval) => {
|
||||
if (!orgId) return;
|
||||
setAssignError(null);
|
||||
if (!assignUserId) { setAssignError("Please select a user."); return; }
|
||||
if (!assignNetworkId) { setAssignError("Please select a network."); return; }
|
||||
setIsAssigning(true);
|
||||
setUnsuspendId(approval.id);
|
||||
try {
|
||||
await api.zerotier.assignAccess(orgId, {
|
||||
target_user_id: assignUserId,
|
||||
portal_network_id: assignNetworkId,
|
||||
justification: assignJustification.trim() || undefined,
|
||||
target_user_id: approval.user_id,
|
||||
portal_network_id: approval.portal_network_id,
|
||||
justification: "Reinstating suspended membership",
|
||||
});
|
||||
toast({ title: "Access assigned", description: "The user can now register devices for this network." });
|
||||
setShowAssign(false);
|
||||
setAssignUserId(""); setAssignNetworkId(""); setAssignJustification("");
|
||||
toast({ title: "Access restored", description: "The user's access has been reinstated." });
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setAssignError(err instanceof ApiError ? err.message : "Failed to assign access.");
|
||||
toast({ variant: "destructive", title: "Failed to restore access", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
setUnsuspendId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,18 +301,42 @@ export default function AccessPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndSession = async (sessionId: string) => {
|
||||
if (!orgId) return;
|
||||
const handleEndSession = (session: AdminSession) => {
|
||||
setEndSessionTarget(session);
|
||||
setShowEndSessionConfirm(true);
|
||||
};
|
||||
|
||||
const handleEndSessionConfirm = async () => {
|
||||
if (!orgId || !endSessionTarget) return;
|
||||
const sessionId = endSessionTarget.id;
|
||||
setEndSessionId(sessionId);
|
||||
setIsEndingSession(true);
|
||||
setShowEndSessionConfirm(false);
|
||||
try {
|
||||
await api.zerotier.endSession(orgId, sessionId);
|
||||
toast({ title: "Session ended" });
|
||||
fetchData();
|
||||
const res = await api.zerotier.adminEndSession(orgId, sessionId);
|
||||
setSessions((prev) =>
|
||||
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) {
|
||||
toast({ variant: "destructive", title: "Failed to end session", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
||||
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();
|
||||
} finally {
|
||||
setEndSessionId(null);
|
||||
setEndSessionTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -344,9 +391,6 @@ export default function AccessPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredSessions = sessions.filter((s) => s.is_active);
|
||||
const activeSessions = filteredSessions;
|
||||
|
||||
const getNetworkName = (networkId: string) => {
|
||||
return networks.find((n) => n.id === networkId)?.name ?? networkId;
|
||||
};
|
||||
@@ -356,10 +400,69 @@ export default function AccessPage() {
|
||||
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 (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Access Control</h1>
|
||||
<h1 className="page-title">ZeroTier Access</h1>
|
||||
<p className="page-description">Manage network access requests, approvals, and active sessions</p>
|
||||
</div>
|
||||
|
||||
@@ -382,9 +485,6 @@ export default function AccessPage() {
|
||||
{networks.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={() => setShowAssign(true)} className="gap-2">
|
||||
<UserPlus className="w-4 h-4" /> Assign Access
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setShowKillSwitch(true)} className="gap-2">
|
||||
<Skull className="w-4 h-4" /> Kill Switch
|
||||
</Button>
|
||||
@@ -444,7 +544,7 @@ export default function AccessPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
||||
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
||||
<ApprovalStateBadge state={approval.state} />
|
||||
<ApprovalStateBadge state={approval.status} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
||||
@@ -510,12 +610,34 @@ export default function AccessPage() {
|
||||
<Zap className="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium font-mono truncate">{session.device_network_membership_id}</p>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium truncate">{session.user?.full_name || session.user?.email || "Unknown user"}</p>
|
||||
{session.user?.email && (
|
||||
<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 className="text-green-600 font-medium flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatExpiry(session.expires_at)}
|
||||
{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>
|
||||
@@ -523,7 +645,7 @@ export default function AccessPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
|
||||
onClick={() => handleEndSession(session.id)}
|
||||
onClick={() => handleEndSession(session)}
|
||||
disabled={endSessionId === session.id}
|
||||
>
|
||||
{endSessionId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||
@@ -541,10 +663,25 @@ export default function AccessPage() {
|
||||
<TabsContent value="approvals">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
All Approvals
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
All Approvals
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
@@ -553,41 +690,190 @@ export default function AccessPage() {
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
) : approvals.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No approvals found.</div>
|
||||
) : filteredApprovals.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
{approvalStateFilter !== "all" || search || selectedNetworkFilter !== "all"
|
||||
? "No approvals match your filters."
|
||||
: "No approvals found."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{approvals.map((approval) => (
|
||||
<div key={approval.id} className="flex items-center gap-4 p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
|
||||
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
|
||||
<ApprovalStateBadge state={approval.state} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
|
||||
{approval.justification && ` — "${approval.justification}"`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(approval.created_at)}
|
||||
{approval.granted_by_user_id && ` · Granted by: ${getUserDisplay(approval.granted_by_user_id)}`}
|
||||
</p>
|
||||
</div>
|
||||
{(approval.state === "approved" || approval.state === "suspended") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50 gap-1 flex-shrink-0"
|
||||
onClick={() => handleRevoke(approval.id)}
|
||||
disabled={revokeId === approval.id || isApproving}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-3 font-medium">
|
||||
<button onClick={() => handleSort("user")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
||||
User {getSortIndicator("user")}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium">
|
||||
<button onClick={() => handleSort("device")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
||||
Device {getSortIndicator("device")}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium">
|
||||
<button onClick={() => handleSort("network")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
||||
Network {getSortIndicator("network")}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium">
|
||||
<button onClick={() => handleSort("status")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
||||
Status {getSortIndicator("status")}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium">
|
||||
<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))
|
||||
}
|
||||
>
|
||||
{revokeId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{((approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
|
||||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id || unsuspendId === approval.id))) ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -720,58 +1006,6 @@ export default function AccessPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Assign Access Dialog */}
|
||||
<Dialog open={showAssign} onOpenChange={(open) => { if (!open) setShowAssign(false); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Network Access</DialogTitle>
|
||||
<DialogDescription>Grant a user direct access to a network without a request.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>User *</Label>
|
||||
<Select value={assignUserId} onValueChange={setAssignUserId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a user…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{orgMembers.map((m) => (
|
||||
<SelectItem key={m.user_id} value={m.user_id}>
|
||||
{m.user?.full_name || m.user?.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Network *</Label>
|
||||
<Select value={assignNetworkId} onValueChange={setAssignNetworkId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a network…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{networks.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Justification (optional)</Label>
|
||||
<Input
|
||||
placeholder="Engineering team access"
|
||||
value={assignJustification}
|
||||
onChange={(e) => setAssignJustification(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{assignError && <p className="text-sm text-destructive">{assignError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAssign(false)} disabled={isAssigning}>Cancel</Button>
|
||||
<Button onClick={handleAssign} disabled={isAssigning}>
|
||||
{isAssigning && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Assign Access
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Kill Switch Dialog */}
|
||||
<Dialog open={showKillSwitch} onOpenChange={(open) => { if (!open) setShowKillSwitch(false); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -781,7 +1015,7 @@ export default function AccessPage() {
|
||||
Kill Switch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Instantly deactivate all active sessions for a user across all managed networks. This cannot be undone.
|
||||
Instantly deactivate all active sessions for a user across all managed networks.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -789,7 +1023,7 @@ export default function AccessPage() {
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks.
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -835,6 +1069,64 @@ export default function AccessPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
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 [isLinking, setIsLinking] = useState(false);
|
||||
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
||||
const [formData, setFormData] = useState({ name: "", description: "", can_sudo: false });
|
||||
const [formData, setFormData] = useState({ name: "", description: "" });
|
||||
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
|
||||
const [expandedMembers, setExpandedMembers] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -505,10 +505,9 @@ export default function DepartmentsPage() {
|
||||
const dept = await api.organizations.createDepartment(
|
||||
orgId,
|
||||
formData.name,
|
||||
formData.description || undefined,
|
||||
formData.can_sudo
|
||||
formData.description || undefined
|
||||
);
|
||||
setFormData({ name: "", description: "", can_sudo: false });
|
||||
setFormData({ name: "", description: "" });
|
||||
setIsCreateDialogOpen(false);
|
||||
await fetchDepartments(orgId);
|
||||
} catch (err) {
|
||||
@@ -523,9 +522,8 @@ export default function DepartmentsPage() {
|
||||
await api.organizations.updateDepartment(orgId, editingDept.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
can_sudo: formData.can_sudo,
|
||||
});
|
||||
setFormData({ name: "", description: "", can_sudo: false });
|
||||
setFormData({ name: "", description: "" });
|
||||
setEditingDept(null);
|
||||
setIsEditDialogOpen(false);
|
||||
await fetchDepartments(orgId);
|
||||
@@ -548,7 +546,7 @@ export default function DepartmentsPage() {
|
||||
|
||||
const openEditDialog = (dept: Department) => {
|
||||
setEditingDept(dept);
|
||||
setFormData({ name: dept.name, description: dept.description || "", can_sudo: dept.can_sudo || false });
|
||||
setFormData({ name: dept.name, description: dept.description || "" });
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -574,7 +572,7 @@ export default function DepartmentsPage() {
|
||||
Manage departments and organize team members
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => { setFormData({ name: "", description: "", can_sudo: false }); setIsCreateDialogOpen(true); }}>
|
||||
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Department
|
||||
</Button>
|
||||
@@ -621,11 +619,7 @@ export default function DepartmentsPage() {
|
||||
<p className="font-medium text-foreground">
|
||||
{dept.name}
|
||||
</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>
|
||||
{dept.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -758,18 +752,6 @@ export default function DepartmentsPage() {
|
||||
rows={3}
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
@@ -811,18 +793,6 @@ export default function DepartmentsPage() {
|
||||
rows={3}
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||
|
||||
+300
-223
@@ -40,13 +40,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -61,7 +55,7 @@ import {
|
||||
ApiError,
|
||||
Device,
|
||||
DeviceNetworkMembership,
|
||||
ActivationSession,
|
||||
UserSession,
|
||||
MembershipState,
|
||||
PortalNetwork,
|
||||
UserNetworkApproval,
|
||||
@@ -114,6 +108,64 @@ 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 }) {
|
||||
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" },
|
||||
@@ -143,7 +195,7 @@ export default function DevicesPage() {
|
||||
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
|
||||
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -159,9 +211,7 @@ export default function DevicesPage() {
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [regError, setRegError] = useState<string | null>(null);
|
||||
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
||||
const [deviceMemberships, setDeviceMemberships] = useState<DeviceNetworkMembership[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
const [expandedDeviceId, setExpandedDeviceId] = useState<string | null>(null);
|
||||
|
||||
const [editDevice, setEditDevice] = useState<Device | null>(null);
|
||||
const [editNickname, setEditNickname] = useState("");
|
||||
@@ -196,7 +246,7 @@ export default function DevicesPage() {
|
||||
const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([
|
||||
api.zerotier.listDevices(orgId),
|
||||
api.zerotier.listMemberships(orgId),
|
||||
api.zerotier.listSessions(orgId),
|
||||
api.zerotier.listUserSessions(orgId),
|
||||
api.zerotier.listNetworks(orgId),
|
||||
api.zerotier.listMyApprovals(orgId),
|
||||
]);
|
||||
@@ -218,24 +268,20 @@ export default function DevicesPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openDeviceDrawer = async (device: Device) => {
|
||||
setSelectedDevice(device);
|
||||
setIsDrawerLoading(true);
|
||||
setDeviceMemberships([]);
|
||||
const refreshSessions = useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
try {
|
||||
const deviceMem = memberships.filter((m) => m.device_id === device.id);
|
||||
setDeviceMemberships(deviceMem);
|
||||
const sessionsRes = await api.zerotier.listUserSessions(orgId);
|
||||
setSessions(sessionsRes.sessions || []);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
// silent
|
||||
}
|
||||
};
|
||||
}, [orgId]);
|
||||
|
||||
const closeDrawer = () => {
|
||||
setSelectedDevice(null);
|
||||
setDeviceMemberships([]);
|
||||
};
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => refreshSessions(), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshSessions]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!orgId) return;
|
||||
@@ -403,14 +449,19 @@ export default function DevicesPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const getActiveSession = (membershipId: string): ActivationSession | null => {
|
||||
return sessions.find((s) => s.device_network_membership_id === membershipId && s.is_active) ?? null;
|
||||
const getActiveSession = (deviceId: string, networkId: string): UserSession | null => {
|
||||
return sessions.find((s) => s.device?.id === deviceId && s.network?.id === networkId && s.is_active) ?? null;
|
||||
};
|
||||
|
||||
const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => {
|
||||
return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? null;
|
||||
};
|
||||
|
||||
const 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 => {
|
||||
return myApprovals.find((a) => a.portal_network_id === networkId) ?? null;
|
||||
};
|
||||
@@ -427,7 +478,7 @@ export default function DevicesPage() {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">ZeroTier Access</h1>
|
||||
<h1 className="page-title">ZeroTier Devices</h1>
|
||||
<p className="page-description">Manage your devices, networks, and access requests</p>
|
||||
</div>
|
||||
|
||||
@@ -477,7 +528,7 @@ export default function DevicesPage() {
|
||||
Registered Devices
|
||||
{!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>Click a device to view memberships and activation status</CardDescription>
|
||||
<CardDescription>Click a device to expand its details and network memberships</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -495,67 +546,168 @@ export default function DevicesPage() {
|
||||
<div className="divide-y">
|
||||
{filteredDevices.map((device) => {
|
||||
const activeCount = memberships.filter(
|
||||
(m) => m.device_id === device.id && m.currently_authorized
|
||||
(m) => m.device_id === device.id && m.active
|
||||
).length;
|
||||
const isExpanded = expandedDeviceId === device.id;
|
||||
const deviceMemberships = memberships.filter((m) => m.device_id === device.id);
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => openDeviceDrawer(device)}
|
||||
>
|
||||
<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} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{device.device_nickname || device.hostname || device.node_id}
|
||||
</p>
|
||||
{device.device_nickname && device.hostname && (
|
||||
<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 key={device.id}>
|
||||
<button
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setExpandedDeviceId(isExpanded ? null : device.id)}
|
||||
>
|
||||
<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} />
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openDeviceDrawer(device); }}>
|
||||
<ChevronRight className="w-4 h-4 mr-2" /> View memberships
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditDevice(device);
|
||||
setEditNickname(device.device_nickname || "");
|
||||
setEditHostname(device.hostname || "");
|
||||
}}>
|
||||
<Pencil className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{device.device_nickname || device.hostname || device.node_id}
|
||||
</p>
|
||||
{device.device_nickname && device.hostname && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setExpandedDeviceId(isExpanded ? null : device.id); }}>
|
||||
<ChevronRight className="w-4 h-4 mr-2" /> View memberships
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditDevice(device);
|
||||
setEditNickname(device.device_nickname || "");
|
||||
setEditHostname(device.hostname || "");
|
||||
}}>
|
||||
<Pencil className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChevronRight className={cn("w-4 h-4 text-muted-foreground flex-shrink-0 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t px-4 pb-4 pt-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{device.hostname && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Hostname</span>
|
||||
<span>{device.hostname}</span>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
@@ -577,17 +729,56 @@ export default function DevicesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{sessions.filter((s) => s.is_active).map((session) => (
|
||||
<div key={session.id} className="flex items-center justify-between text-sm p-2 border rounded">
|
||||
<span className="text-muted-foreground font-mono">{session.device_network_membership_id}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">Expires: {formatExpiry(session.expires_at)}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDeactivate(session.id)} disabled={deactivatingId === session.id}>
|
||||
{deactivatingId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||
</Button>
|
||||
{sessions.filter((s) => s.is_active).map((session) => {
|
||||
const membership = getMembershipBySession(session);
|
||||
return (
|
||||
<div key={session.id} className="flex items-center gap-4 p-3 border rounded">
|
||||
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -653,7 +844,7 @@ export default function DevicesPage() {
|
||||
<p className="text-sm text-muted-foreground font-mono">{network.zerotier_network_id}</p>
|
||||
{approval && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<ApprovalStateBadge state={approval.state} />
|
||||
<ApprovalStateBadge state={approval.status} />
|
||||
{approval.justification && (
|
||||
<span className="text-xs text-muted-foreground">"{approval.justification}"</span>
|
||||
)}
|
||||
@@ -661,7 +852,7 @@ export default function DevicesPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{network.request_mode === "open" && !hasMembership && (
|
||||
{network.request_mode === "open" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -675,7 +866,7 @@ export default function DevicesPage() {
|
||||
<Plus className="w-3 h-3" /> Join
|
||||
</Button>
|
||||
)}
|
||||
{network.request_mode === "approval_required" && !hasMembership && (
|
||||
{network.request_mode === "approval_required" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -711,10 +902,11 @@ export default function DevicesPage() {
|
||||
{devices.find((d) => d.id === m.device_id)?.device_nickname ||
|
||||
devices.find((d) => d.id === m.device_id)?.node_id}
|
||||
</span>
|
||||
<MembershipStateBadge state={m.state} />
|
||||
<ApprovedBadge approved={m.status === "approved"} />
|
||||
<ActiveBadge active={m.active} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{m.approved_for_activation && !m.currently_authorized && (
|
||||
{m.status === "approved" && !m.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -726,7 +918,7 @@ export default function DevicesPage() {
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{m.currently_authorized && (
|
||||
{m.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -798,7 +990,7 @@ export default function DevicesPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<p className="font-medium truncate">{network?.name || approval.portal_network_id}</p>
|
||||
<Badge variant="outline" className="text-xs">{network?.environment}</Badge>
|
||||
<ApprovalStateBadge state={approval.state} />
|
||||
<ApprovalStateBadge state={approval.status} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{approval.grant_type === "requested" ? "You requested" : "Assigned by admin"}
|
||||
@@ -814,14 +1006,14 @@ export default function DevicesPage() {
|
||||
const dev = devices.find((d) => d.id === m.device_id);
|
||||
return (
|
||||
<Badge key={m.id} variant="outline" className="text-xs">
|
||||
{dev?.device_nickname || dev?.node_id}: <MembershipStateBadge state={m.state} />
|
||||
{dev?.device_nickname || dev?.node_id}: {m.active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{approval.state === "pending" && (
|
||||
{approval.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1038,122 +1230,7 @@ export default function DevicesPage() {
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
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,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Network,
|
||||
Plus,
|
||||
@@ -7,14 +8,10 @@ import {
|
||||
MoreHorizontal,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Monitor,
|
||||
Clock,
|
||||
Shield,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Ban,
|
||||
Zap,
|
||||
Download,
|
||||
@@ -55,7 +52,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -63,8 +59,6 @@ import {
|
||||
ApiError,
|
||||
AvailableZtNetwork,
|
||||
PortalNetwork,
|
||||
DeviceNetworkMembership,
|
||||
UserNetworkApproval,
|
||||
NetworkEnvironment,
|
||||
NetworkRequestMode,
|
||||
} from "@/lib/api";
|
||||
@@ -119,6 +113,7 @@ function cn(...classes: (string | boolean | undefined | null)[]) {
|
||||
export default function NetworksPage() {
|
||||
const { orgId } = useCurrentOrganizationId();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -136,11 +131,6 @@ export default function NetworksPage() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<PortalNetwork | null>(null);
|
||||
const [networkMembers, setNetworkMembers] = useState<DeviceNetworkMembership[]>([]);
|
||||
const [networkRequests, setNetworkRequests] = useState<UserNetworkApproval[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
|
||||
const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
@@ -180,31 +170,6 @@ export default function NetworksPage() {
|
||||
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 () => {
|
||||
if (!orgId) return;
|
||||
setShowZtPicker(true);
|
||||
@@ -326,7 +291,7 @@ export default function NetworksPage() {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Networks</h1>
|
||||
<h1 className="page-title">ZeroTier Networks</h1>
|
||||
<p className="page-description">Manage ZeroTier portal networks and monitor access</p>
|
||||
</div>
|
||||
|
||||
@@ -355,7 +320,7 @@ export default function NetworksPage() {
|
||||
Portal Networks
|
||||
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>Click a network to view members, requests, and manage access</CardDescription>
|
||||
<CardDescription>Click a network to manage members, devices, and access requests</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -375,7 +340,7 @@ export default function NetworksPage() {
|
||||
<button
|
||||
key={network.id}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => openNetworkDrawer(network)}
|
||||
onClick={() => navigate(`/org/zerotier/networks/${network.id}`)}
|
||||
>
|
||||
<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" />
|
||||
@@ -410,7 +375,7 @@ export default function NetworksPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}>
|
||||
<Eye className="w-4 h-4 mr-2" /> View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
|
||||
@@ -696,118 +661,6 @@ export default function NetworksPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Network Detail Drawer */}
|
||||
<Sheet open={!!selectedNetwork} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
{selectedNetwork && (
|
||||
<>
|
||||
<SheetHeader className="mb-4">
|
||||
<SheetTitle className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Network className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
{selectedNetwork.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono">{selectedNetwork.zerotier_network_id}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<EnvironmentBadge env={selectedNetwork.environment} />
|
||||
<RequestModeBadge mode={selectedNetwork.request_mode} />
|
||||
</div>
|
||||
{selectedNetwork.description && (
|
||||
<p className="text-sm text-muted-foreground">{selectedNetwork.description}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Default activation</span>
|
||||
<p className="font-medium">{selectedNetwork.default_activation_lifetime_minutes} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Max activation</span>
|
||||
<p className="font-medium">{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Approved users</span>
|
||||
<p className="font-medium flex items-center gap-1"><Users className="w-3 h-3" />{selectedNetwork.approved_user_count ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Active devices</span>
|
||||
<p className="font-medium flex items-center gap-1 text-green-600"><Zap className="w-3 h-3" />{selectedNetwork.active_membership_count ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDrawerLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="members" className="w-full">
|
||||
<TabsList className="mb-3">
|
||||
<TabsTrigger value="members">
|
||||
Members ({networkMembers.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="requests">
|
||||
Requests ({networkRequests.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="members">
|
||||
{networkMembers.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">No members yet.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{networkMembers.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{m.device_id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{m.currently_authorized ? (
|
||||
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-xs text-green-600">Authorized</span></>
|
||||
) : (
|
||||
<><XCircle className="w-4 h-4 text-muted-foreground" /><span className="text-xs text-muted-foreground">Inactive</span></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests">
|
||||
{networkRequests.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">No pending requests.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{networkRequests.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
|
||||
<Clock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{r.user_id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{r.grant_type} · {r.state}
|
||||
</p>
|
||||
{r.justification && <p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ export default function OIDCClientsPage() {
|
||||
// Generic form
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const urisRef = useRef<HTMLTextAreaElement>(null);
|
||||
const corsRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Proxy form
|
||||
const proxyNameRef = useRef<HTMLInputElement>(null);
|
||||
@@ -131,6 +132,7 @@ export default function OIDCClientsPage() {
|
||||
const [editingClient, setEditingClient] = useState<OIDCClient | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editUris, setEditUris] = useState("");
|
||||
const [editCors, setEditCors] = useState("");
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,10 +151,16 @@ export default function OIDCClientsPage() {
|
||||
let uris: string[];
|
||||
let proxyHost: string | undefined;
|
||||
|
||||
let corsOrigins: string[] | null = null;
|
||||
|
||||
if (dialogMode === "generic") {
|
||||
name = nameRef.current?.value.trim() ?? "";
|
||||
uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||
if (!name || !uris.length) return;
|
||||
const corsRaw = (corsRef.current?.value ?? "").trim();
|
||||
if (corsRaw) {
|
||||
corsOrigins = corsRaw.split(/[\n,]+/).map((o) => o.trim()).filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
name = proxyNameRef.current?.value.trim() ?? "";
|
||||
proxyHost = proxyHostRef.current?.value.trim() ?? "";
|
||||
@@ -166,7 +174,7 @@ export default function OIDCClientsPage() {
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await api.organizations.createClient(orgId, name, uris);
|
||||
const result = await api.organizations.createClient(orgId, name, uris, corsOrigins);
|
||||
const created = result.client as OIDCClientWithSecret;
|
||||
setClients((prev) => [...prev, created]);
|
||||
setNewSecret({
|
||||
@@ -202,6 +210,7 @@ export default function OIDCClientsPage() {
|
||||
setEditingClient(client);
|
||||
setEditName(client.name);
|
||||
setEditUris((client.redirect_uris ?? []).join("\n"));
|
||||
setEditCors((client.allowed_cors_origins ?? []).join("\n"));
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
@@ -210,9 +219,14 @@ export default function OIDCClientsPage() {
|
||||
const uris = editUris.split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||
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);
|
||||
try {
|
||||
const result = await api.organizations.updateClient(orgId, editingClient.id, { name, redirect_uris: uris });
|
||||
const result = await api.organizations.updateClient(orgId, editingClient.id, { name, redirect_uris: uris, allowed_cors_origins: corsOrigins });
|
||||
setClients((prev) =>
|
||||
prev.map((c) => (c.id === editingClient.id ? result.client : c))
|
||||
);
|
||||
@@ -390,6 +404,16 @@ export default function OIDCClientsPage() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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">
|
||||
<span>Created {new Date(client.created_at).toLocaleDateString()}</span>
|
||||
<span>
|
||||
@@ -439,6 +463,19 @@ export default function OIDCClientsPage() {
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">One URI per line</p>
|
||||
</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>
|
||||
|
||||
{/* oauth2-proxy tab */}
|
||||
@@ -521,7 +558,7 @@ export default function OIDCClientsPage() {
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit OIDC Client</DialogTitle>
|
||||
<DialogDescription>Update the client name and redirect URIs.</DialogDescription>
|
||||
<DialogDescription>Update the client name, redirect URIs, and CORS origins.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
@@ -544,6 +581,20 @@ export default function OIDCClientsPage() {
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">One URI per line</p>
|
||||
</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 && (
|
||||
<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>
|
||||
|
||||
+233
-20
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Search, Filter, RefreshCw, ChevronLeft, ChevronRight,
|
||||
Download, Globe, Lock, Search, Filter, RefreshCw, ChevronLeft, ChevronRight,
|
||||
LogIn, Key, UserPlus, Shield, Settings,
|
||||
AlertTriangle, Terminal, Loader2,
|
||||
AlertTriangle, Terminal, Loader2, X,
|
||||
CheckCircle2, XCircle, Link2, UserCog,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api, AuditLogEntry } from "@/lib/api";
|
||||
import { api, AuditLogEntry, ApiError, OrganizationMember } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
import { formatDateTime } from "@/lib/date";
|
||||
|
||||
@@ -155,6 +155,16 @@ const ACTION_FILTER_OPTIONS = [
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
function CertDetail({ metadata }: { metadata?: Record<string, unknown> | null }) {
|
||||
@@ -182,11 +192,20 @@ export default function OrgAuditPage() {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [actionFilter, setActionFilter] = 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 [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// debounce search
|
||||
@@ -196,12 +215,22 @@ export default function OrgAuditPage() {
|
||||
}, [search]);
|
||||
|
||||
// reset page on filter change
|
||||
useEffect(() => { setPage(1); }, [actionFilter, successFilter, debouncedSearch]);
|
||||
useEffect(() => { setPage(1); }, [actionFilter, successFilter, userFilter, debouncedSearch, viewMode, selectedUserId]);
|
||||
|
||||
// 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 () => {
|
||||
if (!orgId) { setIsLoading(false); return; }
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setAccessDenied(false);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
page: String(page),
|
||||
@@ -209,39 +238,157 @@ export default function OrgAuditPage() {
|
||||
};
|
||||
if (actionFilter !== "all") params.action = actionFilter;
|
||||
if (successFilter !== "all") params.success = successFilter;
|
||||
if (userFilter) params.user_id = userFilter;
|
||||
if (debouncedSearch) params.q = debouncedSearch;
|
||||
|
||||
const resp = await api.organizations.getAuditLogs(orgId, params);
|
||||
setAuditLogs(resp.audit_logs ?? []);
|
||||
setTotalCount(resp.count ?? 0);
|
||||
setTotalPages(resp.pages ?? 1);
|
||||
if (viewMode === "user") {
|
||||
if (!selectedUserId) { setIsLoading(false); return; }
|
||||
const resp = await api.superadmin.getUserAuditLogs(selectedUserId, params);
|
||||
setAuditLogs(resp.audit_logs ?? []);
|
||||
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) {
|
||||
console.error("Failed to fetch org audit logs:", err);
|
||||
setError("Failed to load audit logs. Please try again.");
|
||||
if (err instanceof ApiError && err.code === 403) {
|
||||
setAccessDenied(true);
|
||||
} else {
|
||||
console.error("Failed to fetch audit logs:", err);
|
||||
setError("Failed to load audit logs. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [orgId, page, actionFilter, successFilter, debouncedSearch]);
|
||||
}, [orgId, page, actionFilter, successFilter, userFilter, debouncedSearch, viewMode, selectedUserId]);
|
||||
|
||||
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 (
|
||||
<div className="page-container">
|
||||
{/* Header */}
|
||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title">Org Audit Log</h1>
|
||||
<h1 className="page-title">Admin Audit Log</h1>
|
||||
<p className="page-description">
|
||||
All organisation activity — user events, admin actions, policy changes
|
||||
{viewMode === "user"
|
||||
? `User events for ${selectedUserLabel ?? "selected user"}`
|
||||
: "Organisation activity — user events, admin actions, policy changes"
|
||||
}
|
||||
{totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={isLoading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting || isLoading}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{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>
|
||||
</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 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
@@ -276,6 +423,39 @@ export default function OrgAuditPage() {
|
||||
</Select>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
@@ -284,11 +464,25 @@ export default function OrgAuditPage() {
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading…</span>
|
||||
</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 ? (
|
||||
<div className="py-12 text-center text-destructive">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto mb-2" />
|
||||
<p>{error}</p>
|
||||
</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 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No audit events match the current filters.
|
||||
@@ -313,7 +507,12 @@ export default function OrgAuditPage() {
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
|
||||
@@ -338,9 +537,23 @@ export default function OrgAuditPage() {
|
||||
{/* Actor / meta row */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{log.user?.email ? (
|
||||
<span className="font-medium text-foreground/70">{log.user.email}</span>
|
||||
<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 ? (
|
||||
<span className="font-mono">{log.user_id.slice(0, 8)}…</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -47,13 +47,43 @@ export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardPro
|
||||
const isSystem = !!ca.is_system;
|
||||
|
||||
// ── User CA: server trusts this public key so it accepts user certs ──────
|
||||
const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
|
||||
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca
|
||||
const userCaServerSnippet = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# /etc/ssh/sshd_config (add once, then reload sshd):
|
||||
TrustedUserCAKeys /etc/ssh/trusted_user_ca
|
||||
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
|
||||
# Create /etc/ssh/auth_principals/<unix-user> containing one principal per line.`;
|
||||
CA_KEY='${ca.public_key.trim()}'
|
||||
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`;
|
||||
|
||||
// ── 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:
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useOrg } from "@/contexts/OrgContext";
|
||||
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
|
||||
import { formatDate as _formatDate } from "@/lib/date";
|
||||
|
||||
@@ -87,6 +88,7 @@ function CopyButton({ text }: { text: string }) {
|
||||
|
||||
export default function SSHKeysPage() {
|
||||
const { toast } = useToast();
|
||||
const { selectedOrgId } = useOrg();
|
||||
|
||||
// Key list state
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
@@ -379,6 +381,7 @@ export default function SSHKeysPage() {
|
||||
principals.length > 0 ? principals : undefined,
|
||||
certType,
|
||||
parsedExpiry,
|
||||
selectedOrgId ?? undefined,
|
||||
);
|
||||
setCertResult(result.certificate);
|
||||
fetchCerts(); // refresh certs list
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,678 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user