diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index 44156f9..a9235df 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -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() {
License Tools
+
navigate('/vendor/auth-policy')}
+ >
+
+ Auth Policy
+
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
diff --git a/ui/src/pages/vendor/AuthPolicyPage.module.css b/ui/src/pages/vendor/AuthPolicyPage.module.css
new file mode 100644
index 0000000..15a04f3
--- /dev/null
+++ b/ui/src/pages/vendor/AuthPolicyPage.module.css
@@ -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;
+}
diff --git a/ui/src/pages/vendor/AuthPolicyPage.tsx b/ui/src/pages/vendor/AuthPolicyPage.tsx
new file mode 100644
index 0000000..58ff11c
--- /dev/null
+++ b/ui/src/pages/vendor/AuthPolicyPage.tsx
@@ -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 (
+
+
+
Authentication Policy
+
+ Controls how tenant admins authenticate to the SaaS platform. This does not affect how
+ tenant users access their dashboards — tenants set their own policy.
+
+
+
+
+
+
+ Require tenant admins to use MFA when accessing the management platform.
+
+
+ MFA Mode
+
+
+
+ {['off', 'optional', 'required'].map((mode) => (
+ handleMfaModeChange(mode)}
+ loading={updatePolicy.isPending}
+ size="sm"
+ >
+ {mode.charAt(0).toUpperCase() + mode.slice(1)}
+
+ ))}
+
+ {confirmRequired && (
+
+
+ All tenant admins who have not enrolled in MFA will be blocked from the platform
+ until they enroll.
+
+
+
+ Yes, require MFA
+
+ setConfirmRequired(false)}>
+ Cancel
+
+
+
+ )}
+
+
+
+
+
+
+ Allow tenant admins to use passkeys (fingerprint, face, or security key) for
+ authentication.
+
+
+ Passkeys
+
+
+
+ {policy.passkeyEnabled ? 'Disable passkeys' : 'Enable passkeys'}
+
+
+ {policy.passkeyEnabled && (
+
+
+ Passkey Mode
+
+
+
+ {['optional', 'preferred', 'required'].map((mode) => (
+ handlePasskeyModeChange(mode)}
+ loading={updatePolicy.isPending}
+ size="sm"
+ >
+ {mode.charAt(0).toUpperCase() + mode.slice(1)}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/router.tsx b/ui/src/router.tsx
index 24b3721..0dd6359 100644
--- a/ui/src/router.tsx
+++ b/ui/src/router.tsx
@@ -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() {
} />
+
}>
+
+
+ } />
{/* Tenant portal */}
} />