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 | 200 |
1 files changed, 200 insertions, 0 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 new file mode 100644 index 0000000..3d3e578 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -0,0 +1,200 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { hubFetch } 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 [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); + }, []); + + 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 { + await hubFetch('/v1/users/me', { + method: 'PATCH', token: user.token, body: { email: val }, + }); + 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 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` + <div> + <h2>${t('profile.title')}</h2> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.profile')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.username')}</span> + <span class="settings-value">${user.username}</span> + </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.email')}</span> + ${emailEditing + ? html`<span style="display:flex;gap:8px;align-items:center"> + <input type="email" value=${emailDraft} + onInput=${e => setEmailDraft(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && saveEmail()} + style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> + <button class="admin-btn" onClick=${saveEmail} + disabled=${emailSaving}>${t('settings.email_save')}</button> + <button class="btn-secondary" onClick=${() => { + setEmailEditing(false); setEmailDraft(email); + }}>${t('settings.cancel')}</button> + </span>` + : html`<span style="display:flex;gap:8px;align-items:center"> + <span class="settings-value">${email || '—'}</span> + <button class="link-btn" onClick=${() => setEmailEditing(true)}> + <${Icon} name="pencil" /></button> + </span>` + } + </div> + ${emailStatus && html`<p class="settings-hint" style="margin-top:4px">${emailStatus}</p>`} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.node_key')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">${t('settings.node_key_desc')}</p> + ${currentNodeKey && html` + <div class="settings-row" style="margin-top:8px"> + <span class="settings-label">${t('settings.node_key_current')}</span> + <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code> + </div> + `} + <div style="display:flex;gap:8px;margin-top:10px;align-items:center"> + <input type="text" class="admin-search" style="flex:1;font-family:monospace;font-size:0.85em" + placeholder=${t('settings.node_key_placeholder')} + value=${nodeKey} onInput=${e => setNodeKey(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> + <button class="admin-btn" onClick=${submitNodeKey} + disabled=${nodeKeyLoading || !nodeKey.trim()}> + ${t('settings.node_key_submit')} + </button> + </div> + ${nodeKeyStatus && html` + <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}"> + ${nodeKeyStatus} + </p> + `} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.node_pins')}</h3> + <p class="settings-hint">${t('settings.node_pins_hint')}</p> + <div class="settings-row"> + <span class="settings-label">${t('settings.node_pins_count', { n: pinCount })}</span> + <button class="btn-secondary" onClick=${clearPins} disabled=${pinCount === 0}> + ${t('settings.node_pins_clear')} + </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>`} + ${!delOpen + ? html`<button class="btn-danger" onClick=${() => setDelOpen(true)}> + ${t('settings.delete_account')} + </button>` + : html` + <form onSubmit=${deleteAccount}> + <p class="settings-hint">${t('settings.delete_confirm')}</p> + <div style="display:flex;gap:8px;margin-top:8px"> + <input type="password" placeholder=${t('login.password')} + autocomplete="current-password" + value=${delPass} onInput=${e => setDelPass(e.target.value)} required /> + <button class="btn-danger" type="submit" disabled=${deleting}> + ${deleting ? '…' : t('settings.delete_confirm_btn')} + </button> + <button class="btn-secondary" type="button" + onClick=${() => { setDelOpen(false); setDelPass(''); setDelError(''); }}> + ${t('settings.cancel')} + </button> + </div> + </form> + `} + </div> + + </div> + `; +} |