refactor: consolidate tenant SettingsPage to use shared account components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,19 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse, PasskeyCredential, AuthPolicy } from '../types/api';
|
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, AuthPolicy } from '../types/api';
|
||||||
|
|
||||||
|
// Re-export account hooks for backward compatibility
|
||||||
|
export {
|
||||||
|
useAccountMfaStatus as useMfaStatus,
|
||||||
|
useAccountMfaSetup as useMfaSetup,
|
||||||
|
useAccountMfaVerify as useMfaVerify,
|
||||||
|
useAccountBackupCodes as useMfaBackupCodes,
|
||||||
|
useAccountMfaRemove as useMfaRemove,
|
||||||
|
useAccountPasskeyList as usePasskeyList,
|
||||||
|
useAccountRenamePasskey as useRenamePasskey,
|
||||||
|
useAccountDeletePasskey as useDeletePasskey,
|
||||||
|
useAccountMfaPreference as useUpdateMfaMethodPreference,
|
||||||
|
} from './account-hooks';
|
||||||
|
|
||||||
export function useTenantDashboard() {
|
export function useTenantDashboard() {
|
||||||
return useQuery<DashboardData>({
|
return useQuery<DashboardData>({
|
||||||
@@ -121,6 +134,14 @@ export function useResetTeamMemberPassword() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useResetTeamMemberMfa() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTenantSettings() {
|
export function useTenantSettings() {
|
||||||
return useQuery<TenantSettings>({
|
return useQuery<TenantSettings>({
|
||||||
queryKey: ['tenant', 'settings'],
|
queryKey: ['tenant', 'settings'],
|
||||||
@@ -128,6 +149,14 @@ export function useTenantSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateTenantSettings() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, Record<string, unknown>>({
|
||||||
|
mutationFn: (updates) => api.patch('/tenant/settings', updates),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
|
export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters.action) params.set('action', filters.action);
|
if (filters.action) params.set('action', filters.action);
|
||||||
@@ -144,90 +173,6 @@ export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// MFA hooks
|
|
||||||
export function useMfaStatus() {
|
|
||||||
return useQuery<MfaStatus>({
|
|
||||||
queryKey: ['tenant', 'mfa', 'status'],
|
|
||||||
queryFn: () => api.get('/tenant/mfa/status'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMfaSetup() {
|
|
||||||
return useMutation<MfaSetupResponse, Error, void>({
|
|
||||||
mutationFn: () => api.post('/tenant/mfa/totp/setup'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMfaVerify() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
|
|
||||||
mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMfaBackupCodes() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<BackupCodesResponse, Error, void>({
|
|
||||||
mutationFn: () => api.post('/tenant/mfa/backup-codes'),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMfaRemove() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<void, Error, void>({
|
|
||||||
mutationFn: () => api.delete('/tenant/mfa/totp'),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResetTeamMemberMfa() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<void, Error, string>({
|
|
||||||
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateTenantSettings() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<void, Error, Record<string, unknown>>({
|
|
||||||
mutationFn: (updates) => api.patch('/tenant/settings', updates),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passkey hooks
|
|
||||||
export function usePasskeyList() {
|
|
||||||
return useQuery<PasskeyCredential[]>({
|
|
||||||
queryKey: ['tenant', 'mfa', 'webauthn'],
|
|
||||||
queryFn: () => api.get('/tenant/mfa/webauthn'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRenamePasskey() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<void, Error, { id: string; name: string }>({
|
|
||||||
mutationFn: ({ id, name }) => api.patch(`/tenant/mfa/webauthn/${id}/name`, { name }),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeletePasskey() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<void, Error, string>({
|
|
||||||
mutationFn: (id) => api.delete(`/tenant/mfa/webauthn/${id}`),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateMfaMethodPreference() {
|
|
||||||
return useMutation<void, Error, string>({
|
|
||||||
mutationFn: (preference) => api.post('/tenant/mfa/method-preference', { preference }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth settings hooks
|
// Auth settings hooks
|
||||||
export function useTenantAuthSettings() {
|
export function useTenantAuthSettings() {
|
||||||
return useQuery<AuthPolicy>({
|
return useQuery<AuthPolicy>({
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
|
||||||
import { errorMessage } from '../../api/client';
|
import { errorMessage } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -12,11 +11,14 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword,
|
useTenantSettings,
|
||||||
useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove,
|
useResetServerAdminPassword,
|
||||||
useUpdateTenantSettings, usePasskeyList, useRenamePasskey, useDeletePasskey,
|
useUpdateTenantSettings,
|
||||||
useTenantAuthSettings, useUpdateTenantAuthSettings,
|
useTenantAuthSettings, useUpdateTenantAuthSettings,
|
||||||
} from '../../api/tenant-hooks';
|
} from '../../api/tenant-hooks';
|
||||||
|
import { MfaSection } from '../../components/account/MfaSection';
|
||||||
|
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection';
|
||||||
|
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';
|
||||||
import { useScopes } from '../../auth/useScopes';
|
import { useScopes } from '../../auth/useScopes';
|
||||||
import { tierColor } from '../../utils/tier';
|
import { tierColor } from '../../utils/tier';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
@@ -31,244 +33,6 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function MfaSection() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { data: mfaStatus, isLoading: statusLoading } = useMfaStatus();
|
|
||||||
const setup = useMfaSetup();
|
|
||||||
const verify = useMfaVerify();
|
|
||||||
const backupCodes = useMfaBackupCodes();
|
|
||||||
const remove = useMfaRemove();
|
|
||||||
|
|
||||||
const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
|
|
||||||
const [verifyCode, setVerifyCode] = useState('');
|
|
||||||
const [codes, setCodes] = useState<string[] | null>(null);
|
|
||||||
const [codesSaved, setCodesSaved] = useState(false);
|
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
||||||
|
|
||||||
async function handleStartSetup() {
|
|
||||||
try {
|
|
||||||
const data = await setup.mutateAsync();
|
|
||||||
setSetupData(data);
|
|
||||||
setVerifyCode('');
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to start MFA setup', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleVerify(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!setupData) return;
|
|
||||||
try {
|
|
||||||
const result = await verify.mutateAsync({ secret: setupData.secret, code: verifyCode });
|
|
||||||
if (result.verified) {
|
|
||||||
const bc = await backupCodes.mutateAsync();
|
|
||||||
setCodes(bc.codes);
|
|
||||||
setSetupData(null);
|
|
||||||
setVerifyCode('');
|
|
||||||
setCodesSaved(false);
|
|
||||||
toast({ title: 'MFA enabled successfully', variant: 'success' });
|
|
||||||
} else {
|
|
||||||
toast({ title: 'Invalid code. Please try again.', variant: 'error' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRegenerateCodes() {
|
|
||||||
try {
|
|
||||||
const bc = await backupCodes.mutateAsync();
|
|
||||||
setCodes(bc.codes);
|
|
||||||
setCodesSaved(false);
|
|
||||||
toast({ title: 'Backup codes regenerated', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to regenerate backup codes', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemove() {
|
|
||||||
try {
|
|
||||||
await remove.mutateAsync();
|
|
||||||
setConfirmRemove(false);
|
|
||||||
setCodes(null);
|
|
||||||
setSetupData(null);
|
|
||||||
toast({ title: 'MFA removed', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to remove MFA', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCopyAll() {
|
|
||||||
if (!codes) return;
|
|
||||||
navigator.clipboard.writeText(codes.join('\n'));
|
|
||||||
toast({ title: 'Backup codes copied to clipboard', variant: 'success' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDownload() {
|
|
||||||
if (!codes) return;
|
|
||||||
const blob = new Blob([codes.join('\n')], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'cameleer-mfa-backup-codes.txt';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusLoading) {
|
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup codes display
|
|
||||||
if (codes) {
|
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
|
||||||
<Alert variant="warning" title="Save your backup codes">
|
|
||||||
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
|
|
||||||
</Alert>
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '8px 24px',
|
|
||||||
marginTop: 16,
|
|
||||||
padding: '16px',
|
|
||||||
background: 'var(--bg-inset)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontFamily: 'var(--font-mono, monospace)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}>
|
|
||||||
{codes.map((code) => (
|
|
||||||
<span key={code}>{code}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
||||||
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
|
|
||||||
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
|
|
||||||
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
|
|
||||||
I've saved my backup codes
|
|
||||||
</label>
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup flow — QR code + verification
|
|
||||||
if (setupData) {
|
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
|
|
||||||
<QRCodeSVG value={setupData.secretQrCode} size={200} />
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: 'var(--bg-inset)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontFamily: 'var(--font-mono, monospace)',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}>
|
|
||||||
{setupData.secret}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<FormField label="Verification code" htmlFor="mfa-code">
|
|
||||||
<Input
|
|
||||||
id="mfa-code"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={6}
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
value={verifyCode}
|
|
||||||
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
placeholder="Enter 6-digit code"
|
|
||||||
required
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
|
|
||||||
Verify & Enable
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main view — enrolled or not
|
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
||||||
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
|
|
||||||
{mfaStatus?.enrolled ? (
|
|
||||||
<Badge label="Enrolled" color="success" />
|
|
||||||
) : (
|
|
||||||
<Badge label="Not enrolled" color="auto" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{mfaStatus?.enrolled ? (
|
|
||||||
<>
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Your account is protected with a TOTP authenticator app.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
|
||||||
Regenerate backup codes
|
|
||||||
</Button>
|
|
||||||
{confirmRemove ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<Alert variant="error" title="This will disable MFA on your account." />
|
|
||||||
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
|
||||||
Confirm removal
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
|
||||||
Remove MFA
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
|
|
||||||
Set up authenticator app
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MfaEnforcementToggle() {
|
function MfaEnforcementToggle() {
|
||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -341,126 +105,6 @@ function MfaEnforcementToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasskeyNudgeBanner() {
|
|
||||||
const { data: status } = useMfaStatus();
|
|
||||||
const [dismissed, setDismissed] = useState(false);
|
|
||||||
|
|
||||||
const lastDismissed = localStorage.getItem('passkey_nudge_dismissed');
|
|
||||||
const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null;
|
|
||||||
|
|
||||||
function handleDismiss() {
|
|
||||||
localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
|
|
||||||
setDismissed(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert variant="info" title="Sign in faster with a passkey">
|
|
||||||
<p style={{ margin: '4px 0 12px' }}>
|
|
||||||
Use your fingerprint, face, or security key instead of typing a code every time.
|
|
||||||
</p>
|
|
||||||
<Button size="sm" variant="secondary" onClick={handleDismiss}>Not now</Button>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasskeySection() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { data: passkeys, isLoading } = usePasskeyList();
|
|
||||||
const renamePasskey = useRenamePasskey();
|
|
||||||
const deletePasskey = useDeletePasskey();
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [editName, setEditName] = useState('');
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function parseAgent(agent: string | null): string {
|
|
||||||
if (!agent) return 'Unknown device';
|
|
||||||
if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome';
|
|
||||||
if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS';
|
|
||||||
if (agent.includes('Firefox')) return 'Firefox';
|
|
||||||
if (agent.includes('Edge')) return 'Edge';
|
|
||||||
return 'Browser';
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRename(id: string, currentName: string | null) {
|
|
||||||
setEditingId(id);
|
|
||||||
setEditName(currentName ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRename(id: string) {
|
|
||||||
try {
|
|
||||||
await renamePasskey.mutateAsync({ id, name: editName });
|
|
||||||
setEditingId(null);
|
|
||||||
toast({ title: 'Passkey renamed', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
try {
|
|
||||||
await deletePasskey.mutateAsync(id);
|
|
||||||
setConfirmDeleteId(null);
|
|
||||||
toast({ title: 'Passkey removed', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) return null;
|
|
||||||
const credentials = passkeys ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card title="Passkeys">
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Use your fingerprint, face, or security key to sign in faster.
|
|
||||||
</p>
|
|
||||||
{credentials.length === 0 ? (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
|
||||||
No passkeys registered. Passkeys can be registered during sign-in when prompted.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{credentials.map((pk) => (
|
|
||||||
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{editingId === pk.id ? (
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} />
|
|
||||||
<Button size="sm" variant="primary" onClick={() => handleRename(pk.id)} loading={renamePasskey.isPending}>Save</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
|
||||||
{parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{editingId !== pk.id && (
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => startRename(pk.id, pk.name)}>Rename</Button>
|
|
||||||
{confirmDeleteId === pk.id ? (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="danger" onClick={() => handleDelete(pk.id)} loading={deletePasskey.isPending}>Confirm</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => setConfirmDeleteId(null)}>Cancel</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" variant="danger" onClick={() => setConfirmDeleteId(pk.id)}>Remove</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthPolicySection() {
|
function AuthPolicySection() {
|
||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -544,34 +188,11 @@ function AuthPolicySection() {
|
|||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { data, isLoading, isError } = useTenantSettings();
|
const { data, isLoading, isError } = useTenantSettings();
|
||||||
const changePassword = useChangeOwnPassword();
|
|
||||||
const resetServerAdmin = useResetServerAdminPassword();
|
const resetServerAdmin = useResetServerAdminPassword();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [serverAdminPw, setServerAdminPw] = useState('');
|
const [serverAdminPw, setServerAdminPw] = useState('');
|
||||||
|
|
||||||
async function handleChangePassword(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (newPassword.length < 8) {
|
|
||||||
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
toast({ title: 'Passwords do not match', variant: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await changePassword.mutateAsync(newPassword);
|
|
||||||
toast({ title: 'Password changed successfully', variant: 'success' });
|
|
||||||
setNewPassword('');
|
|
||||||
setConfirmPassword('');
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to change password', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
@@ -628,40 +249,7 @@ export function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Change Password">
|
<PasswordChangeSection />
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Update your login password. Minimum 8 characters.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
|
|
||||||
<FormField label="New password" htmlFor="new-pw">
|
|
||||||
<Input
|
|
||||||
id="new-pw"
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
placeholder="Enter new password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Confirm password" htmlFor="confirm-pw">
|
|
||||||
<Input
|
|
||||||
id="confirm-pw"
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div>
|
|
||||||
<Button type="submit" variant="primary" loading={changePassword.isPending}>
|
|
||||||
Change Password
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Server Admin Password">
|
<Card title="Server Admin Password">
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user