enabled policies

This commit is contained in:
2026-01-16 17:31:25 +10:30
parent 71c58ddb60
commit 4ee3b81074
13 changed files with 1582 additions and 219 deletions
+19 -4
View File
@@ -6,7 +6,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
// Layouts // Layouts
import PublicLayout from "@/components/layouts/PublicLayout"; import PublicLayout from "@/components/layouts/PublicLayout";
import AuthenticatedLayout from "@/components/layouts/AuthenticatedLayout"; import ProtectedLayout from "@/components/layouts/ProtectedLayout";
// Public pages // Public pages
import Index from "@/pages/Index"; import Index from "@/pages/Index";
@@ -29,13 +29,27 @@ import ActivityPage from "@/pages/user/ActivityPage";
import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; import OrgOverviewPage from "@/pages/org/OrgOverviewPage";
import MembersPage from "@/pages/org/MembersPage"; import MembersPage from "@/pages/org/MembersPage";
import PoliciesPage from "@/pages/org/PoliciesPage"; import PoliciesPage from "@/pages/org/PoliciesPage";
import CompliancePage from "@/pages/org/CompliancePage";
import OrgAuditPage from "@/pages/org/OrgAuditPage"; import OrgAuditPage from "@/pages/org/OrgAuditPage";
import OIDCClientsPage from "@/pages/org/OIDCClientsPage"; import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
import ApiDevTools from "@/components/dev/ApiDevTools"; 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 = () => ( const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -71,8 +85,8 @@ function AppRoutes() {
<Route path="/error" element={<OIDCErrorPage />} /> <Route path="/error" element={<OIDCErrorPage />} />
</Route> </Route>
{/* Authenticated routes */} {/* Protected routes - handles auth and MFA enforcement */}
<Route element={<AuthenticatedLayout />}> <Route element={<ProtectedLayout />}>
{/* User routes */} {/* User routes */}
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/security" element={<SecurityPage />} />
@@ -83,6 +97,7 @@ function AppRoutes() {
<Route path="/org" element={<OrgOverviewPage />} /> <Route path="/org" element={<OrgOverviewPage />} />
<Route path="/org/members" element={<MembersPage />} /> <Route path="/org/members" element={<MembersPage />} />
<Route path="/org/policies" element={<PoliciesPage />} /> <Route path="/org/policies" element={<PoliciesPage />} />
<Route path="/org/policies/compliance" element={<CompliancePage />} />
<Route path="/org/audit" element={<OrgAuditPage />} /> <Route path="/org/audit" element={<OrgAuditPage />} />
<Route path="/org/clients" element={<OIDCClientsPage />} /> <Route path="/org/clients" element={<OIDCClientsPage />} />
</Route> </Route>
+144
View File
@@ -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<string | null>(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 (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Multi-Factor Authentication Required</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p>
Your account requires MFA enrollment to access full features.
Please configure MFA immediately to restore access.
</p>
{compliance.missing_methods.length > 0 && (
<p className="text-sm">
Required methods: {compliance.missing_methods.join(', ')}
</p>
)}
</div>
</AlertDescription>
</Alert>
);
}
// In grace period - warning
if (compliance.overall_status === 'in_grace') {
return (
<Alert className="mb-4 border-warning/50 bg-warning/5">
<Clock className="h-4 w-4 text-warning" />
<AlertTitle className="text-warning">MFA Enrollment Required</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p>
Your organization requires multi-factor authentication. Please configure MFA before the deadline.
</p>
{countdown && (
<p className="text-sm font-medium">
Time remaining: {countdown}
</p>
)}
{compliance.missing_methods.length > 0 && (
<p className="text-sm">
Required methods: {compliance.missing_methods.join(', ')}
</p>
)}
</div>
</AlertDescription>
</Alert>
);
}
// Pending - info
if (compliance.overall_status === 'pending') {
return (
<Alert className="mb-4 border-primary/50 bg-primary/5">
<Clock className="h-4 w-4 text-primary" />
<AlertTitle>MFA Policy Applied</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p>
Your organization has enabled MFA requirements. You have a grace period to configure your authentication methods.
</p>
{compliance.missing_methods.length > 0 && (
<p className="text-sm">
Required methods: {compliance.missing_methods.join(', ')}
</p>
)}
</div>
</AlertDescription>
</Alert>
);
}
return null;
}
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
MFA Configured Successfully
</h2>
<p className="text-muted-foreground mb-4">
Your account is now compliant. Redirecting to your profile...
</p>
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
</div>
);
}
// Determine which MFA methods are required
const missingMethods = mfaCompliance?.missing_methods || [];
const requiresTotp = missingMethods.includes('totp');
const requiresPasskey = missingMethods.includes('webauthn');
return (
<div className="min-h-screen flex flex-col bg-background">
{/* Header - similar to TopBar but without sidebar */}
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-primary" />
<span className="font-semibold text-foreground">Gatehouse</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{user?.email}
</span>
</div>
</header>
{/* Main content */}
<div className="flex-1 flex items-center justify-center p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 rounded-full bg-warning/10 flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-warning" />
</div>
<CardTitle className="text-xl">MFA Enrollment Required</CardTitle>
<CardDescription>
Your account is restricted until you configure multi-factor authentication.
Please set up at least one of the following methods to continue.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Deadline info */}
{mfaCompliance?.deadline_at && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center">
<p className="text-sm font-medium text-destructive">
Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()}
</p>
</div>
)}
{/* TOTP Option */}
{requiresTotp && (
<div className="p-4 border rounded-lg space-y-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded bg-primary/10 flex items-center justify-center flex-shrink-0">
<Smartphone className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-medium text-foreground">Authenticator App</h3>
<p className="text-sm text-muted-foreground">
Set up an authenticator app (Google Authenticator, Authy, etc.)
</p>
</div>
</div>
<Button
onClick={() => setShowTotpEnrollment(true)}
className="w-full"
>
Set up Authenticator
</Button>
</div>
)}
{/* Passkey Option */}
{requiresPasskey && (
<div className="p-4 border rounded-lg space-y-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded bg-primary/10 flex items-center justify-center flex-shrink-0">
<Fingerprint className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-medium text-foreground">Passkey</h3>
<p className="text-sm text-muted-foreground">
Register a passkey (biometrics, security key, or device passkey)
</p>
</div>
</div>
<Button
onClick={() => setShowPasskeyEnrollment(true)}
className="w-full"
>
Add Passkey
</Button>
</div>
)}
{/* Both methods available */}
{(!requiresTotp && !requiresPasskey) && (
<div className="p-4 border rounded-lg space-y-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded bg-primary/10 flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-medium text-foreground">Configure MFA</h3>
<p className="text-sm text-muted-foreground">
Set up multi-factor authentication to secure your account
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
onClick={() => setShowTotpEnrollment(true)}
className="w-full"
>
<Smartphone className="w-4 h-4 mr-2" />
Authenticator
</Button>
<Button
variant="outline"
onClick={() => setShowPasskeyEnrollment(true)}
className="w-full"
>
<Fingerprint className="w-4 h-4 mr-2" />
Passkey
</Button>
</div>
</div>
)}
{/* Loading state while checking */}
{isChecking && (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Checking compliance status...</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* Enrollment Wizards */}
<TotpEnrollmentWizard
open={showTotpEnrollment}
onOpenChange={setShowTotpEnrollment}
onSuccess={handleTotpSuccess}
/>
<AddPasskeyWizard
open={showPasskeyEnrollment}
onOpenChange={setShowPasskeyEnrollment}
onSuccess={handlePasskeySuccess}
/>
</div>
);
}
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiresMfaEnrollment) {
return <MfaEnforcementLayout />;
}
return (
<AuthenticatedLayout>
<Outlet />
</AuthenticatedLayout>
);
}
+115 -121
View File
@@ -13,40 +13,31 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useAuth } from "@/contexts/AuthContext"; 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() { export function TopBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated, mfaCompliance } = useAuth();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null); const [currentOrg, setCurrentOrg] = useState<Organization | null>(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(() => { useEffect(() => {
async function fetchOrgs() { if (organizationsArray.length > 0 && !currentOrg) {
console.log('[TopBar] fetchOrgs called, isAuthenticated:', isAuthenticated); setCurrentOrg(organizationsArray[0]);
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);
}
} }
fetchOrgs(); }, [organizationsArray, currentOrg]);
}, [isAuthenticated, currentOrg]);
const handleLogout = () => { const handleLogout = () => {
navigate("/login"); navigate("/login");
@@ -57,102 +48,105 @@ export function TopBar() {
: user?.email?.slice(0, 2).toUpperCase() || "U"; : user?.email?.slice(0, 2).toUpperCase() || "U";
return ( return (
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0"> <header className="flex flex-col">
{/* Left side - Sidebar toggle */} <ComplianceBanner compliance={mfaCompliance} />
<div className="flex items-center gap-3"> <div className="h-14 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<SidebarTrigger className="text-muted-foreground hover:text-foreground"> {/* Left side - Sidebar toggle */}
<Menu className="w-5 h-5" /> <div className="flex items-center gap-3">
</SidebarTrigger> <SidebarTrigger className="text-muted-foreground hover:text-foreground">
</div> <Menu className="w-5 h-5" />
</SidebarTrigger>
</div>
{/* Right side - Org selector + User menu */} {/* Right side - Org selector + User menu */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Organization Selector */} {/* Organization Selector */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 h-9 px-3"> <Button variant="ghost" className="flex items-center gap-2 h-9 px-3">
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center"> <div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
<Building2 className="w-3.5 h-3.5 text-primary" /> <Building2 className="w-3.5 h-3.5 text-primary" />
</div> </div>
<span className="text-sm font-medium hidden sm:inline"> <span className="text-sm font-medium hidden sm:inline">
{orgsLoading ? "Loading..." : (currentOrg?.name || "No Organization")} {orgsLoading ? "Loading..." : (currentOrg?.name || "No Organization")}
</span>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground uppercase tracking-wider">
Switch Organization
</DropdownMenuLabel>
<DropdownMenuSeparator />
{orgsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : organizations.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-4">
No organizations
</div>
) : (
organizations.map((org) => (
<DropdownMenuItem
key={org.id}
onClick={() => setCurrentOrg(org)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span>{org.name}</span>
</div>
{org.role && ["owner", "admin"].includes(org.role) && (
<span className="text-xs bg-accent/10 text-accent px-1.5 py-0.5 rounded capitalize">
{org.role}
</span>
)}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
{/* User Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 h-9 px-2">
<Avatar className="w-7 h-7">
<AvatarImage src={user?.avatar_url || undefined} />
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
{userInitials}
</AvatarFallback>
</Avatar>
<ChevronDown className="w-4 h-4 text-muted-foreground hidden sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="font-medium">{user?.full_name || "User"}</span>
<span className="text-xs text-muted-foreground font-normal">
{user?.email}
</span> </span>
</div> <ChevronDown className="w-4 h-4 text-muted-foreground" />
</DropdownMenuLabel> </Button>
<DropdownMenuSeparator /> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => navigate("/profile")}> <DropdownMenuContent align="end" className="w-56">
<User className="w-4 h-4 mr-2" /> <DropdownMenuLabel className="text-xs text-muted-foreground uppercase tracking-wider">
Profile Switch Organization
</DropdownMenuItem> </DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigate("/security")}> <DropdownMenuSeparator />
<Shield className="w-4 h-4 mr-2" /> {orgsLoading ? (
Security <div className="flex items-center justify-center py-4">
</DropdownMenuItem> <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<DropdownMenuSeparator /> </div>
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive"> ) : organizationsArray.length === 0 ? (
<LogOut className="w-4 h-4 mr-2" /> <div className="text-sm text-muted-foreground text-center py-4">
Log out No organizations
</DropdownMenuItem> </div>
</DropdownMenuContent> ) : (
</DropdownMenu> organizationsArray.map((org) => (
<DropdownMenuItem
key={org.id}
onClick={() => setCurrentOrg(org)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span>{org.name}</span>
</div>
{org.role && ["owner", "admin"].includes(org.role) && (
<span className="text-xs bg-accent/10 text-accent px-1.5 py-0.5 rounded capitalize">
{org.role}
</span>
)}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
{/* User Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 h-9 px-2">
<Avatar className="w-7 h-7">
<AvatarImage src={user?.avatar_url || undefined} />
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
{userInitials}
</AvatarFallback>
</Avatar>
<ChevronDown className="w-4 h-4 text-muted-foreground hidden sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="font-medium">{user?.full_name || "User"}</span>
<span className="text-xs text-muted-foreground font-normal">
{user?.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User className="w-4 h-4 mr-2" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/security")}>
<Shield className="w-4 h-4 mr-2" />
Security
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
</header> </header>
); );
+130 -2
View File
@@ -1,28 +1,90 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { interface LoginResult {
requiresTotp: boolean; requiresTotp: boolean;
requiresMfaEnrollment?: boolean;
} }
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
mfaCompliance: MfaComplianceSummary | null;
requiresMfaEnrollment: boolean;
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>; login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>;
verifyTotp: (code: string, isBackupCode?: boolean) => Promise<void>; verifyTotp: (code: string, isBackupCode?: boolean) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
refreshCompliance: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(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<string, unknown>) =>
typeof org.effective_mode === 'string'
);
if (!hasEffectiveMode) {
return null;
}
return compliance;
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(loadMfaCompliance);
const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const navigate = useNavigate(); 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 () => { const refreshUser = useCallback(async () => {
try { try {
const response = await api.users.me(); const response = await api.users.me();
@@ -30,6 +92,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.code === 401) { if (error instanceof ApiError && error.code === 401) {
setUser(null); setUser(null);
setMfaCompliance(null);
persistMfaCompliance(null);
setRequiresMfaEnrollment(false);
} }
// Silently fail for other errors during refresh // 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 // Only attempt to fetch user if we have a valid token
if (!tokenManager.hasValidToken()) { if (!tokenManager.hasValidToken()) {
setUser(null); setUser(null);
setMfaCompliance(null);
persistMfaCompliance(null);
setRequiresMfaEnrollment(false);
setIsLoading(false); setIsLoading(false);
return; return;
} }
@@ -48,8 +116,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const response = await api.users.me(); const response = await api.users.me();
setUser(response.user); 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 { } catch {
setUser(null); setUser(null);
setMfaCompliance(null);
persistMfaCompliance(null);
setRequiresMfaEnrollment(false);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -61,7 +144,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (email: string, password: string, rememberMe = false): Promise<LoginResult> => { const login = useCallback(async (email: string, password: string, rememberMe = false): Promise<LoginResult> => {
console.log('[AuthContext] login() called'); console.log('[AuthContext] login() called');
const response = await api.auth.login(email, password, rememberMe); 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 TOTP is required, don't set user yet - wait for TOTP verification
if (response.requires_totp) { if (response.requires_totp) {
@@ -69,6 +157,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { requiresTotp: true }; 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 // Login complete: store token explicitly before setting user state
// This ensures the token is available for any subsequent API calls // This ensures the token is available for any subsequent API calls
// (e.g., when navigate('/profile') triggers refreshUser()) // (e.g., when navigate('/profile') triggers refreshUser())
@@ -82,6 +187,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (response.user) { if (response.user) {
console.log('[AuthContext] Setting user state and navigating to /profile'); console.log('[AuthContext] Setting user state and navigating to /profile');
setUser(response.user); setUser(response.user);
if (response.mfa_compliance) {
setMfaCompliance(response.mfa_compliance);
persistMfaCompliance(response.mfa_compliance);
}
setRequiresMfaEnrollment(false);
navigate('/profile'); navigate('/profile');
} }
return { requiresTotp: false }; return { requiresTotp: false };
@@ -97,6 +207,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
setUser(response.user); 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('/profile');
}, [navigate]); }, [navigate]);
@@ -105,6 +227,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await api.auth.logout(); await api.auth.logout();
} finally { } finally {
setUser(null); setUser(null);
setMfaCompliance(null);
persistMfaCompliance(null);
setRequiresMfaEnrollment(false);
navigate('/login'); navigate('/login');
} }
}, [navigate]); }, [navigate]);
@@ -115,10 +240,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user, user,
isLoading, isLoading,
isAuthenticated: !!user, isAuthenticated: !!user,
mfaCompliance,
requiresMfaEnrollment,
login, login,
verifyTotp, verifyTotp,
logout, logout,
refreshUser, refreshUser,
refreshCompliance,
}} }}
> >
{children} {children}
+37
View File
@@ -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<Organization[], ApiError>({
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;
},
});
}
+125 -2
View File
@@ -44,11 +44,41 @@ export interface OrganizationsResponse {
count: number; 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 { export interface LoginResponse {
user?: User; user?: User;
token?: string; token?: string;
expires_at?: string; expires_at?: string;
requires_totp?: boolean; requires_totp?: boolean;
requires_mfa_enrollment?: boolean;
mfa_compliance?: MfaComplianceSummary;
} }
export interface TotpEnrollResponse { export interface TotpEnrollResponse {
@@ -176,12 +206,16 @@ const SESSION_INVALID_ERROR_TYPES = [
'UNAUTHORIZED', 'UNAUTHORIZED',
]; ];
export const AUTHORIZATION_ERROR_TYPES = ['AUTHORIZATION_ERROR'] as const;
interface RequestConfig { interface RequestConfig {
// Controls token clearing on 401: // Controls token clearing on 401:
// - 'auto' (default): Clear only if error type indicates invalid session // - 'auto' (default): Clear only if error type indicates invalid session
// - true: Always clear token on 401 // - true: Always clear token on 401
// - false: Never clear token on 401 // - false: Never clear token on 401
clearTokenOn401?: boolean | 'auto'; clearTokenOn401?: boolean | 'auto';
// Optional callback for handling 403 authorization errors
on403?: (error: ApiError) => void;
} }
// Central request function - all API calls go through here // Central request function - all API calls go through here
@@ -191,7 +225,7 @@ async function request<T>(
requiresAuth = true, requiresAuth = true,
requestConfig: RequestConfig = {} requestConfig: RequestConfig = {}
): Promise<T> { ): Promise<T> {
const { clearTokenOn401 = 'auto' } = requestConfig; const { clearTokenOn401 = 'auto', on403 } = requestConfig;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -239,6 +273,21 @@ async function request<T>(
} }
} }
// 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( throw new ApiError(
json.message || 'An error occurred', json.message || 'An error occurred',
json.code, json.code,
@@ -290,7 +339,8 @@ export const api = {
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
organizations: () => request<OrganizationsResponse>('/users/me/organizations'), organizations: (requestConfig?: RequestConfig) =>
request<OrganizationsResponse>('/users/me/organizations', {}, true, requestConfig),
// Password change can return 401 for wrong current password - don't clear token // Password change can return 401 for wrong current password - don't clear token
changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) => changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) =>
@@ -454,6 +504,79 @@ export const api = {
method: 'DELETE', method: 'DELETE',
}), }),
}, },
policies: {
// Get organization security policy
getOrgPolicy: (orgId: string, requestConfig?: RequestConfig) =>
request<OrgPolicyResponse>(`/organizations/${orgId}/security-policy`, {}, true, requestConfig),
// Update organization security policy
updateOrgPolicy: (orgId: string, body: UpdateOrgPolicyDto, requestConfig?: RequestConfig) =>
request<OrgPolicyResponse>(`/organizations/${orgId}/security-policy`, {
method: 'PUT',
body: JSON.stringify(body),
}, true, requestConfig),
// List organization compliance (paginated)
listOrgCompliance: (orgId: string, params: Record<string, string>, requestConfig?: RequestConfig) =>
request<OrgCompliancePage>(
`/organizations/${orgId}/mfa-compliance?${new URLSearchParams(params)}`,
{},
true,
requestConfig
),
// Get current user's MFA compliance summary
getMyCompliance: () =>
request<MfaComplianceSummary>('/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 }; 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",
});
};
}
+80 -3
View File
@@ -1,11 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { api, ApiError, tokenManager } from "@/lib/api"; import { api, ApiError, tokenManager } from "@/lib/api";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -20,8 +21,10 @@ import {
formatLoginAssertion, formatLoginAssertion,
WebAuthnLoginOptions, WebAuthnLoginOptions,
} from "@/lib/webauthn"; } 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() { export default function LoginPage() {
const { login, verifyTotp, refreshUser } = useAuth(); const { login, verifyTotp, refreshUser } = useAuth();
@@ -45,8 +48,11 @@ export default function LoginPage() {
if (result.requiresTotp) { if (result.requiresTotp) {
setStep('totp'); setStep('totp');
setTotpCode(""); 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) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] Login failed:", error); 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 (
<div className="auth-card">
<div className="text-center mb-8">
<div className="mx-auto w-12 h-12 rounded-full bg-warning/10 flex items-center justify-center mb-4">
<AlertTriangle className="w-6 h-6 text-warning" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
MFA Enrollment Required
</h1>
<p className="text-muted-foreground mt-2">
Your account requires multi-factor authentication to access full features.
</p>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-base">Configure MFA</CardTitle>
<CardDescription>
Set up at least one authentication method to continue
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowTotpEnrollment(true)}
>
<Smartphone className="w-4 h-4 mr-2" />
Set up Authenticator App
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowPasskeyEnrollment(true)}
>
<Fingerprint className="w-4 h-4 mr-2" />
Add a Passkey
</Button>
</CardContent>
</Card>
<p className="text-center text-sm text-muted-foreground">
After configuring MFA, you'll be redirected to your profile.
</p>
<TotpEnrollmentWizard
open={showTotpEnrollment}
onOpenChange={setShowTotpEnrollment}
onSuccess={() => {
setShowTotpEnrollment(false);
navigate('/profile');
}}
/>
<AddPasskeyWizard
open={showPasskeyEnrollment}
onOpenChange={setShowPasskeyEnrollment}
onSuccess={() => {
setShowPasskeyEnrollment(false);
navigate('/profile');
}}
/>
</div>
);
}
// Passkey email entry step // Passkey email entry step
if (step === 'passkey-email') { if (step === 'passkey-email') {
return ( return (
+277
View File
@@ -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<string, { label: string; color: string; icon: typeof Clock }> = {
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<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("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 (
<div className="page-container">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">MFA Compliance</h1>
<p className="page-description">
Monitor and manage multi-factor authentication compliance for organization members
</p>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold">{stats.total}</p>
<p className="text-sm text-muted-foreground">Total Members</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-success">{stats.compliant}</p>
<p className="text-sm text-muted-foreground">Compliant</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-primary">{stats.inGrace}</p>
<p className="text-sm text-muted-foreground">In Grace</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-warning">{stats.pastDue}</p>
<p className="text-sm text-muted-foreground">Past Due</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-destructive">{stats.suspended}</p>
<p className="text-sm text-muted-foreground">Suspended</p>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by email or name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="w-full md:w-48">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="in_grace">In Grace</SelectItem>
<SelectItem value="past_due">Past Due</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="not_applicable">Not Applicable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Members Table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Members</CardTitle>
<CardDescription>
{filteredMembers.length} of {complianceData?.count || 0} members shown
</CardDescription>
</CardHeader>
<CardContent>
{filteredMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<User className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p>No members found matching your criteria</p>
</div>
) : (
<div className="space-y-3">
{filteredMembers.map((member) => {
const config = STATUS_CONFIG[member.status] || STATUS_CONFIG.pending;
const StatusIcon = config.icon;
return (
<div
key={member.user_id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
<div>
<p className="font-medium text-foreground">
{member.user_name || "Unknown"}
</p>
<p className="text-sm text-muted-foreground">
{member.user_email}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
<div className="text-sm text-muted-foreground">
<span className="hidden md:inline">Deadline: </span>
{new Date(member.deadline_at).toLocaleDateString()}
</div>
)}
<Badge className={config.color}>
<StatusIcon className="w-3 h-3 mr-1" />
{config.label}
</Badge>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => navigate(`/profile?userId=${member.user_id}`)}
title="View Profile"
>
<ExternalLink className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Send Reminder"
onClick={() => {
// TODO: Implement send reminder
console.log('Send reminder to', member.user_id);
}}
>
<Mail className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+347 -52
View File
@@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -6,8 +8,205 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; 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<string, { label: string; description: string }> = {
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() { export default function PoliciesPage() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [currentOrgId, setCurrentOrgId] = useState<string | null>(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 (
<div className="page-container">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
if (!currentOrgId || !policy) {
return (
<div className="page-container">
<Alert>
<AlertTriangle className="w-4 h-4" />
<AlertDescription>
Unable to load organization policy. Please try again later.
</AlertDescription>
</Alert>
</div>
);
}
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header"> <div className="page-header">
@@ -18,31 +217,161 @@ export default function PoliciesPage() {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{/* Registration Mode */} {/* Compliance Overview */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<UserPlus className="w-4 h-4" /> <Users className="w-4 h-4" />
Registration Mode Compliance Overview
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Control how new members can join your organization Current MFA compliance status for organization members
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Select defaultValue="invite"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<SelectTrigger className="w-full max-w-xs"> <div className="p-3 rounded-lg bg-success/10 border border-success/20 text-center">
<SelectValue /> <p className="text-2xl font-bold text-success">{complianceStats.compliant}</p>
</SelectTrigger> <p className="text-xs text-muted-foreground">Compliant</p>
<SelectContent> </div>
<SelectItem value="open">Open registration</SelectItem> <div className="p-3 rounded-lg bg-primary/10 border border-primary/20 text-center">
<SelectItem value="invite">Invite only</SelectItem> <p className="text-2xl font-bold text-primary">{complianceStats.inGrace}</p>
<SelectItem value="closed">Closed</SelectItem> <p className="text-xs text-muted-foreground">In Grace</p>
</SelectContent> </div>
</Select> <div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-center">
<p className="text-sm text-muted-foreground mt-2"> <p className="text-2xl font-bold text-warning">{complianceStats.pastDue}</p>
Invite only: Members can only join via admin invitation <p className="text-xs text-muted-foreground">Past Due</p>
</p> </div>
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center">
<p className="text-2xl font-bold text-destructive">{complianceStats.suspended}</p>
<p className="text-xs text-muted-foreground">Suspended</p>
</div>
<div className="p-3 rounded-lg bg-muted text-center">
<p className="text-2xl font-bold">{complianceStats.total}</p>
<p className="text-xs text-muted-foreground">Total Members</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => navigate('/org/policies/compliance')}
>
View Details
<ExternalLink className="w-4 h-4 ml-2" />
</Button>
</CardContent>
</Card>
{/* MFA Policy */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Smartphone className="w-4 h-4" />
Multi-Factor Authentication
</CardTitle>
<CardDescription>
Require additional authentication methods for all members
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{hasUnsavedChanges && (
<Alert className="border-warning/30 bg-warning/5">
<AlertTriangle className="w-4 h-4 text-warning" />
<AlertDescription className="text-sm">
You have unsaved changes. Click "Save Changes" to apply them or "Discard" to revert.
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label>MFA Policy Mode</Label>
<Select
value={formData.mfa_policy_mode}
onValueChange={handleMfaModeChange}
>
<SelectTrigger className="w-full max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MFA_MODE_LABELS).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{MFA_MODE_LABELS[formData.mfa_policy_mode]?.description}
</p>
</div>
{formData.mfa_policy_mode !== 'disabled' && formData.mfa_policy_mode !== 'optional' && (
<>
<div className="space-y-4 pt-4 border-t">
<div className="space-y-2">
<Label>Grace Period (days)</Label>
<div className="flex items-center gap-4">
<Slider
value={[formData.mfa_grace_period_days]}
onValueChange={handleGracePeriodChange}
max={60}
min={1}
step={1}
className="w-full max-w-xs"
/>
<span className="text-sm font-medium w-16">{formData.mfa_grace_period_days} days</span>
</div>
<p className="text-sm text-muted-foreground">
Members will have this many days to configure MFA after policy is applied.
</p>
</div>
<div className="space-y-2">
<Label>Notify Before Deadline (days)</Label>
<div className="flex items-center gap-4">
<Slider
value={[formData.notify_days_before]}
onValueChange={handleNotifyDaysChange}
max={14}
min={1}
step={1}
className="w-full max-w-xs"
/>
<span className="text-sm font-medium w-16">{formData.notify_days_before} days</span>
</div>
<p className="text-sm text-muted-foreground">
Send reminder notifications this many days before the deadline.
</p>
</div>
</div>
</>
)}
{hasUnsavedChanges && (
<div className="flex gap-2 pt-4 border-t">
<Button
onClick={handleSavePolicy}
disabled={updatePolicyMutation.isPending}
>
{updatePolicyMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
<Button
variant="outline"
onClick={handleDiscardChanges}
disabled={updatePolicyMutation.isPending}
>
Discard
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -100,40 +429,6 @@ export default function PoliciesPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* MFA Requirements */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Smartphone className="w-4 h-4" />
Multi-Factor Authentication
</CardTitle>
<CardDescription>
Require additional authentication methods
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="flex items-center gap-2">
Require TOTP
<Badge variant="secondary" className="text-xs">Recommended</Badge>
</Label>
<p className="text-sm text-muted-foreground">
All members must set up an authenticator app
</p>
</div>
<Switch defaultChecked />
</div>
<Alert className="border-warning/30 bg-warning/5">
<AlertTriangle className="w-4 h-4 text-warning" />
<AlertDescription className="text-sm">
Enabling this will require all existing members to set up TOTP on their next login.
</AlertDescription>
</Alert>
</CardContent>
</Card>
{/* Passkey Requirements */} {/* Passkey Requirements */}
<Card> <Card>
<CardHeader> <CardHeader>
+29 -33
View File
@@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { api, Organization, ApiError } from "@/lib/api"; import { api, Organization, ApiError } from "@/lib/api";
import { useOrganizations } from "@/hooks/useOrganizations";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
function ProfileSkeleton() { function ProfileSkeleton() {
@@ -73,8 +74,16 @@ export default function ProfilePage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [organizations, setOrganizations] = useState<Organization[]>([]);
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 // Sync local name state with user data
useEffect(() => { useEffect(() => {
@@ -83,36 +92,16 @@ export default function ProfilePage() {
} }
}, [user?.full_name]); }, [user?.full_name]);
// Fetch organizations only when user is available // Handle 403 errors for organizations
useEffect(() => { useEffect(() => {
console.log('[ProfilePage] useEffect triggered, user:', user?.id); if (orgsError instanceof ApiError && orgsError.code === 403) {
if (!user) { toast({
console.log('[ProfilePage] No user, skipping organizations fetch'); title: "Access Denied",
setOrgsLoading(false); description: "You don't have permission to view organizations. Please contact your organization administrator.",
return; variant: "destructive",
});
} }
}, [orgsError]);
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]);
const getInitials = (fullName: string | null) => { const getInitials = (fullName: string | null) => {
if (!fullName) return "?"; if (!fullName) return "?";
@@ -271,13 +260,13 @@ export default function ProfilePage() {
<Skeleton className="h-14 w-full" /> <Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" /> <Skeleton className="h-14 w-full" />
</div> </div>
) : organizations.length === 0 ? ( ) : organizationsArray.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6"> <p className="text-sm text-muted-foreground text-center py-6">
You're not a member of any organizations yet. You're not a member of any organizations yet.
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{organizations.map((org) => ( {organizationsArray.map((org) => (
<div <div
key={org.id} key={org.id}
className="flex items-center justify-between p-3 border rounded-lg" className="flex items-center justify-between p-3 border rounded-lg"
@@ -297,7 +286,14 @@ export default function ProfilePage() {
)} )}
<span className="text-foreground font-medium">{org.name}</span> <span className="text-foreground font-medium">{org.name}</span>
</div> </div>
<Badge variant="secondary" className="capitalize">{org.role}</Badge> <div className="flex items-center gap-2">
{(org.role === 'owner' || org.role === 'admin') && (
<Badge variant="default" className="bg-primary text-primary-foreground">
Admin
</Badge>
)}
<Badge variant="secondary" className="capitalize">{org.role}</Badge>
</div>
</div> </div>
))} ))}
</div> </div>
+5
View File
@@ -11,6 +11,8 @@ import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog";
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter";
import { api, ApiError, TotpStatusResponse, PasskeyCredential } from "@/lib/api"; import { api, ApiError, TotpStatusResponse, PasskeyCredential } from "@/lib/api";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
import { useAuth } from "@/contexts/AuthContext";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -49,6 +51,7 @@ export default function SecurityPage() {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const { mfaCompliance } = useAuth();
// Policy requirements (could come from org settings in future) // Policy requirements (could come from org settings in future)
const policyRequirements = { const policyRequirements = {
@@ -228,6 +231,8 @@ export default function SecurityPage() {
</p> </p>
</div> </div>
<ComplianceBanner compliance={mfaCompliance} />
<div className="space-y-6"> <div className="space-y-6">
{/* Policy Status */} {/* Policy Status */}
<Card className="border-accent/30 bg-accent/5"> <Card className="border-accent/30 bg-accent/5">