aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
diff options
context:
space:
mode:
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.js242
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>`}