diff --git a/src/App.tsx b/src/App.tsx index 8ccdb93..0596091 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,13 +18,16 @@ import ResetPasswordPage from "@/pages/auth/ResetPasswordPage"; import InviteAcceptPage from "@/pages/auth/InviteAcceptPage"; import OIDCConsentPage from "@/pages/auth/OIDCConsentPage"; import OIDCErrorPage from "@/pages/auth/OIDCErrorPage"; +import OIDCLoginPage from "@/pages/auth/OIDCLoginPage"; import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage"; +import ActivatePage from "@/pages/auth/ActivatePage"; // User pages import ProfilePage from "@/pages/user/ProfilePage"; import SecurityPage from "@/pages/user/SecurityPage"; import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage"; import ActivityPage from "@/pages/user/ActivityPage"; +import SSHKeysPage from "@/pages/user/SSHKeysPage"; // Organization pages import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; @@ -33,6 +36,13 @@ import PoliciesPage from "@/pages/org/PoliciesPage"; import CompliancePage from "@/pages/org/CompliancePage"; import OrgAuditPage from "@/pages/org/OrgAuditPage"; 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 MyMembershipsPage from "@/pages/org/MyMembershipsPage"; +import SystemAuditPage from "@/pages/admin/SystemAuditPage"; +import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; +import OrgSetupPage from "@/pages/auth/OrgSetupPage"; import NotFound from "@/pages/NotFound"; import ApiDevTools from "@/components/dev/ApiDevTools"; @@ -65,26 +75,77 @@ const App = () => ( ); // Separate component so AuthProvider can use useNavigate -import { AuthProvider } from "@/contexts/AuthContext"; +import { AuthProvider, useAuth } from "@/contexts/AuthContext"; +import { OrgProvider } from "@/contexts/OrgContext"; +import { Navigate } from "react-router-dom"; + +/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */ +function GuestRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isOrgMember, isLoading } = useAuth(); + // Allow authenticated users through to /login when it's a CLI auth request or + // an OIDC session — LoginPage will immediately forward the existing token. + const params = new URLSearchParams(window.location.search); + const isCli = params.has('cli_token') || params.has('cli_redirect'); + const isOidcBridge = params.has('oidc_session_id'); + if (isLoading) return null; // wait for auth state to resolve + if (isAuthenticated && !isCli && !isOidcBridge) { + // If the user hasn't set up an org yet, send them there first + return ; + } + return <>{children}; +} + +/** Blocks access to /admin/* for non-admin users. */ +function RequireAdmin({ children }: { children: React.ReactNode }) { + const { isOrgAdmin, isLoading, isAuthenticated } = useAuth(); + if (isLoading) return null; + if (!isAuthenticated) return ; + if (!isOrgAdmin) return ; + return <>{children}; +} + +/** Blocks access to /org/* for users who don't belong to any organisation. */ +function RequireOrgMember({ children }: { children: React.ReactNode }) { + const { isOrgMember, isLoading, isAuthenticated } = useAuth(); + if (isLoading) return null; + if (!isAuthenticated) return ; + if (!isOrgMember) return ; + return <>{children}; +} + +/** + * Used for /org-setup which lives inside PublicLayout */ +function RequireAuth({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + if (isLoading) return null; + if (!isAuthenticated) return ; + return <>{children}; +} function AppRoutes() { return ( + {/* Index redirect */} } /> {/* Public routes */} }> - } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } /> } /> + } /> + {/* Org-setup uses the same full-screen centred layout as auth pages, + but requires a valid session token (RequireAuth guard below). */} + } /> {/* Protected routes - handles auth and MFA enforcement */} @@ -94,14 +155,26 @@ function AppRoutes() { } /> } /> } /> + } /> - {/* Organization routes */} - } /> - } /> - } /> - } /> - } /> - } /> + {/* Organization routes — org members: overview + own memberships only */} + } /> + } /> + + {/* Organization management routes — org admins/owners only */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Admin routes — org admin/owner only */} + } /> + } /> + } /> {/* Catch-all */} @@ -110,6 +183,7 @@ function AppRoutes() { {/* Dev tools - only shown in development */} + ); } diff --git a/src/components/layouts/ProtectedLayout.tsx b/src/components/layouts/ProtectedLayout.tsx index 2df3e4e..99c0f1e 100644 --- a/src/components/layouts/ProtectedLayout.tsx +++ b/src/components/layouts/ProtectedLayout.tsx @@ -2,12 +2,14 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; import AuthenticatedLayout from './AuthenticatedLayout'; import MfaEnforcementLayout from './MfaEnforcementLayout'; +import { useOrganizations } from '@/hooks/useOrganizations'; import { Loader2 } from 'lucide-react'; export default function ProtectedLayout() { - const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth(); + const { isAuthenticated, isLoading, requiresMfaEnrollment, isOrgMember } = useAuth(); + const { isLoading: isOrgsLoading } = useOrganizations(); - if (isLoading) { + if (isLoading || isOrgsLoading) { return (
@@ -22,13 +24,16 @@ export default function ProtectedLayout() { return ; } + // User is logged in but hasn't joined/created an org yet — send to org-setup + if (!isOrgMember) { + return ; + } + if (requiresMfaEnrollment) { return ; } return ( - - - + ); } diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 898e7e0..0ba10a0 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -8,10 +8,15 @@ import { Users, Settings, FileText, - Key, + Layers, + GitBranch, + ScrollText, + Terminal, + ShieldCheck, } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; +import { useAuth } from "@/contexts/AuthContext"; import { Sidebar, SidebarContent, @@ -30,29 +35,41 @@ import { cn } from "@/lib/utils"; const userNavItems = [ { title: "Profile", url: "/profile", icon: User }, { title: "Security", url: "/security", icon: Shield }, + { title: "SSH Keys", url: "/ssh-keys", icon: Terminal }, { title: "Linked Accounts", url: "/linked-accounts", icon: Link2 }, { title: "Activity", url: "/activity", icon: Activity }, ]; -const orgNavItems = [ +// Visible to ALL org members +const orgMemberNavItems = [ + { title: "Overview", url: "/org", icon: Building2 }, + { title: "My Memberships", url: "/org/my-memberships", icon: Layers }, +]; + +// Visible to org admins/owners only (management) +const orgAdminNavItems = [ { title: "Overview", url: "/org", icon: Building2 }, { title: "Members", url: "/org/members", icon: Users }, + { title: "Departments", url: "/org/departments", icon: Layers }, + { title: "Principals", url: "/org/principals", icon: GitBranch }, { title: "Policies", url: "/org/policies", icon: Settings }, - { title: "Audit Log", url: "/org/audit", icon: FileText }, ]; const adminNavItems = [ - { title: "OIDC Clients", url: "/org/clients", icon: Key }, + { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, + { title: "Org Audit Log", url: "/org/audit", icon: FileText }, + { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; export function AppSidebar() { const { state } = useSidebar(); const collapsed = state === "collapsed"; const location = useLocation(); + const { isOrgAdmin, isOrgMember } = useAuth(); const isActive = (path: string) => location.pathname === path; - const isOrgActive = orgNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); - const isUserActive = userNavItems.some((item) => isActive(item.url)); + const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); + void isOrgActive; // used for future active state tracking return ( {/* User Section */} - - {!collapsed && "Account"} - + {!collapsed && ( + + Account + + )} {userNavItems.map((item) => ( @@ -89,8 +108,11 @@ export function AppSidebar() { to={item.url} end className={cn( - "flex items-center gap-3 px-4 py-2.5 text-sm text-sidebar-foreground rounded-lg mx-2 transition-colors", - "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + "flex items-center text-sm text-sidebar-foreground rounded-lg transition-colors", + "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + collapsed + ? "justify-center w-10 h-10 mx-auto p-0" + : "gap-3 px-4 py-2.5 mx-2" )} activeClassName="bg-sidebar-accent text-sidebar-primary font-medium" > @@ -104,22 +126,28 @@ export function AppSidebar() { - {/* Organization Section */} + {/* Organization Section — content differs by role */} + {isOrgMember && ( - - {!collapsed && "Organization"} - + {!collapsed && ( + + Organization + + )} - {orgNavItems.map((item) => ( + {(isOrgAdmin ? orgAdminNavItems : orgMemberNavItems).map((item) => ( @@ -132,12 +160,16 @@ export function AppSidebar() { + )} - {/* Admin Section */} + {/* Admin Section — only visible to org admins/owners */} + {isOrgAdmin && ( - - {!collapsed && "Admin"} - + {!collapsed && ( + + Admin + + )} {adminNavItems.map((item) => ( @@ -147,8 +179,11 @@ export function AppSidebar() { to={item.url} end className={cn( - "flex items-center gap-3 px-4 py-2.5 text-sm text-sidebar-foreground rounded-lg mx-2 transition-colors", - "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + "flex items-center text-sm text-sidebar-foreground rounded-lg transition-colors", + "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + collapsed + ? "justify-center w-10 h-10 mx-auto p-0" + : "gap-3 px-4 py-2.5 mx-2" )} activeClassName="bg-sidebar-accent text-sidebar-primary font-medium" > @@ -161,6 +196,7 @@ export function AppSidebar() { + )} diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index ffd3e5a..a92393c 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Menu, ChevronDown, LogOut, User, Shield, Building2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -13,34 +12,23 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useAuth } from "@/contexts/AuthContext"; -import { Organization } from "@/lib/api"; +import { useOrg } from "@/contexts/OrgContext"; import { useOrganizations } from "@/hooks/useOrganizations"; import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; export function TopBar() { const navigate = useNavigate(); - const { user, isAuthenticated, mfaCompliance } = useAuth(); - const [currentOrg, setCurrentOrg] = useState(null); - + const { user, mfaCompliance, logout } = useAuth(); + const { selectedOrg, selectOrg } = useOrg(); + // Use React Query hook for organizations with automatic caching and deduplication const { data: organizations = [], isLoading: orgsLoading } = useOrganizations(); - // Debug logging - console.log('[TopBar] organizations data:', organizations); - console.log('[TopBar] organizations is array:', Array.isArray(organizations)); - // Ensure organizations is always an array (defensive check) const organizationsArray = Array.isArray(organizations) ? organizations : []; - // Set initial currentOrg when organizations are loaded - useEffect(() => { - if (organizationsArray.length > 0 && !currentOrg) { - setCurrentOrg(organizationsArray[0]); - } - }, [organizationsArray, currentOrg]); - - const handleLogout = () => { - navigate("/login"); + const handleLogout = async () => { + await logout(); }; const userInitials = user?.full_name @@ -68,7 +56,7 @@ export function TopBar() {
- {orgsLoading ? "Loading..." : (currentOrg?.name || "No Organization")} + {orgsLoading ? "Loading..." : (selectedOrg?.name || "No Organization")} @@ -90,7 +78,7 @@ export function TopBar() { organizationsArray.map((org) => ( setCurrentOrg(org)} + onClick={() => selectOrg(org)} className="flex items-center justify-between" >
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4b7e5be..4eb6068 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,25 +1,32 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; -import { api, User, ApiError, tokenManager, MfaComplianceSummary } from '@/lib/api'; +import { api, User, ApiError, tokenManager, MfaComplianceSummary, PendingInvite } from '@/lib/api'; interface LoginResult { requiresTotp: boolean; requiresWebAuthn: boolean; requiresMfaEnrollment?: boolean; + requiresOrgSetup?: boolean; + pendingInvites?: PendingInvite[]; + isFirstUser?: boolean; } interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; + isOrgAdmin: boolean; + isOrgMember: boolean; mfaCompliance: MfaComplianceSummary | null; requiresMfaEnrollment: boolean; - login: (email: string, password: string, rememberMe?: boolean) => Promise; - verifyTotp: (code: string, isBackupCode?: boolean) => Promise; + login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise; + verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise; verifyWebAuthn: () => Promise; logout: () => Promise; refreshUser: () => Promise; refreshCompliance: () => Promise; + /** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */ + checkOrgAdmin: () => Promise; } const AuthContext = createContext(null); @@ -40,66 +47,61 @@ function persistMfaCompliance(compliance: MfaComplianceSummary | null): void { function loadMfaCompliance(): MfaComplianceSummary | null { try { const stored = localStorage.getItem(MFA_COMPLIANCE_KEY); - if (!stored) { - console.log('[AuthContext] loadMfaCompliance: no stored data'); - return null; - } - + if (!stored) return null; + const parsed = JSON.parse(stored); - console.log('[AuthContext] loadMfaCompliance: raw parsed:', parsed); - + // Handle both direct format and legacy double-nested format // Legacy format: { mfa_compliance: { ... } } // Current format: { ... } let compliance: Record; if (parsed.mfa_compliance && typeof parsed.mfa_compliance === 'object') { - console.log('[AuthContext] loadMfaCompliance: detected legacy double-nested format, unwrapping'); compliance = parsed.mfa_compliance as Record; } else { compliance = parsed; } - + // Validate that the stored data has the required fields - if (!compliance || typeof compliance !== 'object') { - console.log('[AuthContext] loadMfaCompliance: invalid compliance object'); - return null; - } - if (!Array.isArray(compliance.orgs)) { - console.log('[AuthContext] loadMfaCompliance: orgs is not an array'); - return null; - } - - // Validate missing_methods exists and is an array - if (!Array.isArray(compliance.missing_methods)) { - console.log('[AuthContext] loadMfaCompliance: missing_methods is not an array or missing'); - } - + if (!compliance || typeof compliance !== 'object') return null; + if (!Array.isArray(compliance.orgs)) return null; + // Check if at least one org has effective_mode (new field from API) // If not, treat as stale data and return null to fetch fresh data const hasEffectiveMode = compliance.orgs.some((org: Record) => typeof org.effective_mode === 'string' ); - - if (!hasEffectiveMode) { - console.log('[AuthContext] loadMfaCompliance: no effective_mode found, treating as stale'); - return null; - } - - console.log('[AuthContext] loadMfaCompliance: loaded successfully'); + if (!hasEffectiveMode) return null; + return compliance as unknown as MfaComplianceSummary; - } catch (error) { - console.log('[AuthContext] loadMfaCompliance: error loading:', error); + } catch { return null; } } export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [isOrgAdmin, setIsOrgAdmin] = useState(false); + const [isOrgMember, setIsOrgMember] = useState(false); const [mfaCompliance, setMfaCompliance] = useState(loadMfaCompliance); const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false); const [isLoading, setIsLoading] = useState(true); const navigate = useNavigate(); + // Helper to check if user is admin/owner in any org + const checkOrgAdmin = useCallback(async () => { + try { + const data = await api.users.organizations(); + const admin = data.organizations.some( + (org) => org.role === 'owner' || org.role === 'admin' + ); + setIsOrgAdmin(admin); + setIsOrgMember(data.organizations.length > 0); + } catch { + setIsOrgAdmin(false); + setIsOrgMember(false); + } + }, []); + const refreshCompliance = useCallback(async () => { try { const compliance = await api.policies.getMyCompliance(); @@ -148,7 +150,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const response = await api.users.me(); setUser(response.user); - // Also fetch compliance status + // Also fetch compliance status and org role try { const compliance = await api.policies.getMyCompliance(); setMfaCompliance(compliance); @@ -159,8 +161,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setMfaCompliance(null); persistMfaCompliance(null); } + // Check org admin status + await checkOrgAdmin(); } catch { setUser(null); + setIsOrgAdmin(false); + setIsOrgMember(false); setMfaCompliance(null); persistMfaCompliance(null); setRequiresMfaEnrollment(false); @@ -172,32 +178,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { checkAuth(); }, []); - const login = useCallback(async (email: string, password: string, rememberMe = false): Promise => { - console.log('[AuthContext] login() called'); + const login = useCallback(async (email: string, password: string, rememberMe = false, skipNavigate = false): Promise => { const response = await api.auth.login(email, password, rememberMe); - console.log('[AuthContext] login response:', { - requires_totp: response.requires_totp, - requires_webauthn: response.requires_webauthn, - requires_mfa_enrollment: response.requires_mfa_enrollment, - hasToken: !!response.token, - hasUser: !!response.user - }); - + // If WebAuthn is required, don't set user yet - wait for WebAuthn verification if (response.requires_webauthn) { - console.log('[AuthContext] WebAuthn required, returning early'); return { requiresTotp: false, requiresWebAuthn: true }; } - + // If TOTP is required, don't set user yet - wait for TOTP verification if (response.requires_totp) { - console.log('[AuthContext] TOTP required, returning early'); return { requiresTotp: true, requiresWebAuthn: false }; } - + // If MFA enrollment is required (past deadline), set compliance state if (response.requires_mfa_enrollment) { - console.log('[AuthContext] MFA enrollment required, setting compliance state'); if (response.token) { tokenManager.setToken(response.token, response.expires_at ?? null); } @@ -211,48 +206,53 @@ export function AuthProvider({ children }: { children: ReactNode }) { setRequiresMfaEnrollment(true); return { requiresTotp: false, requiresWebAuthn: false, requiresMfaEnrollment: true }; } - - // Login complete: store token explicitly before setting user state - // This ensures the token is available for any subsequent API calls - // (e.g., when navigate('/profile') triggers refreshUser()) + if (response.token) { - console.log('[AuthContext] Storing token in localStorage'); tokenManager.setToken(response.token, response.expires_at ?? null); - console.log('[AuthContext] Token stored, verifying:', tokenManager.getToken()?.substring(0, 20) + '...'); } - - // Set user and navigate + if (response.user) { - console.log('[AuthContext] Setting user state and navigating to /profile'); setUser(response.user); if (response.mfa_compliance) { setMfaCompliance(response.mfa_compliance); persistMfaCompliance(response.mfa_compliance); } setRequiresMfaEnrollment(false); - navigate('/profile'); + await checkOrgAdmin(); + if (!skipNavigate) { + if (response.requires_org_setup) { + navigate('/org-setup', { + state: { + pendingInvites: response.pending_invites ?? [], + isFirstUser: false, + }, + }); + } else { + navigate('/profile'); + } + } } - return { requiresTotp: false, requiresWebAuthn: false }; - }, [navigate]); + return { + requiresTotp: false, + requiresWebAuthn: false, + requiresOrgSetup: response.requires_org_setup, + pendingInvites: response.pending_invites, + }; + }, [navigate, checkOrgAdmin]); const verifyWebAuthn = useCallback(async () => { // WebAuthn verification is handled directly in the LoginPage component - // This is a placeholder for consistency with the interface - console.log('[AuthContext] verifyWebAuthn called - verification handled in LoginPage'); }, []); - const verifyTotp = useCallback(async (code: string, isBackupCode = false) => { + const verifyTotp = useCallback(async (code: string, isBackupCode = false, skipNavigate = false) => { const response = await api.totp.verify(code, isBackupCode); - // Store token explicitly before setting user state - // This ensures the token is available for any subsequent API calls if (response.token) { tokenManager.setToken(response.token, response.expires_at ?? null); } setUser(response.user); - // Check for MFA compliance in response try { const compliance = await api.policies.getMyCompliance(); setMfaCompliance(compliance); @@ -263,14 +263,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { persistMfaCompliance(null); } - navigate('/profile'); - }, [navigate]); + await checkOrgAdmin(); + if (!skipNavigate) { + navigate('/profile'); + } + }, [navigate, checkOrgAdmin]); const logout = useCallback(async () => { try { await api.auth.logout(); } finally { setUser(null); + setIsOrgAdmin(false); + setIsOrgMember(false); setMfaCompliance(null); persistMfaCompliance(null); setRequiresMfaEnrollment(false); @@ -284,6 +289,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { user, isLoading, isAuthenticated: !!user, + isOrgAdmin, + isOrgMember, mfaCompliance, requiresMfaEnrollment, login, @@ -292,6 +299,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { logout, refreshUser, refreshCompliance, + checkOrgAdmin, }} > {children} diff --git a/src/contexts/OrgContext.tsx b/src/contexts/OrgContext.tsx new file mode 100644 index 0000000..3dd3479 --- /dev/null +++ b/src/contexts/OrgContext.tsx @@ -0,0 +1,76 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Organization } from "@/lib/api"; +import { useOrganizations } from "@/hooks/useOrganizations"; +import { useAuth } from "@/contexts/AuthContext"; + +interface OrgContextType { + /** The currently selected organisation (null while loading or no memberships). */ + selectedOrg: Organization | null; + /** Programmatically switch the active organisation and invalidate all org-scoped queries. */ + selectOrg: (org: Organization) => void; + /** Convenience accessor for the selected org's ID. */ + selectedOrgId: string | null; +} + +const OrgContext = createContext(null); + +export function OrgProvider({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth(); + const { data: organizations = [] } = useOrganizations(); + const queryClient = useQueryClient(); + const [selectedOrg, setSelectedOrg] = useState(null); + + // Auto-select the first org once the list arrives (or when the list changes + // and the previously selected org is no longer present, e.g. after deletion). + useEffect(() => { + if (!isAuthenticated) { + setSelectedOrg(null); + return; + } + if (organizations.length === 0) return; + + setSelectedOrg((prev) => { + // Keep the current selection if it still exists in the updated list. + if (prev && organizations.some((o) => o.id === prev.id)) { + // Refresh the object in case name/role changed. + return organizations.find((o) => o.id === prev.id) ?? prev; + } + return organizations[0]; + }); + }, [organizations, isAuthenticated]); + + const selectOrg = useCallback( + (org: Organization) => { + setSelectedOrg(org); + // Invalidate all organisation-scoped React Query caches so every page + // immediately re-fetches data for the newly selected org. + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + // Invalidate any queries keyed by the previous org id. The broadest + // approach is to remove all non-organisations queries so pages reload. + queryClient.invalidateQueries(); + }, + [queryClient], + ); + + return ( + + {children} + + ); +} + +export function useOrg(): OrgContextType { + const ctx = useContext(OrgContext); + if (!ctx) throw new Error("useOrg must be used inside "); + return ctx; +} diff --git a/src/hooks/useCurrentOrganization.ts b/src/hooks/useCurrentOrganization.ts new file mode 100644 index 0000000..1851a14 --- /dev/null +++ b/src/hooks/useCurrentOrganization.ts @@ -0,0 +1,34 @@ +import { useParams } from "react-router-dom"; +import { useOrganizations } from "@/hooks/useOrganizations"; +import { useOrg } from "@/contexts/OrgContext"; + +/** + * Custom hook to get the current organization from URL params, or the + * globally-selected org from OrgContext (set via the TopBar org switcher). + */ +export function useCurrentOrganization() { + const params = useParams<{ orgId?: string }>(); + const { data: organizations = [], isLoading } = useOrganizations(); + const { selectedOrg } = useOrg(); + + // If orgId is in the URL params, use that specific org. + if (params.orgId) { + return { + org: organizations.find((org) => org.id === params.orgId) ?? organizations[0] ?? null, + isLoading, + }; + } + + // Otherwise use the org selected via the TopBar switcher (falls back to + // organizations[0] when OrgContext hasn't initialised yet). + return { org: selectedOrg ?? organizations[0] ?? null, isLoading }; +} + +/** + * Get the organization ID from URL params or the globally-selected org. + * Also returns isLoading so callers can distinguish "no org" from "still loading". + */ +export function useCurrentOrganizationId(): { orgId: string | null; isLoading: boolean } { + const { org, isLoading } = useCurrentOrganization(); + return { orgId: org?.id || null, isLoading }; +} diff --git a/src/hooks/useOrganizations.ts b/src/hooks/useOrganizations.ts index 1b561d1..5e47309 100644 --- a/src/hooks/useOrganizations.ts +++ b/src/hooks/useOrganizations.ts @@ -14,11 +14,8 @@ export function useOrganizations() { return useQuery({ queryKey: ["organizations"], queryFn: async () => { - console.log('[useOrganizations] Fetching organizations...'); const response = await api.users.organizations(); - console.log('[useOrganizations] Response:', response); - console.log('[useOrganizations] Organizations array:', response.organizations); - return response.organizations; + return Array.isArray(response.organizations) ? response.organizations : []; }, // Only fetch when user is authenticated enabled: isAuthenticated, diff --git a/src/lib/api.ts b/src/lib/api.ts index 2458b1b..08a2c8d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -25,6 +25,10 @@ export interface User { last_login_at: string | null; created_at: string; updated_at: string; + // Fields present in admin list view + org_role?: string; + org_id?: string; + activated?: boolean; } export interface Organization { @@ -80,6 +84,12 @@ export interface LoginResponse { requires_webauthn?: boolean; requires_mfa_enrollment?: boolean; mfa_compliance?: MfaComplianceSummary; + /** Set on login when the user belongs to no organisations. */ + requires_org_setup?: boolean; + /** Pending invitations for the user's email (present when requires_org_setup is true). */ + pending_invites?: PendingInvite[]; + /** True when the registering user is the very first user on this instance. */ + is_first_user?: boolean; } export interface TotpEnrollResponse { @@ -167,6 +177,25 @@ export interface LinkedAccountsResponse { unlink_available: boolean; } +export interface PrincipalOption { + id: string; + name: string; + description: string | null; +} + +export interface MyPrincipalsOrg { + org_id: string; + org_name: string; + role: string; + is_admin: boolean; + my_principals: PrincipalOption[]; + all_principals: PrincipalOption[]; // populated for admin/owner only +} + +export interface MyPrincipalsResponse { + orgs: MyPrincipalsOrg[]; +} + export interface OAuthAuthorizeResponse { authorization_url: string; state: string; @@ -211,42 +240,30 @@ export const tokenManager = { if (token && expiry) { const expiryDate = new Date(expiry); if (expiryDate <= new Date()) { - console.log('[TokenManager] Token expired, clearing'); tokenManager.clearToken(); return null; } } - if (token) { - console.log('[TokenManager] Token retrieved:', token.substring(0, 20) + '...'); - } else { - console.log('[TokenManager] No token found in localStorage'); - } - return token; }, setToken: (token: string, expiresAt?: string | null): void => { - console.log('[TokenManager] Setting token, expiresAt:', expiresAt); localStorage.setItem(TOKEN_KEY, token); if (expiresAt) { localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt); } else { localStorage.removeItem(TOKEN_EXPIRY_KEY); } - console.log('[TokenManager] Token set successfully'); }, clearToken: (): void => { - console.log('[TokenManager] Clearing token from localStorage'); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); }, hasValidToken: (): boolean => { - const hasToken = tokenManager.getToken() !== null; - console.log('[TokenManager] hasValidToken:', hasToken); - return hasToken; + return tokenManager.getToken() !== null; }, }; @@ -292,9 +309,6 @@ async function request( const token = tokenManager.getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; - console.log('[API] Added Authorization header for endpoint:', endpoint); - } else { - console.log('[API] WARNING: No token available for authenticated endpoint:', endpoint); } } @@ -370,6 +384,55 @@ export const api = { return response; }, + register: async (email: string, password: string, full_name?: string): Promise => { + const response = await request('/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password, password_confirm: password, full_name }), + }, false); + + if (response.token) { + tokenManager.setToken(response.token, response.expires_at ?? null); + } + + return response; + }, + + forgotPassword: (email: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }, false), + + resetPassword: (token: string, password: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, password, password_confirm: password }), + }, false), + + verifyEmail: (token: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/verify-email', { + method: 'POST', + body: JSON.stringify({ token }), + }, false), + + resendVerification: (email: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }, false), + + activate: (activation_key: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/activate', { + method: 'POST', + body: JSON.stringify({ activation_key }), + }, false), + + resendActivation: (email: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/resend-activation', { + method: 'POST', + body: JSON.stringify({ email }), + }, false), + logout: async (): Promise => { try { await request('/auth/logout', { @@ -392,9 +455,17 @@ export const api = { body: JSON.stringify(data), }), + // Delete the current user's own account (soft delete) + deleteMe: (requestConfig?: RequestConfig) => + request<{ message: string }>('/users/me', { method: 'DELETE' }, true, requestConfig), + organizations: (requestConfig?: RequestConfig) => request('/users/me/organizations', {}, true, requestConfig), + // Get the current user's effective principals across all orgs + myPrincipals: (requestConfig?: RequestConfig) => + request('/users/me/principals', {}, true, requestConfig), + // Password change can return 401 for wrong current password - don't clear token changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) => request<{ message: string }>('/users/me/password', { @@ -405,6 +476,101 @@ export const api = { new_password_confirm: newPasswordConfirm, }), }, true, { clearTokenOn401: false }), + + // Get audit logs for the currently authenticated user + auditLogs: (params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number }>( + `/auth/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), + + // Get pending (unaccepted) invitations for the logged-in user + getMyInvites: (requestConfig?: RequestConfig) => + request<{ invites: PendingInvite[] }>('/users/me/invites', {}, true, requestConfig), + + // Get the current user's department + principal memberships across all orgs + getMyMemberships: (requestConfig?: RequestConfig) => + request<{ orgs: MyOrgMembership[] }>('/users/me/memberships', {}, true, requestConfig), + }, + + admin: { + // Get all system audit logs (admin view — returns all logs for org owners, own logs otherwise) + getAuditLogs: (params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number; is_admin_view: boolean }>( + `/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), + + // List users visible to the calling admin + listUsers: (params?: Record, requestConfig?: RequestConfig) => + request<{ users: User[]; count: number; page: number; per_page: number; pages: number }>( + `/admin/users${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), + + // Get a single user's profile + SSH keys (admin view) + getUser: (userId: string, requestConfig?: RequestConfig) => + request<{ user: User; ssh_keys: SSHKey[] }>(`/admin/users/${userId}`, {}, true, requestConfig), + + // Update a user's role in a shared org (admin action) + updateUserRole: (orgId: string, userId: string, role: string, requestConfig?: RequestConfig) => + request<{ member: OrganizationMember }>(`/organizations/${orgId}/members/${userId}/role`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }, true, requestConfig), + + // List application-level OAuth provider configurations + listOAuthProviders: (requestConfig?: RequestConfig) => + request<{ providers: { id: string; name: string; is_configured: boolean; is_enabled: boolean; client_id: string | null }[] }>( + '/admin/oauth/providers', {}, true, requestConfig, + ), + + // Create or update an application-level OAuth provider + configureOAuthProvider: (provider: string, clientId: string, clientSecret: string, isEnabled: boolean, requestConfig?: RequestConfig) => + request<{ provider: { id: string; client_id: string; is_enabled: boolean } }>( + `/admin/oauth/providers/${provider}`, + { method: 'PUT', body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, is_enabled: isEnabled }) }, + true, + requestConfig, + ), + + // Delete an application-level OAuth provider + deleteOAuthProvider: (provider: string, requestConfig?: RequestConfig) => + request>(`/admin/oauth/providers/${provider}`, { method: 'DELETE' }, true, requestConfig), + + // Suspend a user account (blocks login & CA issuance) + suspendUser: (userId: string, requestConfig?: RequestConfig) => + request<{ user: User }>(`/admin/users/${userId}/suspend`, { method: 'POST' }, true, requestConfig), + + // Restore a suspended user to active status + unsuspendUser: (userId: string, requestConfig?: RequestConfig) => + request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, requestConfig), + + // Permanently delete a user — revokes certs, cascades DB delete, unrecoverable + hardDeleteUser: (userId: string, requestConfig?: RequestConfig) => + request<{ deleted_user_id: string; deleted_user_email: string; ssh_keys_deleted: number; certs_revoked: number }>( + `/admin/users/${userId}/delete`, + { method: 'POST', body: JSON.stringify({ confirm: true }) }, + true, + requestConfig, + ), + + // Get the cert policy for a department + getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, requestConfig), + + // Create or update the cert policy for a department + setDeptCertPolicy: (orgId: string, deptId: string, policy: Partial, requestConfig?: RequestConfig) => + request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, { + method: 'PUT', + body: JSON.stringify(policy), + }, true, requestConfig), }, totp: { @@ -635,8 +801,477 @@ export const api = { credentials: 'include', }), }, + + organizations: { + // Create a new organization (caller becomes owner) + create: (name: string, slug: string, description?: string) => + request<{ organization: Organization }>('/organizations', { + method: 'POST', + body: JSON.stringify({ name, slug, description }), + }, true), + + // Get organization by ID + getById: (orgId: string, requestConfig?: RequestConfig) => + request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig), + + // Delete an organization (owner only; must have no other members) + deleteOrganization: (orgId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}`, { method: 'DELETE' }, true, requestConfig), + + // Get organization members + getMembers: (orgId: string, requestConfig?: RequestConfig) => + request<{ members: OrganizationMember[]; count: number }>(`/organizations/${orgId}/members`, {}, true, requestConfig), + + // Add member to organization + addMember: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) => + request<{ member: OrganizationMember }>(`/organizations/${orgId}/members`, { + method: 'POST', + body: JSON.stringify({ email, role }), + }, true, requestConfig), + + // Remove member from organization + removeMember: (orgId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/members/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Update member role + updateMemberRole: (orgId: string, userId: string, role: string, requestConfig?: RequestConfig) => + request<{ member: OrganizationMember }>(`/organizations/${orgId}/members/${userId}/role`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }, true, requestConfig), + + // Get organization audit logs + getAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number }>( + `/organizations/${orgId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig + ), + + // Get departments + getDepartments: (orgId: string, requestConfig?: RequestConfig) => + request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig), + + // Create department + createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + request<{ department: Department }>(`/organizations/${orgId}/departments`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }, true, requestConfig), + + // Update department + 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), + }, true, requestConfig), + + // Delete department + deleteDepartment: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/departments/${deptId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get department members + getDepartmentMembers: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ members: DepartmentMember[]; count: number }>(`/organizations/${orgId}/departments/${deptId}/members`, {}, true, requestConfig), + + // Add member to department + addDepartmentMember: (orgId: string, deptId: string, email: string, requestConfig?: RequestConfig) => + request<{ member: DepartmentMember }>(`/organizations/${orgId}/departments/${deptId}/members`, { + method: 'POST', + body: JSON.stringify({ email }), + }, true, requestConfig), + + // Remove member from department + removeDepartmentMember: (orgId: string, deptId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/departments/${deptId}/members/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get principals + getPrincipals: (orgId: string, requestConfig?: RequestConfig) => + request<{ principals: Principal[]; count: number }>(`/organizations/${orgId}/principals`, {}, true, requestConfig), + + // Create principal + createPrincipal: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + request<{ principal: Principal }>(`/organizations/${orgId}/principals`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }, true, requestConfig), + + // Update principal + updatePrincipal: (orgId: string, principalId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + request<{ principal: Principal }>(`/organizations/${orgId}/principals/${principalId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + + // Delete principal + deletePrincipal: (orgId: string, principalId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get principal members + getPrincipalMembers: (orgId: string, principalId: string, requestConfig?: RequestConfig) => + request<{ members: PrincipalMember[]; count: number }>(`/organizations/${orgId}/principals/${principalId}/members`, {}, true, requestConfig), + + // Add member to principal + addPrincipalMember: (orgId: string, principalId: string, email: string, requestConfig?: RequestConfig) => + request<{ member: PrincipalMember }>(`/organizations/${orgId}/principals/${principalId}/members`, { + method: 'POST', + body: JSON.stringify({ email }), + }, true, requestConfig), + + // Remove member from principal + removePrincipalMember: (orgId: string, principalId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/members/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Link principal to department + linkPrincipalToDepartment: (orgId: string, principalId: string, departmentId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/departments/${departmentId}`, { + method: 'POST', + }, true, requestConfig), + + // Unlink principal from department + unlinkPrincipalFromDepartment: (orgId: string, principalId: string, departmentId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/departments/${departmentId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get departments linked to a principal + getPrincipalDepartments: (orgId: string, principalId: string, requestConfig?: RequestConfig) => + request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/principals/${principalId}/departments`, {}, true, requestConfig), + + // Get principals linked to a department + getDepartmentPrincipals: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ principals: Principal[]; count: number }>(`/organizations/${orgId}/departments/${deptId}/principals`, {}, true, requestConfig), + + // Create invite token + createInvite: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) => + request<{ invite: OrgInvite }>(`/organizations/${orgId}/invites`, { + method: 'POST', + body: JSON.stringify({ email, role }), + }, true, requestConfig), + + // List pending invites for an organization + getInvites: (orgId: string, requestConfig?: RequestConfig) => + request<{ invites: OrgInvite[] }>(`/organizations/${orgId}/invites`, {}, true, requestConfig), + + // Cancel (delete) an invite + cancelInvite: (orgId: string, inviteId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/invites/${inviteId}`, { + method: 'DELETE', + }, true, requestConfig), + + // List OIDC clients + getClients: (orgId: string, requestConfig?: RequestConfig) => + request<{ clients: OIDCClient[]; count: number }>(`/organizations/${orgId}/clients`, {}, true, requestConfig), + + // Create OIDC client + createClient: (orgId: string, name: string, redirect_uris: string[], requestConfig?: RequestConfig) => + request<{ client: OIDCClientWithSecret }>(`/organizations/${orgId}/clients`, { + method: 'POST', + body: JSON.stringify({ name, redirect_uris }), + }, true, requestConfig), + + // Delete OIDC client + deleteClient: (orgId: string, clientId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/clients/${clientId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Send MFA reminder to a member + sendMfaReminder: (orgId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, { + method: 'POST', + }, true, requestConfig), + + // Transfer organization ownership to another member + transferOwnership: (orgId: string, newOwnerUserId: string, requestConfig?: RequestConfig) => + request<{ previous_owner: OrganizationMember; new_owner: OrganizationMember }>( + `/organizations/${orgId}/transfer-ownership`, + { method: 'POST', body: JSON.stringify({ new_owner_user_id: newOwnerUserId }) }, + true, + requestConfig, + ), + + // List Certificate Authorities for an org + getCAs: (orgId: string, requestConfig?: RequestConfig) => + request<{ cas: OrgCA[]; count: number }>(`/organizations/${orgId}/cas`, {}, true, requestConfig), + + // Create a new Certificate Authority for an org + createCA: (orgId: string, data: { name: string; description?: string; ca_type?: 'user' | 'host'; key_type?: 'ed25519' | 'rsa' | 'ecdsa'; default_cert_validity_hours?: number; max_cert_validity_hours?: number }, requestConfig?: RequestConfig) => + request<{ ca: OrgCA }>(`/organizations/${orgId}/cas`, { + method: 'POST', + body: JSON.stringify(data), + }, true, requestConfig), + + // Update CA configuration + updateCA: (orgId: string, caId: string, data: { default_cert_validity_hours?: number; max_cert_validity_hours?: number }, requestConfig?: RequestConfig) => + request<{ ca: OrgCA }>(`/organizations/${orgId}/cas/${caId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + + // Rotate (replace) a CA's key pair — returns updated CA + old_fingerprint + rotateCA: (orgId: string, caId: string, data?: { key_type?: 'ed25519' | 'rsa' | 'ecdsa'; reason?: string }, requestConfig?: RequestConfig) => + request<{ ca: OrgCA; old_fingerprint: string }>(`/organizations/${orgId}/cas/${caId}/rotate`, { + method: 'POST', + body: JSON.stringify(data ?? {}), + }, true, requestConfig), + + // Soft-delete a CA + deleteCA: (orgId: string, caId: string, requestConfig?: RequestConfig) => + request<{ ca_id: string }>(`/organizations/${orgId}/cas/${caId}`, { + method: 'DELETE', + }, true, requestConfig), + }, + + invites: { + // Get invite details by token (unauthenticated) + getInfo: (token: string) => + request<{ email: string; organization: { id: string; name: string }; role: string; user_exists?: boolean }>( + `/invites/${token}`, + {}, + false, + ), + + // Accept invite (unauthenticated) — password/name only needed for new accounts + accept: (token: string, full_name?: string, password?: string) => + request( + `/invites/${token}/accept`, + { + method: 'POST', + body: JSON.stringify({ full_name, password, password_confirm: password }), + }, + false, + ), + }, + + ssh: { + // List all SSH keys for the current user + listKeys: (requestConfig?: RequestConfig) => + request('/ssh/keys', {}, true, requestConfig), + + // Add a new SSH public key + addKey: (public_key: string, description?: string, requestConfig?: RequestConfig) => + request('/ssh/keys', { + method: 'POST', + body: JSON.stringify({ public_key, description }), + }, true, requestConfig), + + // Delete an SSH key + deleteKey: (keyId: string, requestConfig?: RequestConfig) => + request<{ status: string }>(`/ssh/keys/${keyId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Update SSH key description + updateKeyDescription: (keyId: string, description: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/${keyId}/update-description`, { + method: 'PATCH', + body: JSON.stringify({ description }), + }, true, requestConfig), + + // Get a verification challenge for a key + getChallenge: (keyId: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/${keyId}/verify`, {}, true, requestConfig), + + // Submit signature to verify key ownership + verifyKey: (keyId: string, signature: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/${keyId}/verify`, { + method: 'POST', + body: JSON.stringify({ signature, action: 'verify_signature' }), + }, true, requestConfig), + + // Sign a certificate for the given key + signCertificate: (key_id: string, principals?: string[], cert_type?: 'user' | 'host', expiry_hours?: number, requestConfig?: RequestConfig) => + request('/ssh/sign', { + method: 'POST', + body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }), + }, true, requestConfig), + + // Get the merged department certificate policy for the current user (used in sign dialog) + getMyDeptCertPolicy: (requestConfig?: RequestConfig) => + request<{ policy: DeptCertPolicy }>('/ssh/dept-cert-policy', {}, true, requestConfig), + + // List issued certificates for the current user + listCertificates: (requestConfig?: RequestConfig) => + request<{ certificates: SSHCertificate[]; count: number }>('/ssh/certificates', {}, true, requestConfig), + + // Get a single certificate (includes full cert text) + getCertificate: (certId: string, requestConfig?: RequestConfig) => + request(`/ssh/certificates/${certId}`, {}, true, requestConfig), + + // Revoke a certificate + revokeCertificate: (certId: string, reason?: string, requestConfig?: RequestConfig) => + request<{ status: string; cert_id: string; reason: string }>(`/ssh/certificates/${certId}/revoke`, { + method: 'POST', + body: JSON.stringify({ reason }), + }, true, requestConfig), + + // Get the CA public key for the current user's org + getCaPublicKey: (requestConfig?: RequestConfig) => + request<{ public_key: string; fingerprint: string; ca_name: string; source: string }>('/ssh/ca/public-key', {}, true, requestConfig), + + // Add SSH key on behalf of another user (admin) + adminAddKey: (userId: string, public_key: string, description?: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/admin/${userId}`, { + method: 'POST', + body: JSON.stringify({ public_key, description }), + }, true, requestConfig), + + // List CA permissions for a CA + listCaPermissions: (caId: string, requestConfig?: RequestConfig) => + request<{ ca_id: string; permissions: CAPermission[]; open_to_all: boolean }>(`/ssh/ca/${caId}/permissions`, {}, true, requestConfig), + + // Grant a user permission on a CA + addCaPermission: (caId: string, user_id: string, permission: 'sign' | 'admin', requestConfig?: RequestConfig) => + request<{ message: string; permission: CAPermission }>(`/ssh/ca/${caId}/permissions`, { + method: 'POST', + body: JSON.stringify({ user_id, permission }), + }, true, requestConfig), + + // Revoke a user's CA permission + removeCaPermission: (caId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/ssh/ca/${caId}/permissions/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + }, }; +// Organization types +export interface OrganizationMember { + id: string; + user_id: string; + organization_id: string; + role: string; + created_at: string; + updated_at: string; + user?: User; +} + +export interface AuditLogEntry { + id: string; + action: string; + user_id: string | null; + organization_id: string | null; + resource_type: string | null; + resource_id: string | null; + ip_address: string | null; + user_agent: string | null; + request_id: string | null; + description: string | null; + success: boolean; + error_message: string | null; + metadata?: Record; + created_at: string; + updated_at: string; + user?: User; +} + +export interface Department { + id: string; + organization_id: string; + name: string; + description: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export const STANDARD_SSH_EXTENSIONS = [ + 'permit-X11-forwarding', + 'permit-agent-forwarding', + 'permit-pty', + 'permit-port-forwarding', + 'permit-user-rc', +] as const; + +export interface DeptCertPolicy { + department_id: string; + allow_user_expiry: boolean; + default_expiry_hours: number; + max_expiry_hours: number; + allowed_extensions: string[]; + custom_extensions: string[]; + all_extensions?: string[]; + standard_extensions?: string[]; +} + +export interface PendingInvite { + token: string; + organization: { id: string; name: string }; + role: string; + expires_at: string; +} + +export interface MyOrgMembership { + org_id: string; + org_name: string; + role: string; + departments: { id: string; name: string; description: string | null }[]; + principals: { id: string; name: string; description: string | null; via_department: boolean }[]; +} + +export interface DepartmentMember { + id: string; + user_id: string; + department_id: string; + created_at: string; + updated_at: string; + user?: User; +} + +export interface Principal { + id: string; + organization_id: string; + name: string; + description: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface PrincipalMember { + id: string; + user_id: string; + principal_id: string; + created_at: string; + updated_at: string; + user?: User; +} + +export interface OrgInvite { + id: string; + email: string; + role: string; + expires_at: string; + invite_link?: string; // only present on create response (dev/when email disabled) +} + +export interface OIDCClient { + id: string; + name: string; + client_id: string; + redirect_uris: string[]; + scopes: string[]; + grant_types: string[]; + is_active: boolean; + created_at: string; +} + +export interface OIDCClientWithSecret extends OIDCClient { + client_secret: string; +} + // Policy types export interface OrgPolicyResponse { security_policy: { @@ -673,6 +1308,98 @@ export interface OrgComplianceMember { export { ApiError }; +// SSH Key types +export interface SSHKey { + id: string; + user_id: string; + public_key: string; + description: string | null; + key_type: string | null; + fingerprint: string | null; + verified: boolean; + verified_at: string | null; + created_at: string; + updated_at: string; +} + +export interface SSHKeysResponse { + keys: SSHKey[]; + count: number; +} + +export interface SSHChallengeResponse { + challenge_text: string; + validationText: string; + key_id: string; +} + +export interface SSHVerifyResponse { + verified: boolean; +} + +export interface SSHCertificate { + id: string; + user_id: string; + ssh_key_id: string | null; + certificate: string; + serial: number | null; + key_id: string | null; + cert_type: string; + principals: string[]; + valid_after: string; + valid_before: string; + revoked: boolean; + status: string; + created_at: string; +} + +export interface SSHSignResponse { + certificate: string; + serial: number; + principals: string[]; + valid_after: string; + valid_before: string; + cert_id?: string; +} + +export interface CAPermission { + id: string; + ca_id: string; + user_id: string; + user_email: string | null; + permission: 'sign' | 'admin'; + created_at: string; +} + +export interface OrgCA { + id: string; + organization_id: string | null; + name: string; + description: string | null; + ca_type: 'user' | 'host'; + key_type: string; + public_key: string; + fingerprint: string; + is_active: boolean; + /** True when this entry represents the server-wide config-file CA. + * System CAs are read-only — they cannot be edited, deleted, or replaced + * from the UI. */ + is_system?: boolean; + default_cert_validity_hours: number; + max_cert_validity_hours: number; + total_certs: number; + active_certs: number; + revoked_certs: number; + /** Next serial number that will be assigned when a certificate is issued. */ + next_serial_number: number | null; + created_at: string | null; + updated_at: string | null; + /** Set when the key was last rotated. */ + rotated_at: string | null; + /** Reason provided when the key was last rotated. */ + rotation_reason: string | null; +} + // Reusable 403 error handler for API calls // Shows a user-friendly toast message when access is denied export function create403Handler(toastFn: (options: { title: string; description: string; variant: "destructive" }) => void) { diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 1deb5bb..8741efc 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,13 +1,21 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/contexts/AuthContext"; const Index = () => { const navigate = useNavigate(); + const { isAuthenticated, isOrgMember, isLoading } = useAuth(); useEffect(() => { - // Redirect to login for now - will be replaced with auth check - navigate("/login"); - }, [navigate]); + if (isLoading) return; // Wait for auth check to complete + + if (isAuthenticated) { + // If the user has no org yet, send them to the org-setup page first + navigate(isOrgMember ? "/profile" : "/org-setup", { replace: true }); + } else { + navigate("/login"); + } + }, [isLoading, isAuthenticated, isOrgMember, navigate]); return null; }; diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..8a80188 --- /dev/null +++ b/src/pages/admin/AdminUsersPage.tsx @@ -0,0 +1,733 @@ +import { useState, useCallback, useEffect } from "react"; +import { + Search, + User, + CheckCircle, + XCircle, + Key, + Loader2, + Plus, + ChevronRight, + ShieldCheck, + Shield, + Ban, + UserCheck, + AlertTriangle, + Trash2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { api, User as ApiUser, SSHKey, ApiError } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; + +function formatDate(d: string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +function isSuspended(status: string | undefined) { + return status === "suspended" || status === "compliance_suspended"; +} + +function RoleBadge({ role }: { role: string }) { + const r = (role || "").toLowerCase(); + if (r === "owner") { + return ( + + Owner + + ); + } + if (r === "admin") { + return ( + + Admin + + ); + } + return ( + + Member + + ); +} + +export default function AdminUsersPage() { + const { toast } = useToast(); + const { user: currentUser } = useAuth(); + + // User list + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState("all"); + + // Debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(t); + }, [search]); + + // User detail drawer + const [selectedUser, setSelectedUser] = useState(null); + const [userSshKeys, setUserSshKeys] = useState([]); + const [isDrawerLoading, setIsDrawerLoading] = useState(false); + + // Role update + const [isUpdatingRole, setIsUpdatingRole] = useState(false); + + // Admin add SSH key dialog + const [showAddKey, setShowAddKey] = useState(false); + const [addKeyPublicKey, setAddKeyPublicKey] = useState(""); + const [addKeyDescription, setAddKeyDescription] = useState(""); + const [isAddingKey, setIsAddingKey] = useState(false); + const [addKeyError, setAddKeyError] = useState(null); + + // Suspend / unsuspend + const [isSuspending, setIsSuspending] = useState(false); + const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); + + // Hard delete + const [showHardDelete, setShowHardDelete] = useState(false); + const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState(""); + const [isHardDeleting, setIsHardDeleting] = useState(false); + + // ── Fetch users ───────────────────────────────────────────────────────────── + const fetchUsers = useCallback(async (q: string, pg: number) => { + setIsLoading(true); + try { + const params: Record = { page: String(pg), per_page: "50" }; + if (q) params.q = q; + const data = await api.admin.listUsers(params); + setUsers(data.users); + setTotal(data.count); + setPages(data.pages); + } catch (err) { + if (err instanceof ApiError && err.code === 403) { + toast({ + variant: "destructive", + title: "Access denied", + description: "Admin or owner role required to view all users.", + }); + } else { + toast({ variant: "destructive", title: "Failed to load users" }); + } + } finally { + setIsLoading(false); + } + }, [toast]); + + useEffect(() => { + setPage(1); + fetchUsers(debouncedSearch, 1); + }, [debouncedSearch, fetchUsers]); + + useEffect(() => { + fetchUsers(debouncedSearch, page); + }, [page]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Open user drawer ───────────────────────────────────────────────────────── + const openUserDrawer = async (user: ApiUser) => { + setSelectedUser(user); + setUserSshKeys([]); + setIsDrawerLoading(true); + try { + const data = await api.admin.getUser(user.id); + setUserSshKeys(data.ssh_keys); + } catch { + // Non-fatal — drawer still shows basic user info + } finally { + setIsDrawerLoading(false); + } + }; + + // ── Update role ────────────────────────────────────────────────────────────── + const handleRoleChange = async (newRole: string) => { + if (!selectedUser || !selectedUser.org_id) return; + setIsUpdatingRole(true); + try { + await api.admin.updateUserRole(selectedUser.org_id, selectedUser.id, newRole.toUpperCase()); + const updated = { ...selectedUser, org_role: newRole }; + setSelectedUser(updated); + setUsers((prev) => + prev.map((u) => (u.id === selectedUser.id ? { ...u, org_role: newRole } : u)) + ); + toast({ + title: "Role updated", + description: `${selectedUser.full_name || selectedUser.email} is now a ${newRole}.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to update role", + description: err instanceof ApiError ? err.message : "Something went wrong", + }); + } finally { + setIsUpdatingRole(false); + } + }; + + // ── Admin add SSH key ──────────────────────────────────────────────────────── + const handleAddKey = async () => { + if (!selectedUser) return; + setAddKeyError(null); + if (!addKeyPublicKey.trim()) { + setAddKeyError("Public key is required"); + return; + } + setIsAddingKey(true); + try { + const key = await api.ssh.adminAddKey(selectedUser.id, addKeyPublicKey.trim(), addKeyDescription.trim() || undefined); + setUserSshKeys((prev) => [...prev, key]); + toast({ title: "SSH key added", description: `Key added for ${selectedUser.email}` }); + setShowAddKey(false); + setAddKeyPublicKey(""); + setAddKeyDescription(""); + } catch (err) { + setAddKeyError(err instanceof ApiError ? err.message : "Failed to add key"); + } finally { + setIsAddingKey(false); + } + }; + + // ── Suspend / Unsuspend user ───────────────────────────────────────────────── + const handleSuspend = async () => { + if (!selectedUser) return; + setIsSuspending(true); + try { + const data = await api.admin.suspendUser(selectedUser.id); + const updated = { ...selectedUser, status: data.user.status }; + setSelectedUser(updated); + setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u)); + setShowSuspendConfirm(false); + toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` }); + } catch (err) { + setShowSuspendConfirm(false); + if (err instanceof ApiError && err.type === "OWNER_PROTECTION") { + toast({ + variant: "destructive", + title: "Cannot suspend organization owner", + description: "Transfer ownership to another member before suspending this account.", + }); + } else { + toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); + } + } finally { + setIsSuspending(false); + } + }; + + const handleUnsuspend = async () => { + if (!selectedUser) return; + setIsSuspending(true); + try { + const data = await api.admin.unsuspendUser(selectedUser.id); + const updated = { ...selectedUser, status: data.user.status }; + setSelectedUser(updated); + setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u)); + toast({ title: "User unsuspended", description: `${selectedUser.full_name || selectedUser.email} is now active.` }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to unsuspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); + } finally { + setIsSuspending(false); + } + }; + + // ── Hard delete user ───────────────────────────────────────────────────────── + const handleHardDelete = async () => { + if (!selectedUser) return; + setIsHardDeleting(true); + try { + const result = await api.admin.hardDeleteUser(selectedUser.id); + setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id)); + setTotal((t) => t - 1); + setShowHardDelete(false); + setSelectedUser(null); + toast({ + title: "User permanently deleted", + description: `${result.deleted_user_email} — ${result.certs_revoked} cert(s) revoked, ${result.ssh_keys_deleted} key(s) deleted.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to delete user", + description: err instanceof ApiError ? err.message : "Something went wrong", + }); + } finally { + setIsHardDeleting(false); + } + }; + + // Filter by role client-side + const filteredUsers = users.filter((u) => { + if (roleFilter === "all") return true; + const r = (u.org_role || "member").toLowerCase(); + return r === roleFilter; + }); + + // ────────────────────────────────────────────────────────────────────────────── + return ( +
+
+

User Management

+

+ View and manage users across your organizations +

+
+ + {/* Search + filter bar */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + + + + + Users + {!isLoading && {total}} + + Click a user to view details and manage their role or SSH keys + + + {isLoading ? ( +
+ +
+ ) : filteredUsers.length === 0 ? ( +
+ +

{debouncedSearch ? "No users match your search" : "No users found"}

+
+ ) : ( +
+ {filteredUsers.map((user) => ( + + ))} +
+ )} + + {/* Pagination */} + {pages > 1 && ( +
+

+ Page {page} of {pages} · {total} total +

+
+ + +
+
+ )} +
+
+ + {/* ── User detail drawer ─────────────────────────────────────────────────── */} + { if (!open) setSelectedUser(null); }}> + + {selectedUser && ( + <> + + + + {selectedUser.full_name || selectedUser.email} + + {selectedUser.email} + + + {/* Basic info */} +
+
+ Status + + {isSuspended(selectedUser.status) ? ( + <>Suspended{selectedUser.status === "compliance_suspended" ? " (compliance)" : ""} + ) : ( + <>Active + )} + + Joined + {formatDate(selectedUser.created_at)} + Activated + + {selectedUser.activated === false ? ( + <> No + ) : ( + <> Yes + )} + + Last login + {formatDate(selectedUser.last_login_at)} +
+
+ + {/* Suspend / Unsuspend — only for other users */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Account Access +

+ {isSuspended(selectedUser.status) ? ( +
+

+ {selectedUser.status === "compliance_suspended" + ? "This account is suspended due to MFA compliance. The user cannot log in or request certificates." + : "This account is suspended. The user cannot log in or request certificates."} +

+ +
+ ) : ( +
+

Suspending blocks this user from logging in and requesting SSH certificates.

+ +
+ )} +
+ )} + + {/* Role management — only if not viewing yourself and user has org_id */} + {selectedUser.org_id && selectedUser.id !== currentUser?.id && ( +
+

+ + Organization Role +

+
+ + {isUpdatingRole && } +
+ {(selectedUser.org_role || "").toLowerCase() === "owner" && ( +

Owner role cannot be changed here. Transfer ownership from the Members page.

+ )} +
+ )} + + {/* SSH Keys section */} +
+
+

+ + SSH Keys +

+ +
+ + {isDrawerLoading ? ( +
+ +
+ ) : userSshKeys.length === 0 ? ( +
+ No SSH keys registered +
+ ) : ( +
+ {userSshKeys.map((k) => ( +
+
+ {k.description || No description} + {k.verified ? ( + + Verified + + ) : ( + + Unverified + + )} +
+
+ {k.fingerprint ?? k.public_key.slice(0, 64) + "…"} +
+
+ Added {formatDate(k.created_at)} +
+
+ ))} +
+ )} +
+ + {/* Danger zone — Hard delete */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Danger Zone +

+

+ Permanently delete this account. This cannot be undone — all SSH keys and certificates will be revoked immediately. +

+ +
+ )} + + )} +
+
+ + {/* ── Admin add SSH key dialog ───────────────────────────────────────────── */} + { setShowAddKey(open); setAddKeyError(null); }}> + + + Add SSH Key for {selectedUser?.email} + + Add an SSH public key on behalf of this user (admin action). + + +
+ {addKeyError && ( +
{addKeyError}
+ )} +
+ +