enabled policies
This commit is contained in:
+19
-4
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,103 +48,106 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
@@ -238,6 +272,21 @@ async function request<T>(
|
|||||||
console.log(`[API] 401 received but token preserved (type: ${errorType}, endpoint: ${endpoint})`);
|
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(
|
throw new ApiError(
|
||||||
json.message || 'An error occurred',
|
json.message || 'An error occurred',
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+348
-53
@@ -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>
|
||||||
@@ -160,4 +455,4 @@ export default function PoliciesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user