From a8dc000e7e45cc450d4b53881023aac3a15e20d1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:47:29 +0200 Subject: [PATCH] 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. --- client/src/components/Settings/AccountTab.tsx | 298 +++------------- .../Settings/useAccountSettings.test.tsx | 285 +++++++++++++++ .../components/Settings/useAccountSettings.ts | 327 ++++++++++++++++++ .../screens/settings/MSettingsAccount.tsx | 302 +++------------- 4 files changed, 717 insertions(+), 495 deletions(-) create mode 100644 client/src/components/Settings/useAccountSettings.test.tsx create mode 100644 client/src/components/Settings/useAccountSettings.ts diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index debce647a..462ad20f4 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -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(null) const blockedLabelId = useId() - - const [saving, setSaving] = useState(false) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - - // Profile - const [username, setUsername] = useState(user?.username || '') - const [email, setEmail] = useState(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(null) - const [mfaSecret, setMfaSecret] = useState(null) - const [mfaSetupCode, setMfaSetupCode] = useState('') - const [mfaDisablePwd, setMfaDisablePwd] = useState('') - const [mfaDisableCode, setMfaDisableCode] = useState('') - const [mfaLoading, setMfaLoading] = useState(false) - const [backupCodes, setBackupCodes] = useState(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 = `TREK MFA Backup Codes - -

TREK MFA Backup Codes

${escapeHtml(new Date().toLocaleString())}

${escapeHtml(backupCodesText)}
` - 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) => { - 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. */}
{oidcIssuer && (
- {t('settings.oidcLinked')} {trimTrailingSlashes(oidcIssuer.replace('https://', ''))} + {t('settings.oidcLinked')} {stripTrailingSlashes(oidcIssuer.replace('https://', ''))}
)} @@ -422,11 +224,7 @@ export default function MSettingsAccount() { { - setMfaQr(null) - setMfaSecret(null) - setMfaSetupCode('') - }} + onClick={cancelMfaSetup} > {t('settings.mfa.cancelSetup')}