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')}