import { html, useState, useEffect, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { ask } from './ask.js'; import { Icon } from './icon.js'; import { hubFetch, HUB, session, setAuth, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, } from './hub-client.js'; // A sign-in lockout also refuses the two actions here that re-check the // passphrase (the hub counts them on the same row). function lockedText(seconds) { return t('login.locked', { minutes: Math.max(1, Math.ceil(seconds / 60)) }); } async function lockedMessage(token) { try { const me = await hubFetch('/v1/users/me', { token }); return lockedText(me.passphrase_locked_for || 60); } catch { return lockedText(60); } } export function ProfilePage({ user, onLogout }) { const [nodeKey, setNodeKey] = useState(''); const [currentNodeKey, setCurrentNodeKey] = useState(null); const [nodeKeyStatus, setNodeKeyStatus] = useState(''); const [nodeKeyLoading, setNodeKeyLoading] = useState(false); const [email, setEmail] = useState(''); const [emailDraft, setEmailDraft] = useState(''); const [emailEditing, setEmailEditing] = useState(false); const [emailSaving, setEmailSaving] = useState(false); const [emailStatus, setEmailStatus] = useState(''); const [emailVerifyPending, setEmailVerifyPending] = useState(false); const [emailCode, setEmailCode] = useState(''); const [emailVerifying, setEmailVerifying] = useState(false); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); const [delOpen, setDelOpen] = useState(false); const [delPass, setDelPass] = useState(''); const [delError, setDelError] = useState(''); const [deleting, setDeleting] = useState(false); const [revoking, setRevoking] = useState(false); const [revokeError, setRevokeError] = useState(''); // Every session of this account stops renewing, this one included — the // case it exists for is a browser left signed in somewhere else. const signOutEverywhere = useCallback(async () => { if (!await ask(t('settings.sign_out_everywhere_confirm'))) return; setRevoking(true); setRevokeError(''); try { await hubFetch('/v1/users/me/sessions/revoke', { method: 'POST', token: user.token }); onLogout(); } catch (err) { setRevokeError(err.message); setRevoking(false); } }, [user, onLogout]); const deleteAccount = useCallback(async (e) => { e.preventDefault(); setDeleting(true); setDelError(''); try { const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username); await hubFetch('/v1/users/me', { method: 'DELETE', token: user.token, body: { auth_key: authKey }, }); onLogout(); } catch (err) { setDelError(err.message === 'account_locked' ? await lockedMessage(user.token) : err.message); } finally { setDeleting(false); } }, [delPass, user]); const clearPins = useCallback(() => { window.MeshBayTransport?.clearNodePin?.(); setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); }, []); // ── Passphrase change (docs/MESHBAY_DESIGN.md §3.6) ───────────────────── const [cpOpen, setCpOpen] = useState(false); const [cpOld, setCpOld] = useState(''); const [cpNew, setCpNew] = useState(''); const [cpNew2, setCpNew2] = useState(''); const [cpPhase, setCpPhase] = useState('form'); // form | confirm | working | done const [cpEstimate, setCpEstimate] = useState(null); const [cpProgress, setCpProgress] = useState(null); const [cpResult, setCpResult] = useState(null); const [cpError, setCpError] = useState(''); const cpReset = useCallback(() => { setCpOpen(false); setCpPhase('form'); setCpOld(''); setCpNew(''); setCpNew2(''); setCpEstimate(null); setCpProgress(null); setCpResult(null); setCpError(''); }, []); const _label = (g) => (g.owner_username ? `${g.name}@${g.owner_username}` : g.name); const cpBeginConfirm = useCallback(async (e) => { e.preventDefault(); setCpError(''); if (cpNew.length < 12) { setCpError(t('settings.pw_too_short')); return; } if (cpNew !== cpNew2) { setCpError(t('settings.pw_mismatch')); return; } if (cpNew === cpOld) { setCpError(t('settings.pw_same')); return; } try { // The next step re-wraps every node's bundle before the hub is asked to // accept the new passphrase. Started during a lockout, the nodes would // take the new one and the hub would refuse it — so ask first. const me = await hubFetch('/v1/users/me', { token: user.token }); if (me.passphrase_locked_for > 0) { setCpError(lockedText(me.passphrase_locked_for)); return; } const mine = await hubFetch('/v1/groups/mine', { token: user.token }); const groups = mine.groups || []; setCpEstimate({ reachable: groups.filter((g) => g.node_online).map(_label), unreachable: groups.filter((g) => !g.node_online).map(_label), }); setCpPhase('confirm'); } catch (err) { setCpError(err.message); } }, [cpOld, cpNew, cpNew2, user.token]); const cpConfirm = useCallback(async () => { setCpPhase('working'); setCpError(''); setCpProgress({ done: 0, total: 0 }); try { // Re-wrap every reachable node's identity bundle first — if this cannot // run at all the account is left untouched. const result = await window.MeshBayTransport.rewrapAllNodes({ hubUrl: HUB, token: user.token, username: user.username, userId: user.userId, oldPassphrase: cpOld, newPassphrase: cpNew, onProgress: setCpProgress, }); const oldAuthKey = await window.MeshBayKeys.deriveAuthKey(cpOld, user.username); const newAuthKey = await window.MeshBayKeys.deriveAuthKey(cpNew, user.username); const resp = await hubFetch('/v1/users/password', { method: 'POST', token: user.token, body: { old_auth_key: oldAuthKey, new_auth_key: newAuthKey }, }); // Keep this tab signed in with the fresh pair, and move the session's // bundle key forward so the next node connection opens the new bundles. setAuth({ ...user, token: resp.access_token, refreshToken: resp.refresh_token }); session.bundleKey = result.newBundleKey; _storeBundleKey(result.newBundleKey); setCpResult(result); setCpPhase('done'); } catch (err) { const msg = err.message === 'account_locked' ? await lockedMessage(user.token) : /403|does not match/i.test(err.message) ? t('settings.pw_wrong_current') : err.message; setCpError(msg); setCpPhase('confirm'); } }, [cpOld, cpNew, user]); // ── Recovery key backfill (docs/MESHBAY_DESIGN.md §3.6) ───────────────── // Enter the recovery key once per browser to add a recovery-wrapped copy of // your identity to every group — covers groups joined before the key was // loaded here. const [rkOpen, setRkOpen] = useState(false); const [rkInput, setRkInput] = useState(''); const [rkPhase, setRkPhase] = useState('form'); // form | working | done const [rkProgress, setRkProgress] = useState(null); const [rkResult, setRkResult] = useState(null); const [rkError, setRkError] = useState(''); const rkLoaded = !!session.recoveryKey; const rkReset = useCallback(() => { setRkOpen(false); setRkPhase('form'); setRkInput(''); setRkProgress(null); setRkResult(null); setRkError(''); }, []); const rkBackfill = useCallback(async (e) => { e.preventDefault(); const mnemonic = rkInput.trim(); if (!mnemonic || !window.MeshBayKeys) return; setRkError(''); if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); if (!session.bundleKey) { setRkError(t('settings.recovery_need_relogin')); return; } setRkPhase('working'); setRkProgress(null); try { // Derives the key (and validates the mnemonic — a bad one throws here). const key = await window.MeshBayKeys.deriveRecoveryKey(mnemonic, user.username); session.recoveryKey = key; await _storeRecoveryKey(key); const r = await window.MeshBayTransport.rewrapAllNodes({ hubUrl: HUB, token: user.token, username: user.username, userId: user.userId, bundleKey: session.bundleKey, // keep the current passphrase key recoveryKey: mnemonic, onProgress: setRkProgress, }); setRkResult(r); setRkPhase('done'); setRkInput(''); } catch (err) { setRkError(err.message); setRkPhase('form'); } }, [rkInput, user]); useEffect(() => { hubFetch('/v1/users/me', { token: user.token }) .then(data => { if (data.email) { setEmail(data.email); setEmailDraft(data.email); } }) .catch(() => {}); hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) .then(data => { if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519); }) .catch(() => {}); }, [user.username, user.token]); const saveEmail = useCallback(async () => { const val = emailDraft.trim(); if (!val || val === email) { setEmailEditing(false); return; } setEmailSaving(true); setEmailStatus(''); try { const resp = await hubFetch('/v1/users/me', { method: 'PATCH', token: user.token, body: { email: val }, }); if (resp.email_verification_required) { setEmailVerifyPending(true); setEmailStatus(t('settings.email_code_sent')); } else { setEmail(val); setEmailEditing(false); setEmailStatus(t('settings.email_saved')); setTimeout(() => setEmailStatus(''), 3000); } } catch (e) { setEmailStatus(e.message); } finally { setEmailSaving(false); } }, [emailDraft, email, user.token]); const verifyEmailChange = useCallback(async () => { if (!emailCode.trim()) return; setEmailVerifying(true); setEmailStatus(''); try { const resp = await hubFetch('/v1/users/verify-email-change', { method: 'POST', token: user.token, body: { code: emailCode.trim() }, }); setEmail(resp.email || emailDraft.trim()); setEmailEditing(false); setEmailVerifyPending(false); setEmailCode(''); setEmailStatus(t('settings.email_verified')); setTimeout(() => setEmailStatus(''), 3000); } catch (e) { setEmailStatus(e.message); } finally { setEmailVerifying(false); } }, [emailCode, emailDraft, user.token]); const submitNodeKey = useCallback(async () => { const key = nodeKey.trim(); if (!key) return; setNodeKeyLoading(true); setNodeKeyStatus(''); try { await hubFetch('/v1/users/me/node_key', { method: 'PUT', token: user.token, body: { pk_node_ed25519: key }, }); setCurrentNodeKey(key); setNodeKey(''); setNodeKeyStatus(t('settings.node_key_success')); } catch (e) { setNodeKeyStatus(e.message); } finally { setNodeKeyLoading(false); } }, [nodeKey, user.token]); return html`
${emailStatus}
`}${t('settings.node_key_desc')}
${currentNodeKey && html`${currentNodeKey}
${nodeKeyStatus}
`}${t('settings.node_pins_hint')}
${t('settings.passphrase_hint')}
${!cpOpen && html` `} ${cpOpen && cpPhase === 'form' && html` `} ${cpOpen && cpPhase === 'confirm' && cpEstimate && html`${t('settings.passphrase_confirm_intro')}
${cpEstimate.reachable.length > 0 && html`${t('settings.passphrase_reachable')}
${t('settings.passphrase_unreachable')}
${t('settings.passphrase_fallback_note')}
${cpError && html`${cpError}
`}${t('settings.passphrase_working')} ${cpProgress && cpProgress.total ? ` (${cpProgress.done}/${cpProgress.total})` : ''}
`} ${cpOpen && cpPhase === 'done' && cpResult && html`${t('settings.passphrase_done')}
${(cpResult.unreachable.length > 0 || cpResult.failed.length > 0) && html`${t('settings.passphrase_needs_operator')}
${t('settings.recovery_hint')}
${rkLoaded && rkPhase !== 'done' && html`${t('settings.recovery_loaded')}
`} ${!rkOpen && html` `} ${rkOpen && rkPhase === 'form' && html` `} ${rkOpen && rkPhase === 'working' && html`${t('settings.recovery_working')} ${rkProgress && rkProgress.total ? ` (${rkProgress.done}/${rkProgress.total})` : ''}
`} ${rkOpen && rkPhase === 'done' && rkResult && html`${t('settings.recovery_done')}
${(rkResult.unreachable.length > 0 || rkResult.failed.length > 0) && html`${t('settings.recovery_partial')}
${t('settings.sign_out_everywhere_hint')}
${revokeError && html`${revokeError}
`}${t('settings.delete_hint')}
${delError && html`${delError}
`} ${!delOpen ? html`` : html` `}