feat: add vendor authentication policy management page

Adds /vendor/auth-policy route with MFA mode (off/optional/required) and passkey (enabled/disabled, optional/preferred/required mode) controls, including a confirmation guard before enforcing required MFA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 08:51:45 +02:00
parent ad2b16f26d
commit 8de16019b7
4 changed files with 205 additions and 1 deletions

View File

@@ -4,7 +4,7 @@ import {
Sidebar,
TopBar,
} from '@cameleer/design-system';
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key } from 'lucide-react';
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key, Lock } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/useAuth';
import { useScopes } from '../auth/useScopes';
@@ -148,6 +148,15 @@ export function Layout() {
<Key size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
License Tools
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
fontWeight: isActive(location, '/vendor/auth-policy') ? 600 : 400,
color: isActive(location, '/vendor/auth-policy') ? 'var(--amber)' : 'var(--text-muted)' }}
onClick={() => navigate('/vendor/auth-policy')}
>
<Lock size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Auth Policy
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}

View File

@@ -0,0 +1,25 @@
.subtitle {
color: var(--text-muted);
margin-bottom: 0;
margin-top: 8px;
}
.description {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0;
margin-bottom: 16px;
}
.controlRow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 0.875rem;
}
.buttonGroup {
display: flex;
gap: 8px;
}

164
ui/src/pages/vendor/AuthPolicyPage.tsx vendored Normal file
View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import { Card, Button, Badge, Alert, useToast } from '@cameleer/design-system';
import { useVendorAuthPolicy, useUpdateVendorAuthPolicy } from '../../api/vendor-hooks';
import { errorMessage } from '../../api/client';
import styles from './AuthPolicyPage.module.css';
export function AuthPolicyPage() {
const { data: policy, isLoading } = useVendorAuthPolicy();
const updatePolicy = useUpdateVendorAuthPolicy();
const { toast } = useToast();
const [confirmRequired, setConfirmRequired] = useState(false);
if (isLoading || !policy) return null;
async function handleMfaModeChange(mode: string) {
if (mode === 'required' && policy?.mfaMode !== 'required') {
setConfirmRequired(true);
return;
}
try {
await updatePolicy.mutateAsync({ mfaMode: mode });
toast({ title: `MFA mode set to ${mode}`, variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
async function handleConfirmRequired() {
try {
await updatePolicy.mutateAsync({ mfaMode: 'required' });
setConfirmRequired(false);
toast({ title: 'MFA is now required for all tenant admins', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
async function handlePasskeyToggle() {
try {
await updatePolicy.mutateAsync({ passkeyEnabled: !policy?.passkeyEnabled });
toast({
title: policy?.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled',
variant: 'success',
});
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
async function handlePasskeyModeChange(mode: string) {
try {
await updatePolicy.mutateAsync({ passkeyMode: mode });
toast({ title: `Passkey mode set to ${mode}`, variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 24, maxWidth: 720 }}>
<div>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Authentication Policy</h1>
<p className={styles.subtitle}>
Controls how tenant admins authenticate to the SaaS platform. This does not affect how
tenant users access their dashboards tenants set their own policy.
</p>
</div>
<Card title="Multi-Factor Authentication">
<div style={{ padding: '0 20px 20px' }}>
<p className={styles.description}>
Require tenant admins to use MFA when accessing the management platform.
</p>
<div className={styles.controlRow}>
<span>MFA Mode</span>
<Badge label={policy.mfaMode} color={policy.mfaMode === 'required' ? 'success' : 'auto'} />
</div>
<div className={styles.buttonGroup}>
{['off', 'optional', 'required'].map((mode) => (
<Button
key={mode}
variant={policy.mfaMode === mode ? 'primary' : 'secondary'}
onClick={() => handleMfaModeChange(mode)}
loading={updatePolicy.isPending}
size="sm"
>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</Button>
))}
</div>
{confirmRequired && (
<div style={{ marginTop: 12 }}>
<Alert variant="warning" title="Confirm MFA requirement">
All tenant admins who have not enrolled in MFA will be blocked from the platform
until they enroll.
</Alert>
<div className={styles.buttonGroup} style={{ marginTop: 12 }}>
<Button
variant="primary"
onClick={handleConfirmRequired}
loading={updatePolicy.isPending}
>
Yes, require MFA
</Button>
<Button variant="secondary" onClick={() => setConfirmRequired(false)}>
Cancel
</Button>
</div>
</div>
)}
</div>
</Card>
<Card title="Passkeys">
<div style={{ padding: '0 20px 20px' }}>
<p className={styles.description}>
Allow tenant admins to use passkeys (fingerprint, face, or security key) for
authentication.
</p>
<div className={styles.controlRow}>
<span>Passkeys</span>
<Badge
label={policy.passkeyEnabled ? 'Enabled' : 'Disabled'}
color={policy.passkeyEnabled ? 'success' : 'auto'}
/>
</div>
<Button
variant={policy.passkeyEnabled ? 'danger' : 'primary'}
onClick={handlePasskeyToggle}
loading={updatePolicy.isPending}
size="sm"
>
{policy.passkeyEnabled ? 'Disable passkeys' : 'Enable passkeys'}
</Button>
{policy.passkeyEnabled && (
<div style={{ marginTop: 16 }}>
<div className={styles.controlRow}>
<span>Passkey Mode</span>
<Badge
label={policy.passkeyMode}
color={policy.passkeyMode === 'required' ? 'success' : 'auto'}
/>
</div>
<div className={styles.buttonGroup}>
{['optional', 'preferred', 'required'].map((mode) => (
<Button
key={mode}
variant={policy.passkeyMode === mode ? 'primary' : 'secondary'}
onClick={() => handlePasskeyModeChange(mode)}
loading={updatePolicy.isPending}
size="sm"
>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</Button>
))}
</div>
</div>
)}
</div>
</Card>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
import { EmailConfigPage } from './pages/vendor/EmailConfigPage';
import { LicenseVerifyPage } from './pages/vendor/LicenseVerifyPage';
import { AuthPolicyPage } from './pages/vendor/AuthPolicyPage';
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
import { SsoPage } from './pages/tenant/SsoPage';
@@ -114,6 +115,11 @@ export function AppRouter() {
<LicenseVerifyPage />
</RequireScope>
} />
<Route path="/vendor/auth-policy" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<AuthPolicyPage />
</RequireScope>
} />
{/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} />