diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/profile-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/profile-page.js | 242 |
1 files changed, 241 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index ce22116..10bcee7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -3,7 +3,10 @@ import { } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; -import { hubFetch } from './hub-client.js'; +import { + hubFetch, HUB, session, setAuth, + _storeBundleKey, _loadBundleKey, _storeRecoveryKey, +} from './hub-client.js'; export function ProfilePage({ user, onLogout }) { const [nodeKey, setNodeKey] = useState(''); @@ -47,6 +50,128 @@ export function ProfilePage({ user, onLogout }) { 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 => { @@ -218,6 +343,121 @@ export function ProfilePage({ user, onLogout }) { </div> <div class="settings-section"> + <h3 class="settings-heading">${t('settings.passphrase')}</h3> + <p class="settings-hint">${t('settings.passphrase_hint')}</p> + ${!cpOpen && html` + <button class="admin-btn" onClick=${() => setCpOpen(true)}> + ${t('settings.passphrase_change')} + </button>`} + + ${cpOpen && cpPhase === 'form' && html` + <form onSubmit=${cpBeginConfirm} style="display:flex;flex-direction:column;gap:8px;max-width:340px"> + <input type="password" autocomplete="current-password" + placeholder=${t('settings.passphrase_current')} + value=${cpOld} onInput=${e => setCpOld(e.target.value)} required /> + <input type="password" autocomplete="new-password" + placeholder=${t('settings.passphrase_new')} + value=${cpNew} onInput=${e => setCpNew(e.target.value)} required /> + <input type="password" autocomplete="new-password" + placeholder=${t('settings.passphrase_new_repeat')} + value=${cpNew2} onInput=${e => setCpNew2(e.target.value)} required /> + <div style="display:flex;gap:8px"> + <button class="admin-btn" type="submit">${t('settings.continue')}</button> + <button class="btn-secondary" type="button" onClick=${cpReset}> + ${t('settings.cancel')} + </button> + </div> + </form>`} + + ${cpOpen && cpPhase === 'confirm' && cpEstimate && html` + <div style="max-width:420px"> + <p class="settings-hint">${t('settings.passphrase_confirm_intro')}</p> + ${cpEstimate.reachable.length > 0 && html` + <p style="margin:8px 0 2px"><strong>${t('settings.passphrase_reachable')}</strong></p> + <ul style="margin:0 0 8px 18px"> + ${cpEstimate.reachable.map(n => html`<li>${n}</li>`)} + </ul>`} + ${cpEstimate.unreachable.length > 0 && html` + <p style="margin:8px 0 2px"><strong>${t('settings.passphrase_unreachable')}</strong></p> + <ul style="margin:0 0 8px 18px"> + ${cpEstimate.unreachable.map(n => html`<li>${n}</li>`)} + </ul>`} + <p class="settings-hint">${t('settings.passphrase_fallback_note')}</p> + ${cpError && html`<p class="error-msg">${cpError}</p>`} + <div style="display:flex;gap:8px;margin-top:8px"> + <button class="btn-danger" onClick=${cpConfirm}> + ${t('settings.passphrase_confirm_btn')} + </button> + <button class="btn-secondary" onClick=${cpReset}>${t('settings.cancel')}</button> + </div> + </div>`} + + ${cpOpen && cpPhase === 'working' && html` + <p class="settings-hint"> + ${t('settings.passphrase_working')} + ${cpProgress && cpProgress.total ? ` (${cpProgress.done}/${cpProgress.total})` : ''} + </p>`} + + ${cpOpen && cpPhase === 'done' && cpResult && html` + <div style="max-width:420px"> + <p style="color:var(--success)">${t('settings.passphrase_done')}</p> + ${(cpResult.unreachable.length > 0 || cpResult.failed.length > 0) && html` + <p class="settings-hint" style="margin-top:8px"> + ${t('settings.passphrase_needs_operator')} + </p> + <ul style="margin:0 0 8px 18px"> + ${cpResult.unreachable.concat(cpResult.failed).map(g => + html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)} + </ul>`} + <button class="admin-btn" onClick=${cpReset}>${t('settings.done')}</button> + </div>`} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.recovery')}</h3> + <p class="settings-hint">${t('settings.recovery_hint')}</p> + ${rkLoaded && rkPhase !== 'done' && html` + <p class="settings-hint" style="color:var(--success)">${t('settings.recovery_loaded')}</p>`} + + ${!rkOpen && html` + <button class="admin-btn" onClick=${() => setRkOpen(true)}> + ${t('settings.recovery_open')} + </button>`} + + ${rkOpen && rkPhase === 'form' && html` + <form onSubmit=${rkBackfill} style="display:flex;flex-direction:column;gap:8px;max-width:360px"> + <textarea placeholder=${t('settings.recovery_input_ph')} + value=${rkInput} onInput=${e => setRkInput(e.target.value)} rows="2" + style="font-family:monospace;font-size:0.9em;letter-spacing:0.08em;resize:vertical"></textarea> + ${rkError && html`<p class="error-msg">${rkError}</p>`} + <div style="display:flex;gap:8px"> + <button class="admin-btn" type="submit">${t('settings.recovery_submit')}</button> + <button class="btn-secondary" type="button" onClick=${rkReset}> + ${t('settings.cancel')} + </button> + </div> + </form>`} + + ${rkOpen && rkPhase === 'working' && html` + <p class="settings-hint"> + ${t('settings.recovery_working')} + ${rkProgress && rkProgress.total ? ` (${rkProgress.done}/${rkProgress.total})` : ''} + </p>`} + + ${rkOpen && rkPhase === 'done' && rkResult && html` + <div style="max-width:420px"> + <p style="color:var(--success)">${t('settings.recovery_done')}</p> + ${(rkResult.unreachable.length > 0 || rkResult.failed.length > 0) && html` + <p class="settings-hint" style="margin-top:8px">${t('settings.recovery_partial')}</p> + <ul style="margin:0 0 8px 18px"> + ${rkResult.unreachable.concat(rkResult.failed).map(g => + html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)} + </ul>`} + <button class="admin-btn" onClick=${rkReset}>${t('settings.done')}</button> + </div>`} + </div> + + <div class="settings-section"> <h3 class="settings-heading">${t('settings.danger')}</h3> <p class="settings-hint">${t('settings.delete_hint')}</p> ${delError && html`<p class="error-msg">${delError}</p>`} |