2026-04-11 09:19:48 +02:00
|
|
|
import { useState } from 'react';
|
2026-04-26 21:10:28 +02:00
|
|
|
import { errorMessage } from '../../api/client';
|
2026-04-09 22:00:21 +02:00
|
|
|
import {
|
|
|
|
|
Alert,
|
|
|
|
|
Badge,
|
2026-04-11 09:19:48 +02:00
|
|
|
Button,
|
2026-04-09 22:00:21 +02:00
|
|
|
Card,
|
2026-04-11 09:19:48 +02:00
|
|
|
FormField,
|
|
|
|
|
Input,
|
2026-04-09 22:00:21 +02:00
|
|
|
Spinner,
|
2026-04-11 09:19:48 +02:00
|
|
|
useToast,
|
2026-04-09 22:00:21 +02:00
|
|
|
} from '@cameleer/design-system';
|
2026-04-26 14:04:28 +02:00
|
|
|
import {
|
2026-04-27 14:59:09 +02:00
|
|
|
useTenantSettings,
|
|
|
|
|
useResetServerAdminPassword,
|
|
|
|
|
useUpdateTenantSettings,
|
2026-04-27 08:55:04 +02:00
|
|
|
useTenantAuthSettings, useUpdateTenantAuthSettings,
|
2026-04-26 14:04:28 +02:00
|
|
|
} from '../../api/tenant-hooks';
|
2026-04-27 14:59:09 +02:00
|
|
|
import { MfaSection } from '../../components/account/MfaSection';
|
|
|
|
|
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection';
|
|
|
|
|
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';
|
2026-04-26 14:04:28 +02:00
|
|
|
import { useScopes } from '../../auth/useScopes';
|
2026-04-09 22:00:21 +02:00
|
|
|
import { tierColor } from '../../utils/tier';
|
|
|
|
|
import styles from '../../styles/platform.module.css';
|
|
|
|
|
|
|
|
|
|
function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
|
|
|
|
|
switch (status?.toUpperCase()) {
|
|
|
|
|
case 'ACTIVE': return 'success';
|
|
|
|
|
case 'SUSPENDED': return 'warning';
|
|
|
|
|
case 'PROVISIONING': return 'auto';
|
|
|
|
|
case 'ERROR': return 'error';
|
|
|
|
|
default: return 'auto';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 14:04:28 +02:00
|
|
|
function MfaEnforcementToggle() {
|
|
|
|
|
const scopes = useScopes();
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const { data: settings } = useTenantSettings();
|
|
|
|
|
const updateSettings = useUpdateTenantSettings();
|
|
|
|
|
const [confirmEnable, setConfirmEnable] = useState(false);
|
|
|
|
|
|
|
|
|
|
if (!scopes.has('tenant:manage')) return null;
|
|
|
|
|
|
|
|
|
|
const mfaRequired = settings?.mfaRequired ?? false;
|
|
|
|
|
|
|
|
|
|
async function handleToggle() {
|
|
|
|
|
if (!mfaRequired) {
|
|
|
|
|
setConfirmEnable(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await updateSettings.mutateAsync({ mfaRequired: false });
|
|
|
|
|
toast({ title: 'MFA requirement disabled for all members', variant: 'success' });
|
|
|
|
|
} catch (err) {
|
2026-04-26 21:10:28 +02:00
|
|
|
toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' });
|
2026-04-26 14:04:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleConfirmEnable() {
|
|
|
|
|
try {
|
|
|
|
|
await updateSettings.mutateAsync({ mfaRequired: true });
|
|
|
|
|
setConfirmEnable(false);
|
|
|
|
|
toast({ title: 'MFA is now required for all members', variant: 'success' });
|
|
|
|
|
} catch (err) {
|
2026-04-26 21:10:28 +02:00
|
|
|
toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' });
|
2026-04-26 14:04:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card title="MFA Enforcement">
|
|
|
|
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
|
|
|
When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant.
|
|
|
|
|
</p>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
|
|
|
<span style={{ fontSize: '0.875rem' }}>Require MFA for all members</span>
|
|
|
|
|
<Badge label={mfaRequired ? 'Required' : 'Optional'} color={mfaRequired ? 'success' : 'auto'} />
|
|
|
|
|
</div>
|
|
|
|
|
{confirmEnable ? (
|
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
|
|
|
<Alert variant="warning" title="Confirm MFA requirement">
|
|
|
|
|
All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure?
|
|
|
|
|
</Alert>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
|
|
|
<Button variant="primary" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
|
|
|
|
|
Yes, require MFA
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" onClick={() => setConfirmEnable(false)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
|
|
|
<Button
|
|
|
|
|
variant={mfaRequired ? 'danger' : 'primary'}
|
|
|
|
|
onClick={handleToggle}
|
|
|
|
|
loading={updateSettings.isPending}
|
|
|
|
|
>
|
|
|
|
|
{mfaRequired ? 'Disable MFA requirement' : 'Enable MFA requirement'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 08:55:04 +02:00
|
|
|
function AuthPolicySection() {
|
|
|
|
|
const scopes = useScopes();
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const { data: authSettings } = useTenantAuthSettings();
|
|
|
|
|
const updateAuth = useUpdateTenantAuthSettings();
|
|
|
|
|
|
|
|
|
|
if (!scopes.has('tenant:manage') || !authSettings) return null;
|
|
|
|
|
|
|
|
|
|
async function handleMfaModeChange(mode: string) {
|
|
|
|
|
try {
|
|
|
|
|
await updateAuth.mutateAsync({ mfaMode: mode });
|
|
|
|
|
toast({ title: `MFA mode set to ${mode}`, variant: 'success' });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handlePasskeyToggle() {
|
|
|
|
|
if (!authSettings) return;
|
|
|
|
|
try {
|
|
|
|
|
await updateAuth.mutateAsync({ passkeyEnabled: !authSettings.passkeyEnabled });
|
|
|
|
|
toast({ title: authSettings.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled', variant: 'success' });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handlePasskeyModeChange(mode: string) {
|
|
|
|
|
try {
|
|
|
|
|
await updateAuth.mutateAsync({ passkeyMode: mode });
|
|
|
|
|
toast({ title: `Passkey mode set to ${mode}`, variant: 'success' });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card title="Authentication Policy">
|
|
|
|
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
|
|
|
Configure MFA and passkey requirements for your organization's users.
|
|
|
|
|
</p>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
|
|
|
<span style={{ fontSize: '0.875rem' }}>MFA Mode</span>
|
|
|
|
|
<Badge label={authSettings.mfaMode} color={authSettings.mfaMode === 'required' ? 'success' : 'auto'} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
|
|
|
{['off', 'optional', 'required'].map((mode) => (
|
|
|
|
|
<Button key={mode} variant={authSettings.mfaMode === mode ? 'primary' : 'secondary'}
|
|
|
|
|
onClick={() => handleMfaModeChange(mode)} loading={updateAuth.isPending} size="sm">
|
|
|
|
|
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
|
|
|
<span style={{ fontSize: '0.875rem' }}>Passkeys</span>
|
|
|
|
|
<Badge label={authSettings.passkeyEnabled ? 'Enabled' : 'Disabled'} color={authSettings.passkeyEnabled ? 'success' : 'auto'} />
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant={authSettings.passkeyEnabled ? 'danger' : 'primary'}
|
|
|
|
|
onClick={handlePasskeyToggle} loading={updateAuth.isPending} size="sm">
|
|
|
|
|
{authSettings.passkeyEnabled ? 'Disable passkeys' : 'Enable passkeys'}
|
|
|
|
|
</Button>
|
|
|
|
|
{authSettings.passkeyEnabled && (
|
|
|
|
|
<div style={{ marginTop: 16 }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
|
|
|
<span style={{ fontSize: '0.875rem' }}>Passkey Mode</span>
|
|
|
|
|
<Badge label={authSettings.passkeyMode} color={authSettings.passkeyMode === 'required' ? 'success' : 'auto'} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
{['optional', 'preferred', 'required'].map((mode) => (
|
|
|
|
|
<Button key={mode} variant={authSettings.passkeyMode === mode ? 'primary' : 'secondary'}
|
|
|
|
|
onClick={() => handlePasskeyModeChange(mode)} loading={updateAuth.isPending} size="sm">
|
|
|
|
|
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 22:00:21 +02:00
|
|
|
export function SettingsPage() {
|
|
|
|
|
const { data, isLoading, isError } = useTenantSettings();
|
2026-04-11 09:46:30 +02:00
|
|
|
const resetServerAdmin = useResetServerAdminPassword();
|
2026-04-11 09:19:48 +02:00
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
2026-04-11 09:46:30 +02:00
|
|
|
const [serverAdminPw, setServerAdminPw] = useState('');
|
2026-04-11 09:19:48 +02:00
|
|
|
|
2026-04-09 22:00:21 +02:00
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
|
|
|
|
<Spinner />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isError || !data) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: 24 }}>
|
|
|
|
|
<Alert variant="error" title="Failed to load settings">
|
|
|
|
|
Could not fetch settings. Please refresh.
|
|
|
|
|
</Alert>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
|
|
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
|
2026-04-27 08:55:04 +02:00
|
|
|
<PasskeyNudgeBanner />
|
2026-04-09 22:00:21 +02:00
|
|
|
|
|
|
|
|
<Card title="Tenant Details">
|
|
|
|
|
<div className={styles.dividerList}>
|
|
|
|
|
<div className={styles.kvRow}>
|
|
|
|
|
<span className={styles.kvLabel}>Name</span>
|
|
|
|
|
<span className={styles.kvValue}>{data.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.kvRow}>
|
|
|
|
|
<span className={styles.kvLabel}>Slug</span>
|
|
|
|
|
<span className={styles.kvValueMono}>{data.slug}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.kvRow}>
|
|
|
|
|
<span className={styles.kvLabel}>Tier</span>
|
|
|
|
|
<Badge label={data.tier} color={tierColor(data.tier)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.kvRow}>
|
|
|
|
|
<span className={styles.kvLabel}>Status</span>
|
|
|
|
|
<Badge label={data.status} color={statusColor(data.status)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.kvRow}>
|
|
|
|
|
<span className={styles.kvLabel}>Server Endpoint</span>
|
|
|
|
|
<span className={styles.kvValueMono}>{data.serverEndpoint ?? '—'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.kvRow}>
|
|
|
|
|
<span className={styles.kvLabel}>Created</span>
|
|
|
|
|
<span className={styles.kvValue}>{new Date(data.createdAt).toLocaleDateString()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className={styles.description} style={{ marginTop: 16 }}>
|
|
|
|
|
To change your tier or other billing-related settings, please contact support.
|
|
|
|
|
</p>
|
|
|
|
|
</Card>
|
2026-04-11 09:19:48 +02:00
|
|
|
|
2026-04-27 14:59:09 +02:00
|
|
|
<PasswordChangeSection />
|
2026-04-11 09:46:30 +02:00
|
|
|
|
|
|
|
|
<Card title="Server Admin Password">
|
|
|
|
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
|
|
|
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
|
|
|
|
|
</p>
|
|
|
|
|
<form
|
|
|
|
|
onSubmit={async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (serverAdminPw.length < 8) {
|
|
|
|
|
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await resetServerAdmin.mutateAsync(serverAdminPw);
|
|
|
|
|
toast({ title: 'Server admin password reset successfully', variant: 'success' });
|
|
|
|
|
setServerAdminPw('');
|
|
|
|
|
} catch (err) {
|
2026-04-26 21:10:28 +02:00
|
|
|
toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' });
|
2026-04-11 09:46:30 +02:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}
|
|
|
|
|
>
|
|
|
|
|
<FormField label="New admin password" htmlFor="server-admin-pw">
|
|
|
|
|
<Input
|
|
|
|
|
id="server-admin-pw"
|
|
|
|
|
type="password"
|
|
|
|
|
value={serverAdminPw}
|
|
|
|
|
onChange={(e) => setServerAdminPw(e.target.value)}
|
|
|
|
|
placeholder="Enter new admin password"
|
|
|
|
|
required
|
|
|
|
|
minLength={8}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
<div>
|
|
|
|
|
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
|
|
|
|
|
Reset Admin Password
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</Card>
|
2026-04-26 14:04:28 +02:00
|
|
|
|
|
|
|
|
<MfaSection />
|
|
|
|
|
<MfaEnforcementToggle />
|
2026-04-27 08:55:04 +02:00
|
|
|
<PasskeySection />
|
|
|
|
|
<AuthPolicySection />
|
2026-04-09 22:00:21 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|