From 979b5a918e3433ab386896f181f8e513ed4ad346 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Fri, 6 Mar 2026 00:22:57 +0545 Subject: [PATCH 01/12] Chore: Rebranding Gatehouse to Secuird (UI) --- .../{gatehouse-logo.svg => secuird-logo.svg} | 0 .../{GatehouseLogo.tsx => SecuirdLogo.tsx} | 8 +++--- src/components/dev/ApiDevTools.tsx | 12 ++++---- .../layouts/MfaEnforcementLayout.tsx | 2 +- src/components/layouts/PublicLayout.tsx | 8 +++--- src/components/navigation/AppSidebar.tsx | 6 ++-- .../security/TotpEnrollmentWizard.tsx | 2 +- src/config.ts | 4 +-- src/contexts/AuthContext.tsx | 2 +- src/index.css | 2 +- src/lib/api.ts | 6 ++-- src/pages/auth/ActivatePage.tsx | 4 +-- src/pages/auth/InviteAcceptPage.tsx | 4 +-- src/pages/auth/LoginPage.tsx | 28 +++++++++---------- src/pages/auth/OAuthCallbackPage.tsx | 12 ++++---- src/pages/auth/OIDCConsentPage.tsx | 8 +++--- src/pages/auth/OIDCLoginPage.tsx | 10 +++---- src/pages/auth/RegisterPage.tsx | 2 +- src/pages/org/CAsPage.tsx | 2 +- src/pages/org/OIDCClientsPage.tsx | 6 ++-- src/pages/org/ca/CADetailCard.tsx | 10 +++---- src/pages/org/ca/CASection.tsx | 2 +- src/pages/org/ca/IssueHostCertPanel.tsx | 2 +- src/pages/user/LinkedAccountsPage.tsx | 2 +- src/pages/user/SSHKeysPage.tsx | 4 +-- tsconfig.app.tsbuildinfo | 2 +- vite.config.ts | 2 +- 27 files changed, 76 insertions(+), 76 deletions(-) rename public/{gatehouse-logo.svg => secuird-logo.svg} (100%) rename src/components/branding/{GatehouseLogo.tsx => SecuirdLogo.tsx} (91%) diff --git a/public/gatehouse-logo.svg b/public/secuird-logo.svg similarity index 100% rename from public/gatehouse-logo.svg rename to public/secuird-logo.svg diff --git a/src/components/branding/GatehouseLogo.tsx b/src/components/branding/SecuirdLogo.tsx similarity index 91% rename from src/components/branding/GatehouseLogo.tsx rename to src/components/branding/SecuirdLogo.tsx index a0028e4..aa80334 100644 --- a/src/components/branding/GatehouseLogo.tsx +++ b/src/components/branding/SecuirdLogo.tsx @@ -1,21 +1,21 @@ import { cn } from "@/lib/utils"; -interface GatehouseLogoProps { +interface SecuirdLogoProps { size?: "sm" | "md" | "lg"; variant?: "default" | "light"; className?: string; } /** - * Gatehouse Logo - Abstract gate/doorway mark + * Secuird Logo - Abstract gate/doorway mark * Represents controlled entry and policy enforcement * Two vertical pillars forming a gateway with negative space */ -export function GatehouseLogo({ +export function SecuirdLogo({ size = "md", variant = "default", className -}: GatehouseLogoProps) { +}: SecuirdLogoProps) { const sizeClasses = { sm: "w-8 h-8", md: "w-9 h-9", diff --git a/src/components/dev/ApiDevTools.tsx b/src/components/dev/ApiDevTools.tsx index b42c2da..7d27842 100644 --- a/src/components/dev/ApiDevTools.tsx +++ b/src/components/dev/ApiDevTools.tsx @@ -65,9 +65,9 @@ const isDev = import.meta.env.DEV; const originalFetch = window.fetch; // Avoid patching multiple times during HMR -const globalAny = window as unknown as { __gatehouseFetchPatched?: boolean }; -if (isDev && !globalAny.__gatehouseFetchPatched) { - globalAny.__gatehouseFetchPatched = true; +const globalAny = window as unknown as { __secuirdFetchPatched?: boolean }; +if (isDev && !globalAny.__secuirdFetchPatched) { + globalAny.__secuirdFetchPatched = true; try { window.fetch = async function (input, init) { @@ -165,9 +165,9 @@ if (isDev && !globalAny.__gatehouseFetchPatched) { }; } catch (patchError) { // Log any errors during fetch patching with full stack trace - console.error("[Gatehouse DevTools] Failed to patch fetch:", patchError); + console.error("[Secuird DevTools] Failed to patch fetch:", patchError); if (patchError instanceof Error) { - console.error("[Gatehouse DevTools] Stack trace:", patchError.stack); + console.error("[Secuird DevTools] Stack trace:", patchError.stack); } } } @@ -220,7 +220,7 @@ export default function ApiDevTools() { {/* Header */}
- Gatehouse API DevTools + Secuird API DevTools {logs.length} requests diff --git a/src/components/layouts/MfaEnforcementLayout.tsx b/src/components/layouts/MfaEnforcementLayout.tsx index 29d4102..23b09db 100644 --- a/src/components/layouts/MfaEnforcementLayout.tsx +++ b/src/components/layouts/MfaEnforcementLayout.tsx @@ -97,7 +97,7 @@ export default function MfaEnforcementLayout() {
- Gatehouse + Secuird
diff --git a/src/components/layouts/PublicLayout.tsx b/src/components/layouts/PublicLayout.tsx index 5605e4d..507e530 100644 --- a/src/components/layouts/PublicLayout.tsx +++ b/src/components/layouts/PublicLayout.tsx @@ -1,5 +1,5 @@ import { Outlet, Link } from "react-router-dom"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; export default function PublicLayout() { return ( @@ -11,8 +11,8 @@ export default function PublicLayout() {
- - Gatehouse + + Secuird
@@ -28,7 +28,7 @@ export default function PublicLayout() {

- © {new Date().getFullYear()} Gatehouse. Identity & Access. + © {new Date().getFullYear()} Secuird. Identity & Access.

diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index f3ac838..19234a4 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -18,7 +18,7 @@ import { Monitor, ShieldAlert, } from "lucide-react"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; import { NavLink } from "@/components/NavLink"; import { useAuth } from "@/contexts/AuthContext"; import { @@ -90,10 +90,10 @@ export function AppSidebar() { {/* Logo */}
- + {!collapsed && ( - Gatehouse + Secuird )}
diff --git a/src/components/security/TotpEnrollmentWizard.tsx b/src/components/security/TotpEnrollmentWizard.tsx index 2120b83..3bed6cd 100644 --- a/src/components/security/TotpEnrollmentWizard.tsx +++ b/src/components/security/TotpEnrollmentWizard.tsx @@ -275,7 +275,7 @@ export function TotpEnrollmentWizard({

- Open your authenticator app and enter the 6-digit code shown for Gatehouse. + Open your authenticator app and enter the 6-digit code shown for Secuird.

diff --git a/src/config.ts b/src/config.ts index cfe8adb..0849a36 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -// Gatehouse Configuration +// Secuird Configuration // Environment-specific settings for the application export const config = { @@ -9,7 +9,7 @@ export const config = { // App metadata app: { - name: "Gatehouse", + name: "Secuird", description: "Identity & Access Platform", }, diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4eb6068..a2a8a96 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -32,7 +32,7 @@ interface AuthContextType { const AuthContext = createContext(null); // LocalStorage key for MFA compliance persistence -const MFA_COMPLIANCE_KEY = 'gatehouse_mfa_compliance'; +const MFA_COMPLIANCE_KEY = 'secuird_mfa_compliance'; // Helper to persist MFA compliance to localStorage function persistMfaCompliance(compliance: MfaComplianceSummary | null): void { diff --git a/src/index.css b/src/index.css index 43a05a4..73e6fe4 100644 --- a/src/index.css +++ b/src/index.css @@ -4,7 +4,7 @@ @tailwind components; @tailwind utilities; -/* Gatehouse Design System - Enterprise Identity & Access Platform +/* Secuird Design System - Enterprise Identity & Access Platform Authoritative, infrastructure-grade aesthetic with slate/charcoal/muted blue palette Colors are HSL for theming flexibility */ diff --git a/src/lib/api.ts b/src/lib/api.ts index f7aaa48..9e60c1c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -// API Client for Gatehouse Backend +// API Client for Secuird Backend // Uses Bearer token authentication import { config } from '@/config'; @@ -259,8 +259,8 @@ class ApiError extends Error { } // Token storage keys -const TOKEN_KEY = 'gatehouse_token'; -const TOKEN_EXPIRY_KEY = 'gatehouse_token_expiry'; +const TOKEN_KEY = 'secuird_token'; +const TOKEN_EXPIRY_KEY = 'secuird_token_expiry'; // Token management export const tokenManager = { diff --git a/src/pages/auth/ActivatePage.tsx b/src/pages/auth/ActivatePage.tsx index 50f21fe..00770c5 100644 --- a/src/pages/auth/ActivatePage.tsx +++ b/src/pages/auth/ActivatePage.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useNavigate } from "react-router-dom"; import { CheckCircle, XCircle, Loader2, Mail } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; import { api, ApiError } from "@/lib/api"; type Status = "loading" | "success" | "error" | "missing"; @@ -42,7 +42,7 @@ export default function ActivatePage() {
{/* Logo */}
- +
diff --git a/src/pages/auth/InviteAcceptPage.tsx b/src/pages/auth/InviteAcceptPage.tsx index 85d41a9..19b8db1 100644 --- a/src/pages/auth/InviteAcceptPage.tsx +++ b/src/pages/auth/InviteAcceptPage.tsx @@ -61,7 +61,7 @@ export default function InviteAcceptPage() { const result = await api.invites.accept(token, name || undefined, inviteData?.user_exists ? undefined : password); if (result.token) { // Store the token manually since we're not using the normal login flow - localStorage.setItem("gatehouse_token", result.token); + localStorage.setItem("secuird_token", result.token); } navigate("/profile"); } catch (err: unknown) { @@ -127,7 +127,7 @@ export default function InviteAcceptPage() {

Account found

-

You already have a Gatehouse account. Click below to join the organization.

+

You already have a Secuird account. Click below to join the organization.

) : ( diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index ecdfdb8..27b1c9a 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -27,8 +27,8 @@ import { OAuthProvider } from "@/lib/oauth"; type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa'; -const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; -const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); +const SECUIRD_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; +const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, ''); /** * Complete an OIDC authorization flow after the user has authenticated. @@ -36,7 +36,7 @@ const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); * the auth code and returns the redirect URL for the calling application. */ async function completeOidcFlow(oidcSessionId: string, token: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), @@ -64,13 +64,13 @@ export default function LoginPage() { const [mfaToken, setMfaToken] = useState(null); // OIDC bridge: if oidc_session_id is in the URL, we're acting as the - // login UI for an OIDC authorization flow (e.g. SecuIRD → Gatehouse). + // login UI for an OIDC authorization flow (e.g. SecuIRD → Secuird). // After successful login, call /oidc/complete and redirect to the client app. const oidcSessionId = searchParams.get('oidc_session_id'); const oidcError = searchParams.get('error'); // CLI bridge: if cli_token or cli_redirect is present the login was triggered - // by the Gatehouse CLI tool. After successful auth the token is delivered + // by the Secuird CLI tool. After successful auth the token is delivered // directly to the CLI's local callback server. const cliToken = searchParams.get('cli_token'); const cliRedirectParam = searchParams.get('cli_redirect'); @@ -81,7 +81,7 @@ export default function LoginPage() { useEffect(() => { if (!cliToken || cliFetchedRef.current) return; cliFetchedRef.current = true; - fetch(`${GATEHOUSE_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`) + fetch(`${SECUIRD_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`) .then((r) => r.json()) .then((body) => { if (body?.data?.redirect_url) { @@ -165,7 +165,7 @@ export default function LoginPage() { // MFA enrollment required - will be handled by ProtectedLayout // Navigation happens in AuthContext (MFA path always navigates) } else if (oidcSessionId) { - // OIDC bridge: send token back to the Gatehouse backend to complete the flow + // OIDC bridge: send token back to the Secuird backend to complete the flow const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } else if (cliRedirectUrl) { @@ -176,7 +176,7 @@ export default function LoginPage() { // Normal login: navigation already handled by AuthContext (skipNavigate=false) } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] Login failed:", error); + console.error("[Secuird] Login failed:", error); } const message = error instanceof ApiError @@ -246,7 +246,7 @@ export default function LoginPage() { } } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] MFA verification failed:", error); + console.error("[Secuird] MFA verification failed:", error); } const message = error instanceof ApiError @@ -294,7 +294,7 @@ export default function LoginPage() { // Normal login: navigation already handled by AuthContext (skipNavigate=false) } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] TOTP verification failed:", error); + console.error("[Secuird] TOTP verification failed:", error); } const message = error instanceof ApiError @@ -363,7 +363,7 @@ export default function LoginPage() { }); } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] Passkey login failed:", error); + console.error("[Secuird] Passkey login failed:", error); } let message = "Failed to sign in with passkey"; @@ -438,7 +438,7 @@ export default function LoginPage() { } } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] WebAuthn verification failed:", error); + console.error("[Secuird] WebAuthn verification failed:", error); } let message = "Failed to verify passkey"; @@ -518,7 +518,7 @@ export default function LoginPage() { } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] OAuth login failed:", error); + console.error("[Secuird] OAuth login failed:", error); } let message = `Failed to initiate ${provider} sign in`; @@ -939,7 +939,7 @@ export default function LoginPage() {

{cliRedirectUrl - ? "Sign in to grant the Gatehouse CLI access to your account" + ? "Sign in to grant the Secuird CLI access to your account" : oidcSessionId ? "An application is requesting access to your account" : "Sign in to your account to continue"} diff --git a/src/pages/auth/OAuthCallbackPage.tsx b/src/pages/auth/OAuthCallbackPage.tsx index a2a7332..ef5c317 100644 --- a/src/pages/auth/OAuthCallbackPage.tsx +++ b/src/pages/auth/OAuthCallbackPage.tsx @@ -9,11 +9,11 @@ import { useToast } from "@/hooks/use-toast"; type CallbackState = 'loading' | 'success' | 'error'; -const GATEHOUSE_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; -const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); +const SECUIRD_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; +const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, ''); async function completeOidcFlow(oidcSessionId: string, token: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), @@ -24,7 +24,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise = { openid: { icon: Shield, label: "OpenID", description: "Verify your identity" }, @@ -41,7 +41,7 @@ export default function OIDCConsentPage() { (async () => { try { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: oidcSessionId }), @@ -67,7 +67,7 @@ export default function OIDCConsentPage() { navigate(`/login?oidc_session_id=${context.oidc_session_id}`); return; } - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: context.oidc_session_id, token }), diff --git a/src/pages/auth/OIDCLoginPage.tsx b/src/pages/auth/OIDCLoginPage.tsx index 4f8434c..0049c33 100644 --- a/src/pages/auth/OIDCLoginPage.tsx +++ b/src/pages/auth/OIDCLoginPage.tsx @@ -1,7 +1,7 @@ /** * OIDCLoginPage — Standalone OIDC proxy login UI * - * Unified entry point for OIDC authorization flows via the Gatehouse OIDC bridge. + * Unified entry point for OIDC authorization flows via the Secuird OIDC bridge. * Handles: * 1. Unauthenticated users → shows an email/password login form * 2. Already-authenticated users → shows a consent/approval screen directly @@ -9,7 +9,7 @@ * Route: /oidc-login?oidc_session_id= * * Configure your oauth2-proxy / OIDC client's login_url to: - * https:///oidc-login + * https:///oidc-login */ import { useState, useEffect, useCallback } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; @@ -37,7 +37,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { ApiError, tokenManager } from "@/lib/api"; // ── Configuration ───────────────────────────────────────────────────────────── -const GATEHOUSE_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") +const SECUIRD_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") .replace(/\/api\/v1\/?$/, ""); // ── Scope display metadata ──────────────────────────────────────────────────── @@ -62,7 +62,7 @@ type PageStep = "loading" | "login" | "consent" | "error"; // ── API helpers ─────────────────────────────────────────────────────────────── async function fetchOIDCContext(oidcSessionId: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: oidcSessionId }), @@ -75,7 +75,7 @@ async function fetchOIDCContext(oidcSessionId: string): Promise { } async function completeOIDCFlow(oidcSessionId: string, token: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx index cf84110..f9b542a 100644 --- a/src/pages/auth/RegisterPage.tsx +++ b/src/pages/auth/RegisterPage.tsx @@ -108,7 +108,7 @@ export default function RegisterPage() { Create your account

- Get started with Gatehouse in seconds + Get started with Secuird in seconds

diff --git a/src/pages/org/CAsPage.tsx b/src/pages/org/CAsPage.tsx index baf80df..73940a2 100644 --- a/src/pages/org/CAsPage.tsx +++ b/src/pages/org/CAsPage.tsx @@ -261,7 +261,7 @@ export default function CAsPage() {

Certificate Authorities

- Manage your organization's SSH CAs with Gatehouse + Manage your organization's SSH CAs with Secuird

diff --git a/src/pages/org/OIDCClientsPage.tsx b/src/pages/org/OIDCClientsPage.tsx index 33439a2..71fdae0 100644 --- a/src/pages/org/OIDCClientsPage.tsx +++ b/src/pages/org/OIDCClientsPage.tsx @@ -160,7 +160,7 @@ export default function OIDCClientsPage() {

OIDC Clients

-

Applications that authenticate via Gatehouse

+

Applications that authenticate via Secuird

+ ) : accessDenied ? ( +
+ +

Access Restricted

+

+ You don't have permission to view system-wide audit logs. Contact your administrator to request access. +

+
) : error ? (
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index d95d7d1..6d08970 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -230,8 +230,14 @@ export default function LoginPage() { finishCliFlow(response.token); } else { await refreshUser(); - await checkOrgAdmin(); - navigate('/profile'); + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + + if (hasOrg) { + navigate('/profile'); + } else { + navigate('/org-setup'); + } } } else { // Fallback to regular TOTP verification @@ -357,7 +363,10 @@ export default function LoginPage() { const token = tokenManager.getToken(); if (token) finishCliFlow(token); } else { - navigate('/profile'); + // Verify org membership before navigating to prevent showing org-setup briefly + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + navigate(hasOrg ? '/profile' : '/org-setup'); } toast({ @@ -434,7 +443,10 @@ export default function LoginPage() { } else { await refreshUser(); await checkOrgAdmin(); - navigate('/profile'); + // Verify org membership before navigating to prevent showing org-setup briefly + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + navigate(hasOrg ? '/profile' : '/org-setup'); toast({ title: "Welcome back", description: `Signed in as ${result.user.email}`, diff --git a/src/pages/org/OrgAuditPage.tsx b/src/pages/org/OrgAuditPage.tsx index a62bee4..791e89e 100644 --- a/src/pages/org/OrgAuditPage.tsx +++ b/src/pages/org/OrgAuditPage.tsx @@ -1,204 +1,403 @@ import { useState, useEffect, useCallback } from "react"; -import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react"; -import { useParams } from "react-router-dom"; +import { + Search, Filter, RefreshCw, ChevronLeft, ChevronRight, + LogIn, Key, UserPlus, Shield, Settings, + AlertTriangle, Terminal, Loader2, + CheckCircle2, XCircle, Link2, UserCog, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; import { api, AuditLogEntry } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; +import { formatDateTime } from "@/lib/date"; -const getEventIcon = (action: string) => { - if (action.includes("member") || action.includes("MEMBER")) { - return ; - } - if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) { - return ; - } - if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) { - return ; - } - if (action.includes("client") || action.includes("oidc") || action.includes("key")) { - return ; - } - return ; -}; +// ─── category / display helpers ────────────────────────────────────────────── -const getEventTitle = (action: string) => { - const parts = action.split("."); - return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" "); -}; +type Category = "auth" | "ssh" | "admin" | "member" | "policy" | "security" | "oauth" | "other"; -const getActionCategory = (action: string): string => { - if (action.includes("member") || action.includes("MEMBER")) return "members"; - if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies"; - if (action.includes("client") || action.includes("OIDC")) return "clients"; +const getCategory = (action: string): Category => { + const a = action.toLowerCase(); + if (a.startsWith("session") || a === "user.login" || a === "user.logout") return "auth"; + if (a.startsWith("ssh")) return "ssh"; + if (a.startsWith("admin.")) return "admin"; + if (a.includes("member") || a.includes("invite") || a.startsWith("org.member")) return "member"; + if (a.includes("policy") || a.includes("mfa.policy") || a.startsWith("org.security")) return "policy"; + if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password")) return "security"; + if (a.startsWith("external_auth")) return "oauth"; return "other"; }; +const CATEGORY_META: Record = { + auth: { label: "Auth", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" }, + ssh: { label: "SSH", color: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" }, + admin: { label: "Admin", color: "bg-red-500/10 text-red-600 dark:text-red-400" }, + member: { label: "Member", color: "bg-violet-500/10 text-violet-600 dark:text-violet-400" }, + policy: { label: "Policy", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" }, + security: { label: "Security", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400" }, + oauth: { label: "OAuth", color: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" }, + other: { label: "Other", color: "bg-muted text-muted-foreground" }, +}; + +const getCategoryIcon = (cat: Category) => { + const cls = "w-4 h-4"; + switch (cat) { + case "auth": return ; + case "ssh": return ; + case "admin": return ; + case "member": return ; + case "policy": return ; + case "security": return ; + case "oauth": return ; + default: return ; + } +}; + +const ACTION_LABELS: Record = { + // Sessions + "session.create": "Signed in", + "session.revoke": "Signed out", + "user.login": "Signed in", + "user.logout": "Signed out", + // Members + "org.member.add": "Member added", + "org.member.remove": "Member removed", + "org.member.role_change": "Member role changed", + "org.ownership.transferred": "Ownership transferred", + // Admin actions + "admin.mfa.remove": "MFA removed by admin", + "admin.oauth.unlink": "OAuth unlinked by admin", + "admin.password.set": "Password set by admin", + "admin.email.verify": "Email verified by admin", + // Security / policy + "org.security_policy.update": "Security policy updated", + "user.security_policy.override_update":"User policy override updated", + "mfa.policy.user_suspended": "User suspended (MFA policy)", + "mfa.policy.user_compliant": "User MFA compliant", + // Password + "user.password_change": "Password changed", + "user.password_reset": "Password reset", + // SSH + "ssh.key.added": "SSH key added", + "ssh.key.verified": "SSH key verified", + "ssh.key.deleted": "SSH key removed", + "ssh.cert.requested": "SSH certificate requested", + "ssh.cert.issued": "SSH certificate issued", + "ssh.cert.failed": "SSH certificate request failed", + "ssh.cert.revoked": "SSH certificate revoked", + // WebAuthn / Passkey + "webauthn.register.completed": "Passkey registered", + "webauthn.credential.deleted": "Passkey removed", + "webauthn.login.success": "Signed in with passkey", + "webauthn.login.failed": "Passkey login failed", + // TOTP + "totp.enroll.completed": "TOTP enrolled", + "totp.disabled": "TOTP disabled", + "totp.verify.failed": "TOTP verification failed", + // External auth + "external_auth.link.completed": "OAuth account linked", + "external_auth.unlink": "OAuth account unlinked", + "external_auth.login": "Signed in via OAuth", + "external_auth.login.failed": "OAuth login failed", + // Org + "org.create": "Organisation created", + "org.update": "Organisation updated", + "org.delete": "Organisation deleted", + // User lifecycle + "user.register": "User registered", + "user.suspend": "User suspended", + "user.unsuspend":"User unsuspended", + "user.delete": "User deleted", +}; + +const getActionLabel = (action: string) => + ACTION_LABELS[action] ?? + action.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + +// ─── action filter options (value = enum dot-notation) ─────────────────────── + +const ACTION_FILTER_OPTIONS = [ + { value: "all", label: "All actions" }, + // Auth + { value: "session.create", label: "Sign in" }, + { value: "session.revoke", label: "Sign out" }, + { value: "external_auth.login", label: "OAuth login" }, + // Members + { value: "org.member.add", label: "Member added" }, + { value: "org.member.remove", label: "Member removed" }, + { value: "org.member.role_change", label: "Role changed" }, + // Admin actions + { value: "admin.mfa.remove", label: "MFA removed (admin)" }, + { value: "admin.oauth.unlink", label: "OAuth unlinked (admin)" }, + { value: "admin.password.set", label: "Password set (admin)" }, + // Security / policy + { value: "org.security_policy.update", label: "Security policy changed" }, + { value: "user.password_change", label: "Password changed" }, + { value: "user.password_reset", label: "Password reset" }, + // SSH + { value: "ssh.key.added", label: "SSH key added" }, + { value: "ssh.key.verified", label: "SSH key verified" }, + { value: "ssh.cert.issued", label: "SSH cert issued" }, + { value: "ssh.cert.revoked", label: "SSH cert revoked" }, + // MFA + { value: "totp.enroll.completed", label: "TOTP enrolled" }, + { value: "totp.disabled", label: "TOTP disabled" }, + { value: "webauthn.register.completed", label: "Passkey registered" }, + { value: "webauthn.credential.deleted", label: "Passkey removed" }, + // User lifecycle + { value: "user.register", label: "User registered" }, + { value: "user.suspend", label: "User suspended" }, +]; + +const PER_PAGE = 50; + +// ─── cert metadata detail ───────────────────────────────────────────────────── + +function CertDetail({ metadata }: { metadata?: Record | null }) { + if (!metadata) return null; + const principal = metadata.principal as string | undefined; + const principals = metadata.principals as string[] | undefined; + const serial = metadata.serial_number ?? metadata.serial ?? metadata.cert_serial; + const principalList = principal ? [principal] : Array.isArray(principals) ? principals : []; + if (!principalList.length && !serial) return null; + return ( + + {principalList.length > 0 && <>principal: {principalList.join(", ")}} + {principalList.length > 0 && serial && " · "} + {serial != null && <>serial: {String(serial)}} + + ); +} + +// ─── component ──────────────────────────────────────────────────────────────── + export default function OrgAuditPage() { - const params = useParams<{ orgId?: string }>(); - const { orgId: fallbackOrgId } = useCurrentOrganizationId(); - const orgId = params.orgId || fallbackOrgId; + const { orgId } = useCurrentOrganizationId(); - const [search, setSearch] = useState(""); - const [typeFilter, setTypeFilter] = useState("all"); - const [auditLogs, setAuditLogs] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [actionFilter, setActionFilter] = useState("all"); + const [successFilter, setSuccessFilter] = useState("all"); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - const fetchAuditLogs = useCallback(async (currentOrgId: string) => { + // debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + // reset page on filter change + useEffect(() => { setPage(1); }, [actionFilter, successFilter, debouncedSearch]); + + const fetchLogs = useCallback(async () => { + if (!orgId) { setIsLoading(false); return; } + setIsLoading(true); + setError(null); try { - setIsLoading(true); - setError(null); - const response = await api.organizations.getAuditLogs(currentOrgId); - setAuditLogs(response.audit_logs || []); + const params: Record = { + page: String(page), + per_page: String(PER_PAGE), + }; + if (actionFilter !== "all") params.action = actionFilter; + if (successFilter !== "all") params.success = successFilter; + 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); } catch (err) { - console.error("Failed to fetch audit logs:", err); + console.error("Failed to fetch org audit logs:", err); setError("Failed to load audit logs. Please try again."); } finally { setIsLoading(false); } - }, []); + }, [orgId, page, actionFilter, successFilter, debouncedSearch]); - useEffect(() => { - setError(null); - setAuditLogs([]); - if (!orgId) { - setIsLoading(false); - return; - } - fetchAuditLogs(orgId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [orgId]); - - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(date); - }; - - const filteredLogs = auditLogs.filter((log) => { - const matchesSearch = - search === "" || - log.description?.toLowerCase().includes(search.toLowerCase()) || - log.action.toLowerCase().includes(search.toLowerCase()) || - log.user?.email.toLowerCase().includes(search.toLowerCase()); - - const matchesFilter = - typeFilter === "all" || - getActionCategory(log.action) === typeFilter; - - return matchesSearch && matchesFilter; - }); + useEffect(() => { fetchLogs(); }, [fetchLogs]); return (
+ {/* Header */}
-

Audit Log

+

Org Audit Log

- View all administrative actions and changes + All organisation activity — user events, admin actions, policy changes + {totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}

-
-
+ {/* Filters */} +
setSearch(e.target.value)} className="pl-10" />
- + - + - All events - Member changes - Policy changes - OIDC clients + {ACTION_FILTER_OPTIONS.map((o) => ( + {o.label} + ))} + + +
+ {/* Table */} {isLoading ? ( -
+
- Loading audit logs... + Loading…
) : error ? ( -
- {error} +
+ +

{error}

- ) : filteredLogs.length === 0 ? ( -
- No audit events found + ) : auditLogs.length === 0 ? ( +
+ No audit events match the current filters.
) : (
- {filteredLogs.map((log) => ( -
-
- {getEventIcon(log.action)} -
-
-
-

- {getEventTitle(log.action)} -

- {log.resource_type && ( - - {log.resource_type} - - )} - {!log.success && ( - - Failed - - )} + {auditLogs.map((log) => { + const cat = getCategory(log.action); + const meta = CATEGORY_META[cat]; + const isCert = log.action.startsWith("ssh.cert"); + return ( +
+ {/* Icon */} +
+ {log.success ? getCategoryIcon(cat) : }
-
- by {log.user?.full_name || log.user?.email || "System"} + + {/* Body */} +
+
+ + {getActionLabel(log.action)} + + + {meta.label} + + {!log.success && ( + Failed + )} +
+ + {/* Description */} {log.description && ( - <> - - {log.description} - +

+ {log.description} + {isCert && } +

+ )} + {log.error_message && ( +

{log.error_message}

+ )} + + {/* Actor / meta row */} +
+ {log.user?.email ? ( + {log.user.email} + ) : log.user_id ? ( + {log.user_id.slice(0, 8)}… + ) : ( + System + )} + {log.ip_address && ( + {log.ip_address} + )} + {log.resource_type && ( + + {log.resource_type} + + )} +
+
+ + {/* Timestamp */} +
+

+ {formatDateTime(log.created_at)} +

+ {log.success ? ( + + ) : ( + )}
-

- {formatDate(log.created_at)} -

-
- ))} + ); + })}
)} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages}  ·  {totalCount.toLocaleString()} events +

+
+ + +
+
+ )}
); -} +} \ No newline at end of file diff --git a/src/pages/user/ActivityPage.tsx b/src/pages/user/ActivityPage.tsx index c9e2104..6f1de39 100644 --- a/src/pages/user/ActivityPage.tsx +++ b/src/pages/user/ActivityPage.tsx @@ -1,111 +1,224 @@ -import { useState, useEffect } from "react"; -import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw, Users } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; +import { + LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, + Loader2, RefreshCw, Link2, Terminal, CheckCircle2, XCircle, + ChevronLeft, ChevronRight, Search, +} from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api, AuditLogEntry } from "@/lib/api"; -import { useAuth } from "@/contexts/AuthContext"; import { formatDateTime } from "@/lib/date"; -// Map audit log action strings to display info -const getEventDisplay = (action: string) => { +// ─── event display mapping ──────────────────────────────────────────────────── + +interface EventDisplay { + icon: React.ReactNode; + title: string; +} + +const getEventDisplay = (action: string): EventDisplay => { const a = action.toLowerCase(); - if (a.includes("login") && a.includes("fail")) { - return { icon: , title: "Failed login attempt", failed: true }; - } - if (a.includes("login") || a.includes("authenticate")) { - return { icon: , title: "Signed in", failed: false }; - } - if (a.includes("logout") || a.includes("sign_out")) { - return { icon: , title: "Signed out", failed: false }; - } - if (a.includes("passkey") || a.includes("webauthn")) { - return { icon: , title: "Passkey event", failed: false }; - } - if (a.includes("mfa") || a.includes("totp") || a.includes("2fa")) { - return { icon: , title: "MFA event", failed: false }; - } - if (a.includes("ssh")) { - return { icon: , title: "SSH key event", failed: false }; - } - return { icon: , title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") }; + + // Sessions + if (a === "session.create") return { icon: , title: "Signed in" }; + if (a === "session.revoke") return { icon: , title: "Signed out" }; + if (a === "user.login") return { icon: , title: "Signed in" }; + if (a === "user.logout") return { icon: , title: "Signed out" }; + + // OAuth / external auth + if (a === "external_auth.link.completed") return { icon: , title: "OAuth account linked" }; + if (a === "external_auth.link.initiated") return { icon: , title: "OAuth link started" }; + if (a === "external_auth.link.failed") return { icon: , title: "OAuth link failed" }; + if (a === "external_auth.unlink") return { icon: , title: "OAuth account unlinked" }; + if (a === "external_auth.login") return { icon: , title: "Signed in via OAuth" }; + if (a === "external_auth.login.failed") return { icon: , title: "OAuth login failed" }; + + // SSH keys + if (a === "ssh.key.added") return { icon: , title: "SSH key added" }; + if (a === "ssh.key.verified") return { icon: , title: "SSH key verified" }; + if (a === "ssh.key.deleted") return { icon: , title: "SSH key removed" }; + if (a === "ssh.key.validation.failed")return { icon: , title: "SSH key validation failed" }; + if (a === "ssh.cert.requested") return { icon: , title: "SSH certificate requested" }; + if (a === "ssh.cert.issued") return { icon: , title: "SSH certificate issued" }; + if (a === "ssh.cert.failed") return { icon: , title: "SSH certificate request failed" }; + if (a === "ssh.cert.revoked") return { icon: , title: "SSH certificate revoked" }; + + // WebAuthn / Passkey + if (a === "webauthn.register.completed") return { icon: , title: "Passkey registered" }; + if (a === "webauthn.register.initiated") return { icon: , title: "Passkey registration started" }; + if (a === "webauthn.register.failed") return { icon: , title: "Passkey registration failed" }; + if (a === "webauthn.login.success") return { icon: , title: "Signed in with passkey" }; + if (a === "webauthn.login.failed") return { icon: , title: "Passkey login failed" }; + if (a === "webauthn.credential.deleted") return { icon: , title: "Passkey removed" }; + if (a === "webauthn.credential.renamed") return { icon: , title: "Passkey renamed" }; + + // TOTP / MFA + if (a === "totp.enroll.completed") return { icon: , title: "TOTP authenticator enrolled" }; + if (a === "totp.enroll.initiated") return { icon: , title: "TOTP enrolment started" }; + if (a === "totp.verify.success") return { icon: , title: "TOTP code verified" }; + if (a === "totp.verify.failed") return { icon: , title: "TOTP verification failed" }; + if (a === "totp.disabled") return { icon: , title: "TOTP disabled" }; + if (a === "totp.backup_code.used") return { icon: , title: "TOTP backup code used" }; + if (a === "totp.backup_codes.regenerated")return { icon: , title: "TOTP backup codes regenerated" }; + + // Password + if (a === "user.password_change") return { icon: , title: "Password changed" }; + if (a === "user.password_reset") return { icon: , title: "Password reset" }; + + // Generic fallback + return { + icon: , + title: action.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), + }; }; +// ─── cert metadata detail row ───────────────────────────────────────────────── + +function CertDetail({ metadata }: { metadata?: Record | null }) { + if (!metadata) return null; + const principal = metadata.principal as string | undefined; + const principals = metadata.principals as string[] | undefined; + const serial = metadata.serial_number ?? metadata.serial ?? metadata.cert_serial; + const expiry = metadata.expiry ?? metadata.expires_at ?? metadata.valid_until; + const principalList = principal + ? [principal] + : Array.isArray(principals) + ? principals + : []; + + if (!principalList.length && !serial) return null; + + return ( +
+ {principalList.length > 0 && ( + + Principal{principalList.length > 1 ? "s" : ""}:{" "} + {principalList.join(", ")} + + )} + {serial != null && ( + + Serial: {String(serial)} + + )} + {expiry && ( + + Expires: {new Date(String(expiry)).toLocaleDateString()} + + )} +
+ ); +} + +// ─── filter options ──────────────────────────────────────────────────────────── + +const FILTER_OPTIONS = [ + { value: "all", label: "All events" }, + { value: "session.create", label: "Signed in" }, + { value: "session.revoke", label: "Signed out" }, + { value: "external_auth.login", label: "OAuth login" }, + { value: "external_auth.link.completed", label: "OAuth linked" }, + { value: "external_auth.unlink", label: "OAuth unlinked" }, + { value: "ssh.key.added", label: "SSH key added" }, + { value: "ssh.key.verified", label: "SSH key verified" }, + { value: "ssh.cert.issued", label: "SSH cert issued" }, + { value: "ssh.cert.failed", label: "SSH cert failed" }, + { value: "webauthn.register.completed", label: "Passkey registered" }, + { value: "totp.enroll.completed", label: "TOTP enrolled" }, + { value: "user.password_change", label: "Password changed" }, +]; + +const PER_PAGE = 50; + +// ─── component ──────────────────────────────────────────────────────────────── + export default function ActivityPage() { - const { isOrgAdmin } = useAuth(); - const [filter, setFilter] = useState("all"); - const [view, setView] = useState<"mine" | "org">("mine"); + const [actionFilter, setActionFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); const [events, setEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - const loadEvents = () => { + // debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + // reset page when filters change + useEffect(() => { setPage(1); }, [actionFilter, debouncedSearch]); + + const loadEvents = useCallback(async () => { setIsLoading(true); setError(""); - const req = - view === "org" && isOrgAdmin - ? api.admin.getAuditLogs({ per_page: "100" }).then((d) => d.audit_logs ?? []) - : api.users.auditLogs({ per_page: "50" }).then((d) => d.audit_logs ?? []); + try { + const params: Record = { + page: String(page), + per_page: String(PER_PAGE), + }; + if (actionFilter !== "all") params.action = actionFilter; + if (debouncedSearch) params.q = debouncedSearch; - req - .then((logs) => setEvents(logs)) - .catch(() => setError("Failed to load activity. Please try again.")) - .finally(() => setIsLoading(false)); - }; + const data = await api.users.auditLogs(params); + setEvents(data.audit_logs ?? []); + setTotalCount(data.count ?? 0); + setTotalPages(data.pages ?? 1); + } catch { + setError("Failed to load activity. Please try again."); + } finally { + setIsLoading(false); + } + }, [page, actionFilter, debouncedSearch]); - useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { loadEvents(); }, [loadEvents]); - const formatDate = (dateString: string) => formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); - - const filteredEvents = events.filter((e) => { - if (filter === "all") return true; - const a = e.action.toLowerCase(); - if (filter === "logins") - return a.includes("session_create") || a.includes("session_revoke") || a.includes("external_auth") || a.includes("login") || a.includes("logout"); - if (filter === "security") - return a.includes("mfa") || a.includes("passkey") || a.includes("ssh") || a.includes("totp") || a.includes("password") || a.includes("webauthn"); - return true; - }); + const formatDate = (dateString: string) => + formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); return (
+ {/* Header */}
-

Activity

-

- {view === "org" ? "Organization-wide audit log" : "Your recent account activity and security events"} -

-
-
- {isOrgAdmin && ( - setView(v as "mine" | "org")}> - - My Activity - - - Org Logs - - - - )} - - +

My Activity

+

Your recent account activity and security events

+
+ {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ + {/* Log list */} {isLoading ? ( @@ -117,19 +230,20 @@ export default function ActivityPage() {

{error}

- ) : filteredEvents.length === 0 ? ( + ) : events.length === 0 ? (

No activity events found.

) : (
- {filteredEvents.map((event) => { + {events.map((event) => { const display = getEventDisplay(event.action); + const isCert = event.action.startsWith("ssh.cert"); return (
-

- {display.title} -

- {(!event.success || display.failed) && ( - - Failed - +

{display.title}

+ {!event.success && ( + Failed )}
-
- {view === "org" && event.user_id && ( -

User: {event.user_id}

+ {event.description && ( +

{event.description}

+ )} + {/* Cert-specific: principal + serial */} + {isCert && } +
+ {event.ip_address && ( + {event.ip_address} + )} + {event.user_agent && ( + + {event.user_agent.match(/\(([^)]+)\)/)?.[1]?.split(";")[0]?.trim() ?? event.user_agent.slice(0, 40)} + )} - {event.description &&

{event.description}

} -
- {event.ip_address && ( - {event.ip_address} - )} - {event.user_agent && ( - {event.user_agent} - )} -
-

- {formatDate(event.created_at)} -

+
+

+ {formatDate(event.created_at)} +

+ {event.success ? ( + + ) : ( + + )} +
); })} @@ -172,6 +290,31 @@ export default function ActivityPage() { )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages}  ·  {totalCount.toLocaleString()} events +

+
+ + +
+
+ )}
); } From 58929fbfeff22062e45fadddf752ada378ba6905 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Sun, 8 Mar 2026 18:08:42 +0545 Subject: [PATCH 05/12] Feat: Implemented SUDO Department & API Key --- src/App.tsx | 2 + src/components/navigation/AppSidebar.tsx | 1 + src/lib/api.ts | 72 +++- src/pages/org/ApiKeysPage.tsx | 496 +++++++++++++++++++++++ src/pages/org/DepartmentsPage.tsx | 47 ++- src/pages/org/ca/CADetailCard.tsx | 4 +- src/pages/user/SSHKeysPage.tsx | 4 +- 7 files changed, 611 insertions(+), 15 deletions(-) create mode 100644 src/pages/org/ApiKeysPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 3d68c6b..2b3a544 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ 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 DevicesPage from "@/pages/org/DevicesPage"; @@ -184,6 +185,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 4b68272..2c3307f 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -57,6 +57,7 @@ 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 }, diff --git a/src/lib/api.ts b/src/lib/api.ts index 23f3f5b..9322886 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -248,6 +248,38 @@ export interface LinkAccountResponse { linked_account: LinkedAccount; } +export interface OrganizationApiKey { + id: string; + organization_id: string; + name: string; + description: string | null; + key_hash?: string; // Usually excluded from responses for security + last_used_at: string | null; + is_revoked: boolean; + revoked_at: string | null; + revoke_reason: string | null; + created_at: string; + updated_at: string; +} + +export interface CertificateAuditLog { + id: string; + action: string; + certificate_serial: string; + key_id: string; + principals: string[]; + user_id: string; + user_email: string | null; + issued_at: string; + valid_after: string; + valid_before: string; + ip_address: string | null; + user_agent: string | null; + message: string | null; + success: boolean; + created_at: string; +} + class ApiError extends Error { code: number; type: string; @@ -954,14 +986,14 @@ export const api = { request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig), // Create department - createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + createDepartment: (orgId: string, name: string, description?: string, canSudo?: boolean, requestConfig?: RequestConfig) => request<{ department: Department }>(`/organizations/${orgId}/departments`, { method: 'POST', - body: JSON.stringify({ name, description }), + body: JSON.stringify({ name, description, can_sudo: canSudo }), }, true, requestConfig), // Update department - updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string; can_sudo?: boolean }, requestConfig?: RequestConfig) => request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, { method: 'PATCH', body: JSON.stringify(data), @@ -1130,6 +1162,39 @@ export const api = { request<{ ca_id: string }>(`/organizations/${orgId}/cas/${caId}`, { method: 'DELETE', }, true, requestConfig), + + // Get API keys for organization + getApiKeys: (orgId: string, requestConfig?: RequestConfig) => + request<{ api_keys: OrganizationApiKey[]; count: number }>(`/organizations/${orgId}/api-keys`, {}, true, requestConfig), + + // Create new API key + createApiKey: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + request<{ api_key: OrganizationApiKey & { key?: string } }>(`/organizations/${orgId}/api-keys`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }, true, requestConfig), + + // Update API key + updateApiKey: (orgId: string, keyId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + request<{ api_key: OrganizationApiKey }>(`/organizations/${orgId}/api-keys/${keyId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + + // Delete API key + deleteApiKey: (orgId: string, keyId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/api-keys/${keyId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get certificate audit logs for organization + getCertificateAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: CertificateAuditLog[]; count: number; page: number; per_page: number; pages: number }>( + `/organizations/${orgId}/certificates/audit${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig + ), }, invites: { @@ -1557,6 +1622,7 @@ export interface Department { organization_id: string; name: string; description: string | null; + can_sudo: boolean; created_at: string; updated_at: string; deleted_at: string | null; diff --git a/src/pages/org/ApiKeysPage.tsx b/src/pages/org/ApiKeysPage.tsx new file mode 100644 index 0000000..f4d07cc --- /dev/null +++ b/src/pages/org/ApiKeysPage.tsx @@ -0,0 +1,496 @@ +import { useState, useEffect, useRef } from "react"; +import { + Plus, Copy, Trash2, Loader2, AlertCircle, CheckCircle, Eye, EyeOff, MoreHorizontal, Edit2, Check +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + 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(null); + const [editingKey, setEditingKey] = useState(null); + const [showKey, setShowKey] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const nameRef = useRef(null); + const descriptionRef = useRef(null); + const editNameRef = useRef(null); + const editDescriptionRef = useRef(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 ( +
+
+ +
+
+ ); + } + + return ( +
+
+

API Keys

+

+ Manage API keys for external integrations and programmatic access to your organization. +

+
+ + {/* New key notification */} + {newSecret && ( + + + + + New API Key Created + + + Store this key securely. You won't be able to see it again. + + + +
+ +

{newSecret.name}

+
+
+ + + {newSecret.key} + +
+ +
+
+ )} + + {/* Create button */} +
+ +
+ + {/* Active Keys */} + {activeKeys.length > 0 && ( +
+

Active Keys

+
+ {activeKeys.map((key) => ( + + +
+
+
+

{key.name}

+ {key.last_used_at && ( + + Last used: {formatDate(key.last_used_at)} + + )} +
+ {key.description && ( +

+ {key.description} +

+ )} +

+ Created {formatDate(key.created_at)} +

+
+ + + + + + handleEditKey(key)} + className="cursor-pointer" + > + + Edit + + + handleDeleteKey(key.id)} + className="text-destructive cursor-pointer" + disabled={isDeletingKey} + > + + Delete + + + +
+
+
+ ))} +
+
+ )} + + {/* Revoked Keys */} + {revokedKeys.length > 0 && ( +
+

Revoked Keys

+
+ {revokedKeys.map((key) => ( + + +
+
+

+ {key.name} +

+

+ Revoked {formatDate(key.revoked_at || '')} + {key.revoke_reason && ` - ${key.revoke_reason}`} +

+
+
+
+
+ ))} +
+
+ )} + + {/* Empty state */} + {apiKeys.length === 0 && ( + + + +

No API Keys

+

+ Create your first API key to enable external integrations. +

+ +
+
+ )} + + {/* Create Dialog */} + + + + Create API Key + + Create a new API key for external integrations. The key will be displayed only once. + + +
+
+ + +
+
+ +