import { html, useState, useEffect, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; import { hubFetch, HUB, session, setAuth, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, } from './hub-client.js'; 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 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); } finally { setDeleting(false); } }, [delPass, user]); const clearPins = useCallback(() => { window.MeshBayTransport?.clearNodePin?.(); setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); }, []); // ── Passphrase change (docs/auth-confirm.md §3) ───────────────────────── 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 { 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 = /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/auth-confirm.md §4.3) ─────────────────── // 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`

${t('profile.title')}

${t('settings.profile')}

${t('settings.username')} ${user.username}
${t('settings.email')} ${emailEditing ? html` setEmailDraft(e.target.value)} onKeyDown=${e => e.key === 'Enter' && saveEmail()} disabled=${emailVerifyPending} style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> ${!emailVerifyPending && html` `} ` : html` ${email || '—'} ` }
${emailVerifyPending && html`
setEmailCode(e.target.value)} onKeyDown=${e => e.key === 'Enter' && verifyEmailChange()} inputmode="numeric" maxlength="6" style="width:120px;text-align:center;font-size:1.1em;letter-spacing:0.2em" />
`} ${emailStatus && html`

${emailStatus}

`}

${t('settings.node_key')}

${t('settings.node_key_desc')}

${currentNodeKey && html`
${t('settings.node_key_current')} ${currentNodeKey}
`}
setNodeKey(e.target.value)} onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} />
${nodeKeyStatus && html`

${nodeKeyStatus}

`}

${t('settings.node_pins')}

${t('settings.node_pins_hint')}

${t('settings.node_pins_count', { n: pinCount })}

${t('settings.passphrase')}

${t('settings.passphrase_hint')}

${!cpOpen && html` `} ${cpOpen && cpPhase === 'form' && html`
setCpOld(e.target.value)} required /> setCpNew(e.target.value)} required /> setCpNew2(e.target.value)} required />
`} ${cpOpen && cpPhase === 'confirm' && cpEstimate && html`

${t('settings.passphrase_confirm_intro')}

${cpEstimate.reachable.length > 0 && html`

${t('settings.passphrase_reachable')}

    ${cpEstimate.reachable.map(n => html`
  • ${n}
  • `)}
`} ${cpEstimate.unreachable.length > 0 && html`

${t('settings.passphrase_unreachable')}

    ${cpEstimate.unreachable.map(n => html`
  • ${n}
  • `)}
`}

${t('settings.passphrase_fallback_note')}

${cpError && html`

${cpError}

`}
`} ${cpOpen && cpPhase === 'working' && html`

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

    ${cpResult.unreachable.concat(cpResult.failed).map(g => html`
  • ${g.name}${g.reason ? ` — ${g.reason}` : ''}
  • `)}
`}
`}

${t('settings.recovery')}

${t('settings.recovery_hint')}

${rkLoaded && rkPhase !== 'done' && html`

${t('settings.recovery_loaded')}

`} ${!rkOpen && html` `} ${rkOpen && rkPhase === 'form' && html`
${rkError && html`

${rkError}

`}
`} ${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')}

    ${rkResult.unreachable.concat(rkResult.failed).map(g => html`
  • ${g.name}${g.reason ? ` — ${g.reason}` : ''}
  • `)}
`}
`}

${t('settings.danger')}

${t('settings.delete_hint')}

${delError && html`

${delError}

`} ${!delOpen ? html`` : html`

${t('settings.delete_confirm')}

setDelPass(e.target.value)} required />
`}
`; }