enabled policies

This commit is contained in:
2026-01-16 17:31:25 +10:30
parent 71c58ddb60
commit 4ee3b81074
13 changed files with 1582 additions and 219 deletions
+80 -3
View File
@@ -1,11 +1,12 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2 } from "lucide-react";
import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/contexts/AuthContext";
import { api, ApiError, tokenManager } from "@/lib/api";
import { useToast } from "@/hooks/use-toast";
@@ -20,8 +21,10 @@ import {
formatLoginAssertion,
WebAuthnLoginOptions,
} from "@/lib/webauthn";
import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard";
type LoginStep = 'credentials' | 'totp' | 'passkey-email';
type LoginStep = 'credentials' | 'totp' | 'passkey-email' | 'mfa-enrollment';
export default function LoginPage() {
const { login, verifyTotp, refreshUser } = useAuth();
@@ -45,8 +48,11 @@ export default function LoginPage() {
if (result.requiresTotp) {
setStep('totp');
setTotpCode("");
} else if (result.requiresMfaEnrollment) {
// MFA enrollment required - will be handled by ProtectedLayout
// Navigation happens in AuthContext
}
// If no TOTP required, navigation happens in AuthContext
// If no TOTP or MFA enrollment required, navigation happens in AuthContext
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] Login failed:", error);
@@ -204,6 +210,77 @@ export default function LoginPage() {
}
};
// MFA enrollment step - shows when user needs to configure MFA
if (step === 'mfa-enrollment') {
const [showTotpEnrollment, setShowTotpEnrollment] = useState(false);
const [showPasskeyEnrollment, setShowPasskeyEnrollment] = useState(false);
return (
<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
if (step === 'passkey-email') {
return (
+277
View File
@@ -0,0 +1,277 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Shield, Search, Filter, Loader2, User, Clock, AlertTriangle, CheckCircle, Mail, ExternalLink } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
compliant: {
label: "Compliant",
color: "bg-success/10 text-success border-success/20",
icon: CheckCircle,
},
in_grace: {
label: "In Grace",
color: "bg-primary/10 text-primary border-primary/20",
icon: Clock,
},
past_due: {
label: "Past Due",
color: "bg-warning/10 text-warning border-warning/20",
icon: AlertTriangle,
},
suspended: {
label: "Suspended",
color: "bg-destructive/10 text-destructive border-destructive/20",
icon: AlertTriangle,
},
pending: {
label: "Pending",
color: "bg-muted text-muted-foreground",
icon: Clock,
},
not_applicable: {
label: "Not Applicable",
color: "bg-muted text-muted-foreground",
icon: Shield,
},
};
export default function CompliancePage() {
const navigate = useNavigate();
const { toast } = useToast();
const [currentOrgId, setCurrentOrgId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
// Fetch organizations to get current org
const { data: orgsData, isLoading: orgsLoading } = useQuery({
queryKey: ['organizations'],
queryFn: () => api.users.organizations({
on403: create403Handler(toast),
}),
});
useEffect(() => {
if (orgsData?.organizations && orgsData.organizations.length > 0) {
setCurrentOrgId(orgsData.organizations[0].id);
}
}, [orgsData]);
// Fetch compliance data
const { data: complianceData, isLoading: complianceLoading } = useQuery({
queryKey: ['org-compliance', currentOrgId],
queryFn: () => currentOrgId ? api.policies.listOrgCompliance(currentOrgId, {}, {
on403: create403Handler(toast),
}) : null,
enabled: !!currentOrgId,
});
// Filter members based on search and status
const filteredMembers = complianceData?.members?.filter((member) => {
const matchesSearch =
member.user_email.toLowerCase().includes(searchQuery.toLowerCase()) ||
member.user_name?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === "all" || member.status === statusFilter;
return matchesSearch && matchesStatus;
}) || [];
// Calculate stats
const stats = {
total: complianceData?.count || 0,
compliant: complianceData?.members?.filter(m => m.status === 'compliant').length || 0,
inGrace: complianceData?.members?.filter(m => m.status === 'in_grace').length || 0,
pastDue: complianceData?.members?.filter(m => m.status === 'past_due').length || 0,
suspended: complianceData?.members?.filter(m => m.status === 'suspended').length || 0,
};
if (orgsLoading || complianceLoading) {
return (
<div className="page-container">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">MFA Compliance</h1>
<p className="page-description">
Monitor and manage multi-factor authentication compliance for organization members
</p>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold">{stats.total}</p>
<p className="text-sm text-muted-foreground">Total Members</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-success">{stats.compliant}</p>
<p className="text-sm text-muted-foreground">Compliant</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-primary">{stats.inGrace}</p>
<p className="text-sm text-muted-foreground">In Grace</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-warning">{stats.pastDue}</p>
<p className="text-sm text-muted-foreground">Past Due</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold text-destructive">{stats.suspended}</p>
<p className="text-sm text-muted-foreground">Suspended</p>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by email or name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="w-full md:w-48">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="in_grace">In Grace</SelectItem>
<SelectItem value="past_due">Past Due</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="not_applicable">Not Applicable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Members Table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Members</CardTitle>
<CardDescription>
{filteredMembers.length} of {complianceData?.count || 0} members shown
</CardDescription>
</CardHeader>
<CardContent>
{filteredMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<User className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p>No members found matching your criteria</p>
</div>
) : (
<div className="space-y-3">
{filteredMembers.map((member) => {
const config = STATUS_CONFIG[member.status] || STATUS_CONFIG.pending;
const StatusIcon = config.icon;
return (
<div
key={member.user_id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
<div>
<p className="font-medium text-foreground">
{member.user_name || "Unknown"}
</p>
<p className="text-sm text-muted-foreground">
{member.user_email}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
<div className="text-sm text-muted-foreground">
<span className="hidden md:inline">Deadline: </span>
{new Date(member.deadline_at).toLocaleDateString()}
</div>
)}
<Badge className={config.color}>
<StatusIcon className="w-3 h-3 mr-1" />
{config.label}
</Badge>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => navigate(`/profile?userId=${member.user_id}`)}
title="View Profile"
>
<ExternalLink className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Send Reminder"
onClick={() => {
// TODO: Implement send reminder
console.log('Send reminder to', member.user_id);
}}
>
<Mail className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+348 -53
View File
@@ -1,4 +1,6 @@
import { Shield, Lock, Fingerprint, Smartphone, UserPlus, AlertTriangle } from "lucide-react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Shield, Lock, Fingerprint, Smartphone, UserPlus, AlertTriangle, Loader2, Users, ExternalLink } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -6,8 +8,205 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
const MFA_MODE_LABELS: Record<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() {
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 (
<div className="page-container">
<div className="page-header">
@@ -18,31 +217,161 @@ export default function PoliciesPage() {
</div>
<div className="space-y-6">
{/* Registration Mode */}
{/* Compliance Overview */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<UserPlus className="w-4 h-4" />
Registration Mode
<Users className="w-4 h-4" />
Compliance Overview
</CardTitle>
<CardDescription>
Control how new members can join your organization
Current MFA compliance status for organization members
</CardDescription>
</CardHeader>
<CardContent>
<Select defaultValue="invite">
<SelectTrigger className="w-full max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open registration</SelectItem>
<SelectItem value="invite">Invite only</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
Invite only: Members can only join via admin invitation
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-3 rounded-lg bg-success/10 border border-success/20 text-center">
<p className="text-2xl font-bold text-success">{complianceStats.compliant}</p>
<p className="text-xs text-muted-foreground">Compliant</p>
</div>
<div className="p-3 rounded-lg bg-primary/10 border border-primary/20 text-center">
<p className="text-2xl font-bold text-primary">{complianceStats.inGrace}</p>
<p className="text-xs text-muted-foreground">In Grace</p>
</div>
<div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-center">
<p className="text-2xl font-bold text-warning">{complianceStats.pastDue}</p>
<p className="text-xs text-muted-foreground">Past Due</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>
</Card>
@@ -100,40 +429,6 @@ export default function PoliciesPage() {
</CardContent>
</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 */}
<Card>
<CardHeader>
@@ -160,4 +455,4 @@ export default function PoliciesPage() {
</div>
</div>
);
}
}
+29 -33
View File
@@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/contexts/AuthContext";
import { api, Organization, ApiError } from "@/lib/api";
import { useOrganizations } from "@/hooks/useOrganizations";
import { toast } from "@/hooks/use-toast";
function ProfileSkeleton() {
@@ -73,8 +74,16 @@ export default function ProfilePage() {
const [name, setName] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [organizations, setOrganizations] = useState<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
useEffect(() => {
@@ -83,36 +92,16 @@ export default function ProfilePage() {
}
}, [user?.full_name]);
// Fetch organizations only when user is available
// Handle 403 errors for organizations
useEffect(() => {
console.log('[ProfilePage] useEffect triggered, user:', user?.id);
if (!user) {
console.log('[ProfilePage] No user, skipping organizations fetch');
setOrgsLoading(false);
return;
if (orgsError instanceof ApiError && orgsError.code === 403) {
toast({
title: "Access Denied",
description: "You don't have permission to view organizations. Please contact your organization administrator.",
variant: "destructive",
});
}
const fetchOrgs = async () => {
console.log('[ProfilePage] Making api.users.organizations() request');
try {
const response = await api.users.organizations();
console.log('[ProfilePage] Organizations fetched successfully:', response.organizations.length);
setOrganizations(response.organizations);
} catch (error) {
if (error instanceof ApiError) {
toast({
title: "Error loading organizations",
description: error.message,
variant: "destructive",
});
}
} finally {
setOrgsLoading(false);
}
};
fetchOrgs();
}, [user]);
}, [orgsError]);
const getInitials = (fullName: string | null) => {
if (!fullName) return "?";
@@ -271,13 +260,13 @@ export default function ProfilePage() {
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : organizations.length === 0 ? (
) : organizationsArray.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
You're not a member of any organizations yet.
</p>
) : (
<div className="space-y-2">
{organizations.map((org) => (
{organizationsArray.map((org) => (
<div
key={org.id}
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>
</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>
+5
View File
@@ -11,6 +11,8 @@ import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog";
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter";
import { api, ApiError, TotpStatusResponse, PasskeyCredential } from "@/lib/api";
import { useToast } from "@/hooks/use-toast";
import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
import { useAuth } from "@/contexts/AuthContext";
import {
AlertDialog,
AlertDialogAction,
@@ -49,6 +51,7 @@ export default function SecurityPage() {
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const { mfaCompliance } = useAuth();
// Policy requirements (could come from org settings in future)
const policyRequirements = {
@@ -228,6 +231,8 @@ export default function SecurityPage() {
</p>
</div>
<ComplianceBanner compliance={mfaCompliance} />
<div className="space-y-6">
{/* Policy Status */}
<Card className="border-accent/30 bg-accent/5">