enabled policies
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 { 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user