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:
@@ -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')}
|
||||
|
||||
25
ui/src/pages/vendor/AuthPolicyPage.module.css
vendored
Normal file
25
ui/src/pages/vendor/AuthPolicyPage.module.css
vendored
Normal 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
164
ui/src/pages/vendor/AuthPolicyPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user