diff --git a/src/App.tsx b/src/App.tsx index 8017e78..48f91ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; // Layouts import PublicLayout from "@/components/layouts/PublicLayout"; -import AuthenticatedLayout from "@/components/layouts/AuthenticatedLayout"; +import ProtectedLayout from "@/components/layouts/ProtectedLayout"; // Public pages import Index from "@/pages/Index"; @@ -29,13 +29,27 @@ import ActivityPage from "@/pages/user/ActivityPage"; import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; import MembersPage from "@/pages/org/MembersPage"; 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 NotFound from "@/pages/NotFound"; import ApiDevTools from "@/components/dev/ApiDevTools"; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + // Don't retry on 403 authorization errors + if (error && typeof error === 'object' && 'code' in error && error.code === 403) { + return false; + } + // Default retry behavior for other errors (max 3 retries) + return failureCount < 3; + }, + }, + }, +}); const App = () => ( @@ -71,8 +85,8 @@ function AppRoutes() { } /> - {/* Authenticated routes */} - }> + {/* Protected routes - handles auth and MFA enforcement */} + }> {/* User routes */} } /> } /> @@ -83,6 +97,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/auth/ComplianceBanner.tsx b/src/components/auth/ComplianceBanner.tsx new file mode 100644 index 0000000..c00c190 --- /dev/null +++ b/src/components/auth/ComplianceBanner.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react'; +import { AlertTriangle, Clock, CheckCircle } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { MfaComplianceSummary, isMfaRequired } from '@/lib/api'; + +interface ComplianceBannerProps { + compliance: MfaComplianceSummary | null; +} + +export function ComplianceBanner({ compliance }: ComplianceBannerProps) { + const [countdown, setCountdown] = useState(null); + + // Calculate countdown from deadline + useEffect(() => { + if (!compliance?.deadline_at) { + setCountdown(null); + return; + } + + const deadline = new Date(compliance.deadline_at); + const now = new Date(); + + if (deadline <= now) { + setCountdown('Deadline passed'); + return; + } + + const updateCountdown = () => { + const remaining = deadline.getTime() - Date.now(); + + if (remaining <= 0) { + setCountdown('Deadline passed'); + return; + } + + const days = Math.floor(remaining / (1000 * 60 * 60 * 24)); + const hours = Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) { + setCountdown(`${days} day${days > 1 ? 's' : ''} remaining`); + } else if (hours > 0) { + setCountdown(`${hours} hour${hours > 1 ? 's' : ''} remaining`); + } else { + setCountdown(`${minutes} minute${minutes > 1 ? 's' : ''} remaining`); + } + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 60000); // Update every minute + + return () => clearInterval(interval); + }, [compliance?.deadline_at]); + + // Check if MFA is required based on effective_mode (if available) + const mfaRequired = isMfaRequired(compliance); + + // Don't show if no compliance data or already compliant + if (!compliance || compliance.overall_status === 'compliant' || + compliance.overall_status === 'not_applicable') { + return null; + } + + // Show banner if: + // 1. MFA is required (effective_mode starts with "require_"), OR + // 2. There are missing methods (fallback for older data without effective_mode) + if (!mfaRequired && compliance.missing_methods.length === 0) { + return null; + } + + // Past due - high severity + if (compliance.overall_status === 'past_due' || compliance.overall_status === 'suspended') { + return ( + + + Multi-Factor Authentication Required + +
+

+ Your account requires MFA enrollment to access full features. + Please configure MFA immediately to restore access. +

+ {compliance.missing_methods.length > 0 && ( +

+ Required methods: {compliance.missing_methods.join(', ')} +

+ )} +
+
+
+ ); + } + + // In grace period - warning + if (compliance.overall_status === 'in_grace') { + return ( + + + MFA Enrollment Required + +
+

+ Your organization requires multi-factor authentication. Please configure MFA before the deadline. +

+ {countdown && ( +

+ Time remaining: {countdown} +

+ )} + {compliance.missing_methods.length > 0 && ( +

+ Required methods: {compliance.missing_methods.join(', ')} +

+ )} +
+
+
+ ); + } + + // Pending - info + if (compliance.overall_status === 'pending') { + return ( + + + MFA Policy Applied + +
+

+ Your organization has enabled MFA requirements. You have a grace period to configure your authentication methods. +

+ {compliance.missing_methods.length > 0 && ( +

+ Required methods: {compliance.missing_methods.join(', ')} +

+ )} +
+
+
+ ); + } + + return null; +} \ No newline at end of file diff --git a/src/components/layouts/MfaEnforcementLayout.tsx b/src/components/layouts/MfaEnforcementLayout.tsx new file mode 100644 index 0000000..29d4102 --- /dev/null +++ b/src/components/layouts/MfaEnforcementLayout.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Shield, Smartphone, Fingerprint, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAuth } from '@/contexts/AuthContext'; +import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard'; +import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard'; +import { api } from '@/lib/api'; + +export default function MfaEnforcementLayout() { + const navigate = useNavigate(); + const { user, mfaCompliance, refreshCompliance } = useAuth(); + const [showTotpEnrollment, setShowTotpEnrollment] = useState(false); + const [showPasskeyEnrollment, setShowPasskeyEnrollment] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const [isCompliant, setIsCompliant] = useState(false); + + // Check compliance status on mount and after enrollment + useEffect(() => { + const checkCompliance = async () => { + setIsChecking(true); + try { + const compliance = await api.policies.getMyCompliance(); + if (compliance.overall_status === 'compliant') { + setIsCompliant(true); + } else { + setIsCompliant(false); + } + } catch (error) { + console.error('[MfaEnforcementLayout] Failed to check compliance:', error); + setIsCompliant(false); + } finally { + setIsChecking(false); + } + }; + + checkCompliance(); + }, []); + + // Redirect when compliant + useEffect(() => { + if (isCompliant) { + const timer = setTimeout(() => { + navigate('/profile'); + }, 2000); + return () => clearTimeout(timer); + } + }, [isCompliant, navigate]); + + const handleTotpSuccess = async () => { + setShowTotpEnrollment(false); + await refreshCompliance(); + const compliance = await api.policies.getMyCompliance(); + setIsCompliant(compliance.overall_status === 'compliant'); + }; + + const handlePasskeySuccess = async () => { + setShowPasskeyEnrollment(false); + await refreshCompliance(); + const compliance = await api.policies.getMyCompliance(); + setIsCompliant(compliance.overall_status === 'compliant'); + }; + + // Show success state + if (isCompliant) { + return ( +
+ + +
+
+ +
+

+ MFA Configured Successfully +

+

+ Your account is now compliant. Redirecting to your profile... +

+ +
+
+
+
+ ); + } + + // Determine which MFA methods are required + const missingMethods = mfaCompliance?.missing_methods || []; + const requiresTotp = missingMethods.includes('totp'); + const requiresPasskey = missingMethods.includes('webauthn'); + + return ( +
+ {/* Header - similar to TopBar but without sidebar */} +
+
+ + Gatehouse +
+
+ + {user?.email} + +
+
+ + {/* Main content */} +
+ + +
+ +
+ MFA Enrollment Required + + Your account is restricted until you configure multi-factor authentication. + Please set up at least one of the following methods to continue. + +
+ + {/* Deadline info */} + {mfaCompliance?.deadline_at && ( +
+

+ Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()} +

+
+ )} + + {/* TOTP Option */} + {requiresTotp && ( +
+
+
+ +
+
+

Authenticator App

+

+ Set up an authenticator app (Google Authenticator, Authy, etc.) +

+
+
+ +
+ )} + + {/* Passkey Option */} + {requiresPasskey && ( +
+
+
+ +
+
+

Passkey

+

+ Register a passkey (biometrics, security key, or device passkey) +

+
+
+ +
+ )} + + {/* Both methods available */} + {(!requiresTotp && !requiresPasskey) && ( +
+
+
+ +
+
+

Configure MFA

+

+ Set up multi-factor authentication to secure your account +

+
+
+
+ + +
+
+ )} + + {/* Loading state while checking */} + {isChecking && ( +
+ + Checking compliance status... +
+ )} +
+
+
+ + {/* Enrollment Wizards */} + + + +
+ ); +} diff --git a/src/components/layouts/ProtectedLayout.tsx b/src/components/layouts/ProtectedLayout.tsx new file mode 100644 index 0000000..2df3e4e --- /dev/null +++ b/src/components/layouts/ProtectedLayout.tsx @@ -0,0 +1,34 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import AuthenticatedLayout from './AuthenticatedLayout'; +import MfaEnforcementLayout from './MfaEnforcementLayout'; +import { Loader2 } from 'lucide-react'; + +export default function ProtectedLayout() { + const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth(); + + if (isLoading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (requiresMfaEnrollment) { + return ; + } + + return ( + + + + ); +} diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index b83ada3..ffd3e5a 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -13,40 +13,31 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useAuth } from "@/contexts/AuthContext"; -import { api, Organization } from "@/lib/api"; +import { Organization } from "@/lib/api"; +import { useOrganizations } from "@/hooks/useOrganizations"; +import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; export function TopBar() { const navigate = useNavigate(); - const { user, isAuthenticated } = useAuth(); - const [organizations, setOrganizations] = useState([]); + const { user, isAuthenticated, mfaCompliance } = useAuth(); const [currentOrg, setCurrentOrg] = useState(null); - const [orgsLoading, setOrgsLoading] = useState(true); + + // 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(() => { - async function fetchOrgs() { - console.log('[TopBar] fetchOrgs called, isAuthenticated:', isAuthenticated); - if (!isAuthenticated) { - console.log('[TopBar] Not authenticated, skipping organizations fetch'); - setOrgsLoading(false); - return; - } - - try { - console.log('[TopBar] Making api.users.organizations() request'); - const response = await api.users.organizations(); - console.log('[TopBar] Organizations fetched successfully:', response.organizations.length); - setOrganizations(response.organizations); - if (response.organizations.length > 0 && !currentOrg) { - setCurrentOrg(response.organizations[0]); - } - } catch (error) { - console.error("[TopBar] Failed to fetch organizations:", error); - } finally { - setOrgsLoading(false); - } + if (organizationsArray.length > 0 && !currentOrg) { + setCurrentOrg(organizationsArray[0]); } - fetchOrgs(); - }, [isAuthenticated, currentOrg]); + }, [organizationsArray, currentOrg]); const handleLogout = () => { navigate("/login"); @@ -57,103 +48,106 @@ export function TopBar() { : user?.email?.slice(0, 2).toUpperCase() || "U"; return ( -
- {/* Left side - Sidebar toggle */} -
- - - -
+
+ +
+ {/* Left side - Sidebar toggle */} +
+ + + +
- {/* Right side - Org selector + User menu */} -
- {/* Organization Selector */} - - - - - - - Switch Organization - - - {orgsLoading ? ( -
- -
- ) : organizations.length === 0 ? ( -
- No organizations -
- ) : ( - organizations.map((org) => ( - setCurrentOrg(org)} - className="flex items-center justify-between" - > -
- - {org.name} -
- {org.role && ["owner", "admin"].includes(org.role) && ( - - {org.role} - - )} -
- )) - )} -
-
- - {/* User Menu */} - - - - - - -
- {user?.full_name || "User"} - - {user?.email} + {/* Right side - Org selector + User menu */} +
+ {/* Organization Selector */} + + +
- - - navigate("/profile")}> - - Profile - - navigate("/security")}> - - Security - - - - - Log out - - - + + + + + + Switch Organization + + + {orgsLoading ? ( +
+ +
+ ) : organizationsArray.length === 0 ? ( +
+ No organizations +
+ ) : ( + organizationsArray.map((org) => ( + setCurrentOrg(org)} + className="flex items-center justify-between" + > +
+ + {org.name} +
+ {org.role && ["owner", "admin"].includes(org.role) && ( + + {org.role} + + )} +
+ )) + )} +
+ + + {/* User Menu */} + + + + + + +
+ {user?.full_name || "User"} + + {user?.email} + +
+
+ + navigate("/profile")}> + + Profile + + navigate("/security")}> + + Security + + + + + Log out + +
+
+
); -} +} \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 106155d..88c9d00 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,28 +1,90 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; -import { api, User, ApiError, tokenManager } from '@/lib/api'; +import { api, User, ApiError, tokenManager, MfaComplianceSummary } from '@/lib/api'; interface LoginResult { requiresTotp: boolean; + requiresMfaEnrollment?: boolean; } interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; + mfaCompliance: MfaComplianceSummary | null; + requiresMfaEnrollment: boolean; login: (email: string, password: string, rememberMe?: boolean) => Promise; verifyTotp: (code: string, isBackupCode?: boolean) => Promise; logout: () => Promise; refreshUser: () => Promise; + refreshCompliance: () => Promise; } const AuthContext = createContext(null); +// LocalStorage key for MFA compliance persistence +const MFA_COMPLIANCE_KEY = 'gatehouse_mfa_compliance'; + +// Helper to persist MFA compliance to localStorage +function persistMfaCompliance(compliance: MfaComplianceSummary | null): void { + if (compliance) { + localStorage.setItem(MFA_COMPLIANCE_KEY, JSON.stringify(compliance)); + } else { + localStorage.removeItem(MFA_COMPLIANCE_KEY); + } +} + +// Helper to load MFA compliance from localStorage +function loadMfaCompliance(): MfaComplianceSummary | null { + try { + const stored = localStorage.getItem(MFA_COMPLIANCE_KEY); + if (!stored) return null; + + const compliance = JSON.parse(stored); + + // Validate that the stored data has the required fields + 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) { + return null; + } + + return compliance; + } catch { + return null; + } +} + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [mfaCompliance, setMfaCompliance] = useState(loadMfaCompliance); + const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false); const [isLoading, setIsLoading] = useState(true); const navigate = useNavigate(); + const refreshCompliance = useCallback(async () => { + try { + const compliance = await api.policies.getMyCompliance(); + setMfaCompliance(compliance); + persistMfaCompliance(compliance); + + // Check if user is now compliant + if (compliance.overall_status === 'compliant' && requiresMfaEnrollment) { + setRequiresMfaEnrollment(false); + navigate('/profile'); + } + } catch (error) { + console.error('[AuthContext] Failed to refresh compliance:', error); + } + }, [requiresMfaEnrollment, navigate]); + const refreshUser = useCallback(async () => { try { const response = await api.users.me(); @@ -30,6 +92,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (error) { if (error instanceof ApiError && error.code === 401) { setUser(null); + setMfaCompliance(null); + persistMfaCompliance(null); + setRequiresMfaEnrollment(false); } // Silently fail for other errors during refresh } @@ -41,6 +106,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Only attempt to fetch user if we have a valid token if (!tokenManager.hasValidToken()) { setUser(null); + setMfaCompliance(null); + persistMfaCompliance(null); + setRequiresMfaEnrollment(false); setIsLoading(false); return; } @@ -48,8 +116,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const response = await api.users.me(); setUser(response.user); + + // Also fetch compliance status + try { + const compliance = await api.policies.getMyCompliance(); + setMfaCompliance(compliance); + persistMfaCompliance(compliance); + setRequiresMfaEnrollment(compliance.overall_status === 'suspended'); + } catch { + // Compliance fetch failed, continue without it + setMfaCompliance(null); + persistMfaCompliance(null); + } } catch { setUser(null); + setMfaCompliance(null); + persistMfaCompliance(null); + setRequiresMfaEnrollment(false); } finally { setIsLoading(false); } @@ -61,7 +144,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const login = useCallback(async (email: string, password: string, rememberMe = false): Promise => { console.log('[AuthContext] login() called'); const response = await api.auth.login(email, password, rememberMe); - console.log('[AuthContext] login response:', { requires_totp: response.requires_totp, hasToken: !!response.token, hasUser: !!response.user }); + console.log('[AuthContext] login response:', { + requires_totp: response.requires_totp, + requires_mfa_enrollment: response.requires_mfa_enrollment, + hasToken: !!response.token, + hasUser: !!response.user + }); // If TOTP is required, don't set user yet - wait for TOTP verification if (response.requires_totp) { @@ -69,6 +157,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { requiresTotp: true }; } + // 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); + } + if (response.user) { + setUser(response.user); + } + if (response.mfa_compliance) { + setMfaCompliance(response.mfa_compliance); + persistMfaCompliance(response.mfa_compliance); + } + setRequiresMfaEnrollment(true); + return { requiresTotp: 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()) @@ -82,6 +187,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { 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'); } return { requiresTotp: false }; @@ -97,6 +207,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { } setUser(response.user); + + // Check for MFA compliance in response + try { + const compliance = await api.policies.getMyCompliance(); + setMfaCompliance(compliance); + persistMfaCompliance(compliance); + setRequiresMfaEnrollment(compliance.overall_status === 'suspended'); + } catch { + setMfaCompliance(null); + persistMfaCompliance(null); + } + navigate('/profile'); }, [navigate]); @@ -105,6 +227,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { await api.auth.logout(); } finally { setUser(null); + setMfaCompliance(null); + persistMfaCompliance(null); + setRequiresMfaEnrollment(false); navigate('/login'); } }, [navigate]); @@ -115,10 +240,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { user, isLoading, isAuthenticated: !!user, + mfaCompliance, + requiresMfaEnrollment, login, verifyTotp, logout, refreshUser, + refreshCompliance, }} > {children} diff --git a/src/hooks/useOrganizations.ts b/src/hooks/useOrganizations.ts new file mode 100644 index 0000000..1b561d1 --- /dev/null +++ b/src/hooks/useOrganizations.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, Organization, ApiError } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; + +/** + * Custom hook for fetching user organizations using React Query. + * Provides automatic caching and deduplication of API calls. + * + * @returns Query result with organizations data, loading state, and error + */ +export function useOrganizations() { + const { isAuthenticated } = useAuth(); + + 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; + }, + // Only fetch when user is authenticated + enabled: isAuthenticated, + // Cache data for 5 minutes (300,000ms) before considering it stale + staleTime: 5 * 60 * 1000, + // Keep cached data in memory for 10 minutes (600,000ms) + gcTime: 10 * 60 * 1000, + // Don't retry on 403 errors (handled by QueryClient default config) + retry: (failureCount, error) => { + if (error instanceof ApiError && error.code === 403) { + return false; + } + return failureCount < 3; + }, + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 47dbcdb..d5abaf3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -44,11 +44,41 @@ export interface OrganizationsResponse { count: number; } +export interface MfaComplianceOrgSummary { + organization_id: string; + organization_name: string; + status: string; + deadline_at: string | null; + effective_mode: string; + applied_at: string; +} + +export interface MfaComplianceSummary { + overall_status: string; + missing_methods: string[]; + deadline_at: string | null; + orgs: MfaComplianceOrgSummary[]; +} + +/** + * Check if MFA is required for the user based on their compliance status. + * This checks if any organization has an effective_mode that starts with "require_", + * which handles require_webauthn, require_totp, or any future MFA methods. + */ +export function isMfaRequired(compliance: MfaComplianceSummary | null): boolean { + if (!compliance || !compliance.orgs) return false; + return compliance.orgs.some( + org => org.effective_mode && org.effective_mode.startsWith('require_') + ); +} + export interface LoginResponse { user?: User; token?: string; expires_at?: string; requires_totp?: boolean; + requires_mfa_enrollment?: boolean; + mfa_compliance?: MfaComplianceSummary; } export interface TotpEnrollResponse { @@ -176,12 +206,16 @@ const SESSION_INVALID_ERROR_TYPES = [ 'UNAUTHORIZED', ]; +export const AUTHORIZATION_ERROR_TYPES = ['AUTHORIZATION_ERROR'] as const; + interface RequestConfig { // Controls token clearing on 401: // - 'auto' (default): Clear only if error type indicates invalid session // - true: Always clear token on 401 // - false: Never clear token on 401 clearTokenOn401?: boolean | 'auto'; + // Optional callback for handling 403 authorization errors + on403?: (error: ApiError) => void; } // Central request function - all API calls go through here @@ -191,7 +225,7 @@ async function request( requiresAuth = true, requestConfig: RequestConfig = {} ): Promise { - const { clearTokenOn401 = 'auto' } = requestConfig; + const { clearTokenOn401 = 'auto', on403 } = requestConfig; const headers: Record = { 'Content-Type': 'application/json', @@ -238,6 +272,21 @@ async function request( console.log(`[API] 401 received but token preserved (type: ${errorType}, endpoint: ${endpoint})`); } } + + // Handle 403 authorization errors + if (json.code === 403) { + const error = new ApiError( + json.message || 'Access denied', + json.code, + errorType, + json.error?.details || {} + ); + + if (on403) { + on403(error); + } + throw error; + } throw new ApiError( json.message || 'An error occurred', @@ -290,7 +339,8 @@ export const api = { body: JSON.stringify(data), }), - organizations: () => request('/users/me/organizations'), + organizations: (requestConfig?: RequestConfig) => + request('/users/me/organizations', {}, true, requestConfig), // Password change can return 401 for wrong current password - don't clear token changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) => @@ -454,6 +504,79 @@ export const api = { method: 'DELETE', }), }, + + policies: { + // Get organization security policy + getOrgPolicy: (orgId: string, requestConfig?: RequestConfig) => + request(`/organizations/${orgId}/security-policy`, {}, true, requestConfig), + + // Update organization security policy + updateOrgPolicy: (orgId: string, body: UpdateOrgPolicyDto, requestConfig?: RequestConfig) => + request(`/organizations/${orgId}/security-policy`, { + method: 'PUT', + body: JSON.stringify(body), + }, true, requestConfig), + + // List organization compliance (paginated) + listOrgCompliance: (orgId: string, params: Record, requestConfig?: RequestConfig) => + request( + `/organizations/${orgId}/mfa-compliance?${new URLSearchParams(params)}`, + {}, + true, + requestConfig + ), + + // Get current user's MFA compliance summary + getMyCompliance: () => + request('/users/me/mfa-compliance'), + }, }; +// Policy types +export interface OrgPolicyResponse { + security_policy: { + organization_id: string; + mfa_policy_mode: string; + mfa_grace_period_days: number; + notify_days_before: number; + policy_version: number; + }; +} + +export interface UpdateOrgPolicyDto { + mfa_policy_mode: string; + mfa_grace_period_days: number; + notify_days_before: number; +} + +export interface OrgCompliancePage { + members: OrgComplianceMember[]; + count: number; + page: number; + page_size: number; +} + +export interface OrgComplianceMember { + user_id: string; + user_email: string; + user_name: string; + status: string; + deadline_at: string | null; + compliant_at: string | null; + last_notified_at: string | null; +} + export { ApiError }; + +// 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) { + return (error: ApiError) => { + console.warn('[API] 403 Access Denied:', error.message); + toastFn({ + title: "Access Denied", + description: "You don't have permission to view this section. Please contact your organization administrator.", + variant: "destructive", + }); + }; +} \ No newline at end of file diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index a0f0b4a..3e92088 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2 } from "lucide-react"; +import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useAuth } from "@/contexts/AuthContext"; import { api, ApiError, tokenManager } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; @@ -20,8 +21,10 @@ import { formatLoginAssertion, WebAuthnLoginOptions, } from "@/lib/webauthn"; +import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; +import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; -type LoginStep = 'credentials' | 'totp' | 'passkey-email'; +type LoginStep = 'credentials' | 'totp' | 'passkey-email' | 'mfa-enrollment'; export default function LoginPage() { const { login, verifyTotp, refreshUser } = useAuth(); @@ -45,8 +48,11 @@ export default function LoginPage() { if (result.requiresTotp) { setStep('totp'); setTotpCode(""); + } else if (result.requiresMfaEnrollment) { + // MFA enrollment required - will be handled by ProtectedLayout + // Navigation happens in AuthContext } - // If no TOTP required, navigation happens in AuthContext + // If no TOTP or MFA enrollment required, navigation happens in AuthContext } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] Login failed:", error); @@ -204,6 +210,77 @@ export default function LoginPage() { } }; + // MFA enrollment step - shows when user needs to configure MFA + if (step === 'mfa-enrollment') { + const [showTotpEnrollment, setShowTotpEnrollment] = useState(false); + const [showPasskeyEnrollment, setShowPasskeyEnrollment] = useState(false); + + return ( +
+
+
+ +
+

+ MFA Enrollment Required +

+

+ Your account requires multi-factor authentication to access full features. +

+
+ + + + Configure MFA + + Set up at least one authentication method to continue + + + + + + + + +

+ After configuring MFA, you'll be redirected to your profile. +

+ + { + setShowTotpEnrollment(false); + navigate('/profile'); + }} + /> + + { + setShowPasskeyEnrollment(false); + navigate('/profile'); + }} + /> +
+ ); + } + // Passkey email entry step if (step === 'passkey-email') { return ( diff --git a/src/pages/org/CompliancePage.tsx b/src/pages/org/CompliancePage.tsx new file mode 100644 index 0000000..e6ed660 --- /dev/null +++ b/src/pages/org/CompliancePage.tsx @@ -0,0 +1,277 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { Shield, Search, Filter, Loader2, User, Clock, AlertTriangle, CheckCircle, Mail, ExternalLink } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { api, OrgComplianceMember, create403Handler } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; + +const STATUS_CONFIG: Record = { + compliant: { + label: "Compliant", + color: "bg-success/10 text-success border-success/20", + icon: CheckCircle, + }, + in_grace: { + label: "In Grace", + color: "bg-primary/10 text-primary border-primary/20", + icon: Clock, + }, + past_due: { + label: "Past Due", + color: "bg-warning/10 text-warning border-warning/20", + icon: AlertTriangle, + }, + suspended: { + label: "Suspended", + color: "bg-destructive/10 text-destructive border-destructive/20", + icon: AlertTriangle, + }, + pending: { + label: "Pending", + color: "bg-muted text-muted-foreground", + icon: Clock, + }, + not_applicable: { + label: "Not Applicable", + color: "bg-muted text-muted-foreground", + icon: Shield, + }, +}; + +export default function CompliancePage() { + const navigate = useNavigate(); + const { toast } = useToast(); + const [currentOrgId, setCurrentOrgId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + // Fetch organizations to get current org + const { data: orgsData, isLoading: orgsLoading } = useQuery({ + queryKey: ['organizations'], + queryFn: () => api.users.organizations({ + on403: create403Handler(toast), + }), + }); + + useEffect(() => { + if (orgsData?.organizations && orgsData.organizations.length > 0) { + setCurrentOrgId(orgsData.organizations[0].id); + } + }, [orgsData]); + + // Fetch compliance data + const { data: complianceData, isLoading: complianceLoading } = useQuery({ + queryKey: ['org-compliance', currentOrgId], + queryFn: () => currentOrgId ? api.policies.listOrgCompliance(currentOrgId, {}, { + on403: create403Handler(toast), + }) : null, + enabled: !!currentOrgId, + }); + + // Filter members based on search and status + const filteredMembers = complianceData?.members?.filter((member) => { + const matchesSearch = + member.user_email.toLowerCase().includes(searchQuery.toLowerCase()) || + member.user_name?.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesStatus = statusFilter === "all" || member.status === statusFilter; + + return matchesSearch && matchesStatus; + }) || []; + + // Calculate stats + const stats = { + total: complianceData?.count || 0, + compliant: complianceData?.members?.filter(m => m.status === 'compliant').length || 0, + inGrace: complianceData?.members?.filter(m => m.status === 'in_grace').length || 0, + pastDue: complianceData?.members?.filter(m => m.status === 'past_due').length || 0, + suspended: complianceData?.members?.filter(m => m.status === 'suspended').length || 0, + }; + + if (orgsLoading || complianceLoading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+

MFA Compliance

+

+ Monitor and manage multi-factor authentication compliance for organization members +

+
+ + {/* Stats Overview */} +
+ + +
+

{stats.total}

+

Total Members

+
+
+
+ + +
+

{stats.compliant}

+

Compliant

+
+
+
+ + +
+

{stats.inGrace}

+

In Grace

+
+
+
+ + +
+

{stats.pastDue}

+

Past Due

+
+
+
+ + +
+

{stats.suspended}

+

Suspended

+
+
+
+
+ + {/* Filters */} + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ +
+
+
+
+ + {/* Members Table */} + + + Members + + {filteredMembers.length} of {complianceData?.count || 0} members shown + + + + {filteredMembers.length === 0 ? ( +
+ +

No members found matching your criteria

+
+ ) : ( +
+ {filteredMembers.map((member) => { + const config = STATUS_CONFIG[member.status] || STATUS_CONFIG.pending; + const StatusIcon = config.icon; + + return ( +
+
+
+ +
+
+

+ {member.user_name || "Unknown"} +

+

+ {member.user_email} +

+
+
+ +
+ {member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && ( +
+ Deadline: + {new Date(member.deadline_at).toLocaleDateString()} +
+ )} + + + + {config.label} + + +
+ + +
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/org/PoliciesPage.tsx b/src/pages/org/PoliciesPage.tsx index 347ee92..ee6ce72 100644 --- a/src/pages/org/PoliciesPage.tsx +++ b/src/pages/org/PoliciesPage.tsx @@ -1,4 +1,6 @@ -import { Shield, Lock, Fingerprint, Smartphone, UserPlus, AlertTriangle } from "lucide-react"; +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { Shield, Lock, Fingerprint, Smartphone, UserPlus, AlertTriangle, Loader2, Users, ExternalLink } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -6,8 +8,205 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Slider } from "@/components/ui/slider"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; + +const MFA_MODE_LABELS: Record = { + disabled: { + label: "Disabled", + description: "No MFA required for members", + }, + optional: { + label: "Optional", + description: "Members may opt-in to MFA", + }, + require_totp: { + label: "Require TOTP", + description: "All members must set up an authenticator app", + }, + require_webauthn: { + label: "Require Passkey", + description: "All members must register a passkey", + }, + require_totp_or_webauthn: { + label: "Require TOTP or Passkey", + description: "Members must set up at least one MFA method", + }, +}; export default function PoliciesPage() { + const navigate = useNavigate(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [currentOrgId, setCurrentOrgId] = useState(null); + + // Local form state for unsaved changes + const [formData, setFormData] = useState({ + mfa_policy_mode: '', + mfa_grace_period_days: 14, + notify_days_before: 7, + }); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // Fetch organizations to get current org + const { data: orgsData, isLoading: orgsLoading } = useQuery({ + queryKey: ['organizations'], + queryFn: () => api.users.organizations({ + on403: create403Handler(toast), + }), + }); + + useEffect(() => { + if (orgsData?.organizations && orgsData.organizations.length > 0) { + setCurrentOrgId(orgsData.organizations[0].id); + } + }, [orgsData]); + + // Fetch org policy + const { data: policy, isLoading: policyLoading } = useQuery({ + queryKey: ['org-policy', currentOrgId], + queryFn: () => currentOrgId ? api.policies.getOrgPolicy(currentOrgId, { + on403: create403Handler(toast), + }) : null, + enabled: !!currentOrgId, + }); + + useEffect(() => { + if (policy?.security_policy) { + setFormData({ + mfa_policy_mode: policy.security_policy.mfa_policy_mode, + mfa_grace_period_days: policy.security_policy.mfa_grace_period_days, + notify_days_before: policy.security_policy.notify_days_before, + }); + setHasUnsavedChanges(false); + } + }, [policy]); + + // Fetch org compliance summary + const { data: complianceData, isLoading: complianceLoading } = useQuery({ + queryKey: ['org-compliance', currentOrgId], + queryFn: () => currentOrgId ? api.policies.listOrgCompliance(currentOrgId, {}, { + on403: create403Handler(toast), + }) : null, + enabled: !!currentOrgId, + }); + + // Update policy mutation + const updatePolicyMutation = useMutation({ + mutationFn: async (data: UpdateOrgPolicyDto) => { + if (!currentOrgId) throw new Error('No organization selected'); + return api.policies.updateOrgPolicy(currentOrgId, data, { + on403: create403Handler(toast), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['org-policy', currentOrgId] }); + setHasUnsavedChanges(false); + toast({ + title: "Policy updated", + description: "Security policy has been updated successfully.", + }); + }, + onError: (error: Error) => { + toast({ + variant: "destructive", + title: "Failed to update policy", + description: error.message, + }); + }, + }); + + // Calculate compliance stats + const complianceStats = { + compliant: 0, + inGrace: 0, + pastDue: 0, + suspended: 0, + pending: 0, + total: complianceData?.count || 0, + }; + + if (complianceData?.members) { + for (const member of complianceData.members) { + switch (member.status) { + case 'compliant': + complianceStats.compliant++; + break; + case 'in_grace': + complianceStats.inGrace++; + break; + case 'past_due': + complianceStats.pastDue++; + break; + case 'suspended': + complianceStats.suspended++; + break; + case 'pending': + complianceStats.pending++; + break; + } + } + } + + const handleMfaModeChange = (mode: string) => { + setFormData(prev => ({ ...prev, mfa_policy_mode: mode })); + setHasUnsavedChanges(true); + }; + + const handleGracePeriodChange = (days: number[]) => { + setFormData(prev => ({ ...prev, mfa_grace_period_days: days[0] })); + setHasUnsavedChanges(true); + }; + + const handleNotifyDaysChange = (days: number[]) => { + setFormData(prev => ({ ...prev, notify_days_before: days[0] })); + setHasUnsavedChanges(true); + }; + + const handleSavePolicy = () => { + updatePolicyMutation.mutate({ + mfa_policy_mode: formData.mfa_policy_mode, + mfa_grace_period_days: formData.mfa_grace_period_days, + notify_days_before: formData.notify_days_before, + }); + }; + + const handleDiscardChanges = () => { + if (policy?.security_policy) { + setFormData({ + mfa_policy_mode: policy.security_policy.mfa_policy_mode, + mfa_grace_period_days: policy.security_policy.mfa_grace_period_days, + notify_days_before: policy.security_policy.notify_days_before, + }); + setHasUnsavedChanges(false); + } + }; + + if (orgsLoading || policyLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!currentOrgId || !policy) { + return ( +
+ + + + Unable to load organization policy. Please try again later. + + +
+ ); + } + return (
@@ -18,31 +217,161 @@ export default function PoliciesPage() {
- {/* Registration Mode */} + {/* Compliance Overview */} - - Registration Mode + + Compliance Overview - Control how new members can join your organization + Current MFA compliance status for organization members - -

- Invite only: Members can only join via admin invitation -

+
+
+

{complianceStats.compliant}

+

Compliant

+
+
+

{complianceStats.inGrace}

+

In Grace

+
+
+

{complianceStats.pastDue}

+

Past Due

+
+
+

{complianceStats.suspended}

+

Suspended

+
+
+

{complianceStats.total}

+

Total Members

+
+
+ +
+
+ + {/* MFA Policy */} + + + + + Multi-Factor Authentication + + + Require additional authentication methods for all members + + + + {hasUnsavedChanges && ( + + + + You have unsaved changes. Click "Save Changes" to apply them or "Discard" to revert. + + + )} +
+ + +

+ {MFA_MODE_LABELS[formData.mfa_policy_mode]?.description} +

+
+ + {formData.mfa_policy_mode !== 'disabled' && formData.mfa_policy_mode !== 'optional' && ( + <> + +
+
+ +
+ + {formData.mfa_grace_period_days} days +
+

+ Members will have this many days to configure MFA after policy is applied. +

+
+ +
+ +
+ + {formData.notify_days_before} days +
+

+ Send reminder notifications this many days before the deadline. +

+
+
+ + )} + + {hasUnsavedChanges && ( +
+ + +
+ )}
@@ -100,40 +429,6 @@ export default function PoliciesPage() { - {/* MFA Requirements */} - - - - - Multi-Factor Authentication - - - Require additional authentication methods - - - -
-
- -

- All members must set up an authenticator app -

-
- -
- - - - - Enabling this will require all existing members to set up TOTP on their next login. - - -
-
- {/* Passkey Requirements */} @@ -160,4 +455,4 @@ export default function PoliciesPage() {
); -} +} \ No newline at end of file diff --git a/src/pages/user/ProfilePage.tsx b/src/pages/user/ProfilePage.tsx index 9860777..cea7865 100644 --- a/src/pages/user/ProfilePage.tsx +++ b/src/pages/user/ProfilePage.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/contexts/AuthContext"; import { api, Organization, ApiError } from "@/lib/api"; +import { useOrganizations } from "@/hooks/useOrganizations"; import { toast } from "@/hooks/use-toast"; function ProfileSkeleton() { @@ -73,8 +74,16 @@ export default function ProfilePage() { const [name, setName] = useState(""); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [organizations, setOrganizations] = useState([]); - const [orgsLoading, setOrgsLoading] = useState(true); + + // Use React Query hook for organizations with automatic caching and deduplication + const { data: organizations = [], isLoading: orgsLoading, error: orgsError } = useOrganizations(); + + // Debug logging + console.log('[ProfilePage] organizations data:', organizations); + console.log('[ProfilePage] organizations is array:', Array.isArray(organizations)); + + // Ensure organizations is always an array (defensive check) + const organizationsArray = Array.isArray(organizations) ? organizations : []; // Sync local name state with user data useEffect(() => { @@ -83,36 +92,16 @@ export default function ProfilePage() { } }, [user?.full_name]); - // Fetch organizations only when user is available + // Handle 403 errors for organizations useEffect(() => { - console.log('[ProfilePage] useEffect triggered, user:', user?.id); - if (!user) { - console.log('[ProfilePage] No user, skipping organizations fetch'); - setOrgsLoading(false); - return; + if (orgsError instanceof ApiError && orgsError.code === 403) { + toast({ + title: "Access Denied", + description: "You don't have permission to view organizations. Please contact your organization administrator.", + variant: "destructive", + }); } - - const fetchOrgs = async () => { - console.log('[ProfilePage] Making api.users.organizations() request'); - try { - const response = await api.users.organizations(); - console.log('[ProfilePage] Organizations fetched successfully:', response.organizations.length); - setOrganizations(response.organizations); - } catch (error) { - if (error instanceof ApiError) { - toast({ - title: "Error loading organizations", - description: error.message, - variant: "destructive", - }); - } - } finally { - setOrgsLoading(false); - } - }; - - fetchOrgs(); - }, [user]); + }, [orgsError]); const getInitials = (fullName: string | null) => { if (!fullName) return "?"; @@ -271,13 +260,13 @@ export default function ProfilePage() { - ) : organizations.length === 0 ? ( + ) : organizationsArray.length === 0 ? (

You're not a member of any organizations yet.

) : (
- {organizations.map((org) => ( + {organizationsArray.map((org) => (
{org.name}
- {org.role} +
+ {(org.role === 'owner' || org.role === 'admin') && ( + + Admin + + )} + {org.role} +
))} diff --git a/src/pages/user/SecurityPage.tsx b/src/pages/user/SecurityPage.tsx index 1f4c428..7f55314 100644 --- a/src/pages/user/SecurityPage.tsx +++ b/src/pages/user/SecurityPage.tsx @@ -11,6 +11,8 @@ import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; import { api, ApiError, TotpStatusResponse, PasskeyCredential } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; +import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; +import { useAuth } from "@/contexts/AuthContext"; import { AlertDialog, AlertDialogAction, @@ -49,6 +51,7 @@ export default function SecurityPage() { const [isDeleting, setIsDeleting] = useState(false); const { toast } = useToast(); + const { mfaCompliance } = useAuth(); // Policy requirements (could come from org settings in future) const policyRequirements = { @@ -228,6 +231,8 @@ export default function SecurityPage() {

+ +
{/* Policy Status */}