mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
refactor(settings): share the account settings logic between desktop and phone
Profile and avatar, the password change, two factor setup with its backup codes and the account deletion now live in one useAccountSettings hook. Two existing differences stay as options: the toast key for a failed avatar removal and whether the backup code actions skip an empty code list.
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
import React, { useState, useEffect, useId } from 'react'
|
||||
import React, { useId } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer, ShieldCheck } from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { authApi, adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { UserWithOidc } from '../../types'
|
||||
import Section from './Section'
|
||||
import PasskeysSection from './PasskeysSection'
|
||||
import PasswordChecklist from '../shared/PasswordChecklist'
|
||||
import { passwordErrorKey } from '../../utils/passwordError'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
@@ -19,8 +11,7 @@ import { EditorField, GRID_2, INPUT, PANEL } from '../shared/dialogParts'
|
||||
import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
|
||||
} from './settingsKit'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
|
||||
|
||||
/** The small turning ring a button shows while its request is on the way. */
|
||||
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
|
||||
@@ -28,153 +19,55 @@ const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border
|
||||
/** The eyebrow over a block inside a card body. */
|
||||
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
|
||||
// Drops every trailing slash, like the `/\/+$/` replace it stands in for — as a scan,
|
||||
// because that pattern re-walks the slash run from each start position.
|
||||
const stripTrailingSlashes = (value: string): string => {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end--
|
||||
return value.slice(0, end)
|
||||
}
|
||||
|
||||
export default function AccountTab(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const blockedLabelId = useId()
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(config => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode &&
|
||||
!user?.mfa_enabled &&
|
||||
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
if (!backupCodesText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const oidcIssuer = (user as UserWithOidc)?.oidc_issuer
|
||||
const {
|
||||
user,
|
||||
demoMode,
|
||||
avatarInputRef,
|
||||
saving,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
username,
|
||||
setUsername,
|
||||
email,
|
||||
setEmail,
|
||||
currentPassword,
|
||||
setCurrentPassword,
|
||||
newPassword,
|
||||
setNewPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
oidcOnlyMode,
|
||||
mfaQr,
|
||||
mfaSecret,
|
||||
mfaSetupCode,
|
||||
setMfaSetupCode,
|
||||
mfaDisablePwd,
|
||||
setMfaDisablePwd,
|
||||
mfaDisableCode,
|
||||
setMfaDisableCode,
|
||||
mfaLoading,
|
||||
backupCodes,
|
||||
backupCodesText,
|
||||
mfaRequiredByPolicy,
|
||||
oidcIssuer,
|
||||
dismissBackupCodes,
|
||||
copyBackupCodes,
|
||||
downloadBackupCodes,
|
||||
printBackupCodes,
|
||||
handleAvatarUpload,
|
||||
handleAvatarRemove,
|
||||
saveProfile,
|
||||
changePassword,
|
||||
startMfaSetup,
|
||||
cancelMfaSetup,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
requestDelete,
|
||||
deleteAccount,
|
||||
} = useAccountSettings({ avatarRemoveErrorKey: 'settings.avatarError', ignoreEmptyBackupCodes: true })
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -251,19 +144,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
{/* The card's foot: the way out on the left, the save on the right, like a dialog's bar. */}
|
||||
<div className="-mx-4 -mb-4 flex items-center gap-2 border-t border-edge-faint bg-surface-card px-4 py-3" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onClick={requestDelete}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -321,21 +202,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<PasswordChecklist password={newPassword} />
|
||||
<div className="flex justify-end" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
const weak = passwordErrorKey(newPassword)
|
||||
if (weak) return toast.error(t(weak))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
onClick={changePassword}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<Lock size={14} />
|
||||
@@ -365,19 +232,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = await authApi.mfaSetup() as { qr_svg: string; secret: string }
|
||||
setMfaQr(data.qr_svg)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
onClick={startMfaSetup}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
@@ -417,7 +272,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<div className="flex flex-wrap justify-end gap-2" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
onClick={cancelMfaSetup}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
@@ -425,26 +280,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
onClick={enableMfa}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
@@ -484,22 +320,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
onClick={disableMfa}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
@@ -575,16 +396,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm === true}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
onConfirm={deleteAccount}
|
||||
title={t('settings.deleteAccountTitle')}
|
||||
message={t('settings.deleteAccountWarning')}
|
||||
confirmLabel={t('settings.deleteAccountConfirm')}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// FE-COMP-ACCOUNT-HOOK-001 to -016: the account settings logic the desktop tab and the phone screen share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { adminApi, authApi } from '../../api/client';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { MFA_BACKUP_SESSION_KEY, stripTrailingSlashes, useAccountSettings } from './useAccountSettings';
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router')>('react-router');
|
||||
return { ...actual, useNavigate: () => navigate };
|
||||
});
|
||||
|
||||
const STRONG = 'Correct-Horse-9-Battery';
|
||||
|
||||
function seedAuth(over: Record<string, unknown> = {}) {
|
||||
const store = {
|
||||
user: buildUser({ username: 'ann', email: 'ann@example.com', role: 'user', mfa_enabled: false }),
|
||||
demoMode: false,
|
||||
appRequireMfa: false,
|
||||
updateProfile: vi.fn().mockResolvedValue(undefined),
|
||||
uploadAvatar: vi.fn().mockResolvedValue({}),
|
||||
deleteAvatar: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn(),
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
...over,
|
||||
};
|
||||
seedStore(useAuthStore, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function render(opts: { desktop?: boolean; url?: string } = {}) {
|
||||
const desktop = opts.desktop ?? true;
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[opts.url ?? '/settings']}>{children}</MemoryRouter>
|
||||
);
|
||||
return renderHook(
|
||||
() =>
|
||||
useAccountSettings({
|
||||
avatarRemoveErrorKey: desktop ? 'settings.avatarError' : 'settings.avatarRemoveError',
|
||||
ignoreEmptyBackupCodes: desktop,
|
||||
}),
|
||||
{ wrapper }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
vi.spyOn(authApi, 'getAppConfig').mockResolvedValue({} as Awaited<ReturnType<typeof authApi.getAppConfig>>);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('stripTrailingSlashes', () => {
|
||||
it('FE-COMP-ACCOUNT-HOOK-001: drops every trailing slash and nothing else', () => {
|
||||
expect(stripTrailingSlashes('id.example.com///')).toBe('id.example.com');
|
||||
expect(stripTrailingSlashes('a/b')).toBe('a/b');
|
||||
expect(stripTrailingSlashes('///')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAccountSettings', () => {
|
||||
it('FE-COMP-ACCOUNT-HOOK-002: fills the profile from the user and follows it', async () => {
|
||||
seedAuth();
|
||||
const { result } = render();
|
||||
expect(result.current.username).toBe('ann');
|
||||
expect(result.current.email).toBe('ann@example.com');
|
||||
act(() => useAuthStore.setState({ user: buildUser({ username: 'bob', email: 'b@x.io' }) }));
|
||||
await waitFor(() => expect(result.current.username).toBe('bob'));
|
||||
expect(result.current.email).toBe('b@x.io');
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-003: an OIDC only instance hides the password change', async () => {
|
||||
seedAuth();
|
||||
vi.mocked(authApi.getAppConfig).mockResolvedValue({ oidc_only_mode: true } as Awaited<
|
||||
ReturnType<typeof authApi.getAppConfig>
|
||||
>);
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(result.current.oidcOnlyMode).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-004: MFA is required by the URL or the instance policy, never in demo mode', () => {
|
||||
seedAuth();
|
||||
expect(render({ url: '/settings?mfa=required' }).result.current.mfaRequiredByPolicy).toBe(true);
|
||||
seedAuth({ appRequireMfa: true });
|
||||
expect(render().result.current.mfaRequiredByPolicy).toBe(true);
|
||||
seedAuth({ appRequireMfa: true, demoMode: true });
|
||||
expect(render().result.current.mfaRequiredByPolicy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-005: saving the profile toasts success, or the error message', async () => {
|
||||
const store = seedAuth();
|
||||
const { result } = render();
|
||||
act(() => result.current.setUsername('ann2'));
|
||||
await act(() => result.current.saveProfile());
|
||||
expect(store.updateProfile).toHaveBeenCalledWith({ username: 'ann2', email: 'ann@example.com' });
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.toast.profileSaved');
|
||||
store.updateProfile.mockRejectedValueOnce(new Error('Taken'));
|
||||
await act(() => result.current.saveProfile());
|
||||
expect(toast.error).toHaveBeenCalledWith('Taken');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-006: the password change checks each field before it sends anything', async () => {
|
||||
seedAuth();
|
||||
const change = vi.spyOn(authApi, 'changePassword').mockResolvedValue({});
|
||||
const { result } = render();
|
||||
await act(() => result.current.changePassword());
|
||||
expect(toast.error).toHaveBeenLastCalledWith('settings.currentPasswordRequired');
|
||||
act(() => result.current.setCurrentPassword('old'));
|
||||
await act(() => result.current.changePassword());
|
||||
expect(toast.error).toHaveBeenLastCalledWith('settings.passwordRequired');
|
||||
act(() => result.current.setNewPassword(STRONG));
|
||||
act(() => result.current.setConfirmPassword('other'));
|
||||
await act(() => result.current.changePassword());
|
||||
expect(toast.error).toHaveBeenLastCalledWith('settings.passwordMismatch');
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-007: a valid password change sends, clears the fields and reloads the user', async () => {
|
||||
const store = seedAuth();
|
||||
const change = vi.spyOn(authApi, 'changePassword').mockResolvedValue({});
|
||||
const { result } = render();
|
||||
act(() => result.current.setCurrentPassword('old'));
|
||||
act(() => result.current.setNewPassword(STRONG));
|
||||
act(() => result.current.setConfirmPassword(STRONG));
|
||||
await act(() => result.current.changePassword());
|
||||
expect(change).toHaveBeenCalledWith({ current_password: 'old', new_password: STRONG });
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.passwordChanged');
|
||||
expect(result.current.currentPassword).toBe('');
|
||||
expect(result.current.newPassword).toBe('');
|
||||
expect(store.loadUser).toHaveBeenCalledWith({ silent: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-008: MFA setup shows the QR and secret, cancel clears them', async () => {
|
||||
seedAuth();
|
||||
vi.spyOn(authApi, 'mfaSetup').mockResolvedValue({ qr_svg: '<svg/>', secret: 'SECRET' });
|
||||
const { result } = render();
|
||||
await act(() => result.current.startMfaSetup());
|
||||
expect(result.current.mfaQr).toBe('<svg/>');
|
||||
expect(result.current.mfaSecret).toBe('SECRET');
|
||||
expect(result.current.mfaLoading).toBe(false);
|
||||
act(() => result.current.cancelMfaSetup());
|
||||
expect(result.current.mfaQr).toBeNull();
|
||||
expect(result.current.mfaSecret).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-009: enabling MFA keeps the backup codes for the session and reloads the user', async () => {
|
||||
const store = seedAuth();
|
||||
vi.spyOn(authApi, 'mfaEnable').mockResolvedValue({ success: true, mfa_enabled: true, backup_codes: ['a1', 'b2'] });
|
||||
const { result } = render();
|
||||
act(() => result.current.setMfaSetupCode('123456'));
|
||||
await act(() => result.current.enableMfa());
|
||||
expect(authApi.mfaEnable).toHaveBeenCalledWith({ code: '123456' });
|
||||
expect(result.current.backupCodes).toEqual(['a1', 'b2']);
|
||||
expect(result.current.backupCodesText).toBe('a1\nb2');
|
||||
expect(sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)).toBe('["a1","b2"]');
|
||||
expect(store.loadUser).toHaveBeenCalledWith({ silent: true });
|
||||
act(() => result.current.dismissBackupCodes());
|
||||
expect(result.current.backupCodes).toBeNull();
|
||||
expect(sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-010: pending backup codes come back from the session once MFA is on', async () => {
|
||||
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, '["x9"]');
|
||||
seedAuth({ user: buildUser({ mfa_enabled: true }) });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(result.current.backupCodes).toEqual(['x9']));
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-011: disabling MFA sends password and code and forgets the codes', async () => {
|
||||
seedAuth({ user: buildUser({ mfa_enabled: true }) });
|
||||
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, '["x9"]');
|
||||
vi.spyOn(authApi, 'mfaDisable').mockResolvedValue({});
|
||||
const { result } = render();
|
||||
act(() => result.current.setMfaDisablePwd('pw'));
|
||||
act(() => result.current.setMfaDisableCode('654321'));
|
||||
await act(() => result.current.disableMfa());
|
||||
expect(authApi.mfaDisable).toHaveBeenCalledWith({ password: 'pw', code: '654321' });
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.mfa.toastDisabled');
|
||||
expect(sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-012: a failed avatar removal names the error each shell asks for', async () => {
|
||||
seedAuth({ deleteAvatar: vi.fn().mockRejectedValue(new Error('x')) });
|
||||
const desktop = render({ desktop: true });
|
||||
await act(() => desktop.result.current.handleAvatarRemove());
|
||||
expect(toast.error).toHaveBeenLastCalledWith('settings.avatarError');
|
||||
const phone = render({ desktop: false });
|
||||
await act(() => phone.result.current.handleAvatarRemove());
|
||||
expect(toast.error).toHaveBeenLastCalledWith('settings.avatarRemoveError');
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-013: the last admin is blocked from deleting, others reach the confirm', async () => {
|
||||
seedAuth({ user: buildUser({ role: 'admin' }) });
|
||||
vi.spyOn(adminApi, 'stats').mockResolvedValue({});
|
||||
const users = vi.spyOn(adminApi, 'users').mockResolvedValue({ users: [{ role: 'admin' }, { role: 'user' }] });
|
||||
const { result } = render();
|
||||
await act(() => result.current.requestDelete());
|
||||
expect(result.current.showDeleteConfirm).toBe('blocked');
|
||||
users.mockResolvedValue({ users: [{ role: 'admin' }, { role: 'admin' }] });
|
||||
await act(() => result.current.requestDelete());
|
||||
expect(result.current.showDeleteConfirm).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-014: deleting the account logs out and leaves for the login page', async () => {
|
||||
const store = seedAuth();
|
||||
vi.spyOn(authApi, 'deleteOwnAccount').mockResolvedValue({});
|
||||
const { result } = render();
|
||||
await act(() => result.current.deleteAccount());
|
||||
expect(store.logout).toHaveBeenCalled();
|
||||
expect(navigate).toHaveBeenCalledWith('/login', { state: { noRedirect: true } });
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-015: with codes, copy, download and print hand over the code text', async () => {
|
||||
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, '["a1","b<2"]');
|
||||
seedAuth({ user: buildUser({ mfa_enabled: true }) });
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true });
|
||||
const createUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:codes');
|
||||
const revokeUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
const printWindow = {
|
||||
document: { open: vi.fn(), write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
print: vi.fn(),
|
||||
};
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(printWindow as unknown as Window);
|
||||
const { result } = render({ desktop: true });
|
||||
await waitFor(() => expect(result.current.backupCodesText).toBe('a1\nb<2'));
|
||||
|
||||
await act(() => result.current.copyBackupCodes());
|
||||
expect(writeText).toHaveBeenCalledWith('a1\nb<2');
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.mfa.backupCopied');
|
||||
writeText.mockRejectedValueOnce(new Error('denied'));
|
||||
await act(() => result.current.copyBackupCodes());
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
|
||||
act(() => result.current.downloadBackupCodes());
|
||||
expect(createUrl).toHaveBeenCalledWith(expect.any(Blob));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(revokeUrl).toHaveBeenCalledWith('blob:codes');
|
||||
|
||||
act(() => result.current.printBackupCodes());
|
||||
expect(open).toHaveBeenCalledWith('', '_blank', 'width=900,height=700');
|
||||
expect(printWindow.document.write).toHaveBeenCalledWith(expect.stringContaining('<pre>a1\nb<2</pre>'));
|
||||
expect(printWindow.print).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-HOOK-016: without codes the desktop skips the code actions, the phone still runs them', async () => {
|
||||
seedAuth();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true });
|
||||
const createUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:codes');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
const desktop = render({ desktop: true }).result;
|
||||
expect(desktop.current.backupCodesText).toBe('');
|
||||
await act(() => desktop.current.copyBackupCodes());
|
||||
act(() => desktop.current.downloadBackupCodes());
|
||||
act(() => desktop.current.printBackupCodes());
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(createUrl).not.toHaveBeenCalled();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
const phone = render({ desktop: false }).result;
|
||||
await act(() => phone.current.copyBackupCodes());
|
||||
act(() => phone.current.downloadBackupCodes());
|
||||
act(() => phone.current.printBackupCodes());
|
||||
expect(writeText).toHaveBeenCalledWith('');
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.mfa.backupCopied');
|
||||
expect(createUrl).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { escapeHtml } from '@trek/shared';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { adminApi, authApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { getApiErrorMessage, type UserWithOidc } from '../../types';
|
||||
import { passwordErrorKey } from '../../utils/passwordError';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
export const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending';
|
||||
|
||||
/** Drops every trailing slash for display, as a scan: the `/\/+$/` it stands in for backtracks quadratically. */
|
||||
export function stripTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value[end - 1] === '/') end--;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
export interface AccountSettingsOptions {
|
||||
/** The toast key when removing the avatar fails ('settings.avatarError' on the desktop, 'settings.avatarRemoveError' on the phone). */
|
||||
avatarRemoveErrorKey: string;
|
||||
/** Skip copy, download and print while the backup code text is empty (the desktop does, the phone does not). */
|
||||
ignoreEmptyBackupCodes: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account settings behind the desktop tab and the phone screen: profile and
|
||||
* avatar, the password change, TOTP two factor setup with its backup codes, and
|
||||
* deleting the account (refused for the last admin). Each shell renders its own
|
||||
* cards, dialogs and sheets over this.
|
||||
*/
|
||||
export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCodes }: AccountSettingsOptions) {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false);
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '');
|
||||
const [email, setEmail] = useState<string>(user?.email || '');
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '');
|
||||
setEmail(user?.email || '');
|
||||
}, [user]);
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.getAppConfig?.()
|
||||
.then((config) => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null);
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('');
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode && !user?.mfa_enabled && (searchParams.get('mfa') === 'required' || appRequireMfa);
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || '';
|
||||
const skipBackupCodes = ignoreEmptyBackupCodes && !backupCodesText;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) {
|
||||
setBackupCodes(parsed);
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY);
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes]);
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY);
|
||||
setBackupCodes(null);
|
||||
};
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
if (skipBackupCodes) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText);
|
||||
toast.success(t('settings.mfa.backupCopied'));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (skipBackupCodes) return;
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'trek-mfa-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const printBackupCodes = () => {
|
||||
if (skipBackupCodes) return;
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
|
||||
const w = window.open('', '_blank', 'width=900,height=700');
|
||||
if (!w) return;
|
||||
w.document.open();
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
w.print();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
toast.success(t('settings.avatarUploaded'));
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'));
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar();
|
||||
toast.success(t('settings.avatarRemoved'));
|
||||
} catch {
|
||||
toast.error(t(avatarRemoveErrorKey));
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateProfile({ username, email });
|
||||
toast.success(t('settings.toast.profileSaved'));
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'));
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'));
|
||||
const weak = passwordErrorKey(newPassword);
|
||||
if (weak) return toast.error(t(weak));
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'));
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword });
|
||||
toast.success(t('settings.passwordChanged'));
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
await loadUser({ silent: true });
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const startMfaSetup = async () => {
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
const data = (await authApi.mfaSetup()) as { qr_svg: string; secret: string };
|
||||
setMfaQr(data.qr_svg);
|
||||
setMfaSecret(data.secret);
|
||||
setMfaSetupCode('');
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelMfaSetup = () => {
|
||||
setMfaQr(null);
|
||||
setMfaSecret(null);
|
||||
setMfaSetupCode('');
|
||||
};
|
||||
|
||||
const enableMfa = async () => {
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
const resp = (await authApi.mfaEnable({ code: mfaSetupCode })) as { backup_codes?: string[] };
|
||||
toast.success(t('settings.mfa.toastEnabled'));
|
||||
setMfaQr(null);
|
||||
setMfaSecret(null);
|
||||
setMfaSetupCode('');
|
||||
const codes = resp.backup_codes || null;
|
||||
if (codes?.length) {
|
||||
try {
|
||||
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setBackupCodes(codes);
|
||||
await loadUser({ silent: true });
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableMfa = async () => {
|
||||
setMfaLoading(true);
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode });
|
||||
toast.success(t('settings.mfa.toastDisabled'));
|
||||
setMfaDisablePwd('');
|
||||
setMfaDisableCode('');
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY);
|
||||
setBackupCodes(null);
|
||||
await loadUser({ silent: true });
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestDelete = async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats();
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin');
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the normal confirm */
|
||||
}
|
||||
}
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount();
|
||||
logout();
|
||||
navigate('/login', { state: { noRedirect: true } });
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const oidcIssuer = (user as UserWithOidc)?.oidc_issuer;
|
||||
|
||||
return {
|
||||
user,
|
||||
demoMode,
|
||||
avatarInputRef,
|
||||
saving,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
username,
|
||||
setUsername,
|
||||
email,
|
||||
setEmail,
|
||||
currentPassword,
|
||||
setCurrentPassword,
|
||||
newPassword,
|
||||
setNewPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
oidcOnlyMode,
|
||||
mfaQr,
|
||||
mfaSecret,
|
||||
mfaSetupCode,
|
||||
setMfaSetupCode,
|
||||
mfaDisablePwd,
|
||||
setMfaDisablePwd,
|
||||
mfaDisableCode,
|
||||
setMfaDisableCode,
|
||||
mfaLoading,
|
||||
backupCodes,
|
||||
backupCodesText,
|
||||
mfaRequiredByPolicy,
|
||||
oidcIssuer,
|
||||
dismissBackupCodes,
|
||||
copyBackupCodes,
|
||||
downloadBackupCodes,
|
||||
printBackupCodes,
|
||||
handleAvatarUpload,
|
||||
handleAvatarRemove,
|
||||
saveProfile,
|
||||
changePassword,
|
||||
startMfaSetup,
|
||||
cancelMfaSetup,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
requestDelete,
|
||||
deleteAccount,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
@@ -16,263 +16,65 @@ import {
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
import { authApi, adminApi } from '../../../api/client'
|
||||
import { getApiErrorMessage } from '../../../types'
|
||||
import type { UserWithOidc } from '../../../types'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
|
||||
import { passwordErrorKey } from '../../../utils/passwordError'
|
||||
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
/** Drop trailing slashes for display, as a scan: without it /\/+$/ backtracks quadratically. */
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end--
|
||||
return value.slice(0, end)
|
||||
}
|
||||
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
|
||||
|
||||
/**
|
||||
* "Account" section — AccountTab parity: profile + avatar, password change,
|
||||
* TOTP 2FA (setup, backup codes, disable), passkeys and account deletion.
|
||||
*/
|
||||
export default function MSettingsAccount() {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then((config) => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode && !user?.mfa_enabled && (searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = () => {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarRemoveError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
const weak = passwordErrorKey(newPassword)
|
||||
if (weak) return toast.error(t(weak))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const startMfaSetup = async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = (await authApi.mfaSetup()) as { qr_svg: string; secret: string }
|
||||
setMfaQr(data.qr_svg)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const enableMfa = async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const resp = (await authApi.mfaEnable({ code: mfaSetupCode })) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try {
|
||||
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disableMfa = async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const requestDelete = async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch { /* fall through to the normal confirm */ }
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
const oidcIssuer = (user as UserWithOidc)?.oidc_issuer
|
||||
const {
|
||||
user,
|
||||
demoMode,
|
||||
avatarInputRef,
|
||||
saving,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
username,
|
||||
setUsername,
|
||||
email,
|
||||
setEmail,
|
||||
currentPassword,
|
||||
setCurrentPassword,
|
||||
newPassword,
|
||||
setNewPassword,
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
oidcOnlyMode,
|
||||
mfaQr,
|
||||
mfaSecret,
|
||||
mfaSetupCode,
|
||||
setMfaSetupCode,
|
||||
mfaDisablePwd,
|
||||
setMfaDisablePwd,
|
||||
mfaDisableCode,
|
||||
setMfaDisableCode,
|
||||
mfaLoading,
|
||||
backupCodes,
|
||||
backupCodesText,
|
||||
mfaRequiredByPolicy,
|
||||
oidcIssuer,
|
||||
dismissBackupCodes,
|
||||
copyBackupCodes,
|
||||
downloadBackupCodes,
|
||||
printBackupCodes,
|
||||
handleAvatarUpload,
|
||||
handleAvatarRemove,
|
||||
saveProfile,
|
||||
changePassword,
|
||||
startMfaSetup,
|
||||
cancelMfaSetup,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
requestDelete,
|
||||
deleteAccount,
|
||||
} = useAccountSettings({ avatarRemoveErrorKey: 'settings.avatarRemoveError', ignoreEmptyBackupCodes: false })
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -319,7 +121,7 @@ export default function MSettingsAccount() {
|
||||
</div>
|
||||
{oidcIssuer && (
|
||||
<div className="mt-[2px] font-geist text-[0.625rem] text-m-faint">
|
||||
{t('settings.oidcLinked')} {trimTrailingSlashes(oidcIssuer.replace('https://', ''))}
|
||||
{t('settings.oidcLinked')} {stripTrailingSlashes(oidcIssuer.replace('https://', ''))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -422,11 +224,7 @@ export default function MSettingsAccount() {
|
||||
</MSetButton>
|
||||
<MSetButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
}}
|
||||
onClick={cancelMfaSetup}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</MSetButton>
|
||||
|
||||
Reference in New Issue
Block a user