aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 22:41:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 22:41:02 +0200
commit8d8f85b4bf976249266a89f692408027216711b7 (patch)
tree79cdc211a7791e5f25f96879606329eee03ddb11 /packages/meshbay-hub/src/meshbay_hub/static
parent38f91818f876c51dcd7eb7911b65fc7bf5154c83 (diff)
downloadmeshbay-8d8f85b4bf976249266a89f692408027216711b7.tar.gz
feat(account): a user can delete their own account, an admin can delete one
Both go through the same erasure, so there is one description of what happens rather than two that drift. Gone: credentials, email, node key, group memberships, notifications, refresh tokens, node registrations. The username is released. Kept, on purpose and stated in the UI: the row itself, emptied, and the IP log that points at it. Those logs exist for a year to answer legal requests, and a log that can no longer say whose connection it recorded keeps the data while losing the only thing it is for. So the account becomes a tombstone rather than a hole in the table. Out of reach, also stated: files uploaded to nodes, and the identity keys nodes pinned. Those are on machines the hub does not command, and only their operators can remove them — `member unpin` and a delete on their own disk. Saying so in the confirmation matters more than the button. Owning groups blocks deletion, with the list. Cascading would delete other people's groups out from under them; the account holder can hand them over or delete them first, deliberately. Self-deletion re-checks the passphrase. A live token may be a borrowed laptop or a tab left open, and it is not consent to something irreversible. Admin deletion requires admin rather than moderator: suspension is the reversible moderation tool and stays one click away. A deleted account's access token stops working at once — the status check already refuses anything but "active", which the tests now pin down, because refresh tokens being gone would otherwise leave up to an hour of usable session. Tests: 8 covering what survives and what does not, plus a db_session fixture for assertions that cannot honestly be made through the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css13
3 files changed, 96 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index d8b83ed..7b1b5fc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -2130,7 +2130,7 @@ function SearchPage() {
const THEME_OPTIONS = ['light', 'dark', 'system'];
-function SettingsPage({ user, theme, onThemeChange, groups }) {
+function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
const [locale, setLoc] = useState(getLocale);
const [muted, setMuted] = useState(() => {
try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); }
@@ -2142,6 +2142,29 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
const [nodeKeyLoading, setNodeKeyLoading] = 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 {
+ // The passphrase is re-checked by the hub, not merely by this form: an
+ // open session is not consent to something irreversible.
+ 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]);
// 11.5.8: node identity pins are refused strictly on change, so users need a
// deliberate way to accept a legitimate rotation (operator reinstalled a node).
@@ -2251,6 +2274,33 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</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 class="settings-section">
<h3 class="settings-heading">${t('settings.appearance')}</h3>
<div class="settings-row">
<span class="settings-label">${t('settings.theme')}</span>
@@ -2368,6 +2418,18 @@ function AdminPage({ token }) {
else if (tab === 'blocklist') loadBlocklist();
}, [tab]);
+ const deleteUser = useCallback(async (u) => {
+ // Suspension is the reversible tool and stays one click away; this one is
+ // not, so it names the account and says what it cannot reach.
+ if (!confirm(t('admin.delete_confirm', { user: u.username }))) return;
+ try {
+ await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token });
+ loadUsers(userSearch);
+ } catch (err) {
+ alert(err.message);
+ }
+ }, [token, userSearch, loadUsers]);
+
const patchUser = useCallback(async (userId, patch) => {
try {
await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token });
@@ -2510,6 +2572,10 @@ function AdminPage({ token }) {
? html`<button class="admin-btn" onClick=${() => patchUser(u.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
: null
}
+ ${u.status !== 'deleted' && html`
+ <button class="admin-btn danger"
+ onClick=${() => deleteUser(u)}>${t('admin.btn_delete')}</button>
+ `}
</td>
</tr>
`)}
@@ -2806,7 +2872,7 @@ function App() {
: html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
- onThemeChange=${setTheme} groups=${groups} />`;
+ onThemeChange=${setTheme} groups=${groups} onLogout=${authCtx.logout} />`;
} else {
page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index c19411b..1b03375 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -132,6 +132,16 @@ const en = {
'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.",
'settings.node_pins_count': '{n} pinned',
'settings.node_pins_clear': 'Clear pinned identities',
+ 'settings.danger': 'Delete account',
+ 'settings.delete_hint': 'Removes your account, your group memberships and your '
+ + 'notifications from the hub, and frees your username. It cannot reach what '
+ + 'lives on nodes: files you uploaded stay where their operator keeps them, '
+ + 'and each node keeps the identity it pinned for you until its operator '
+ + 'unpins it. Connection logs are kept for a year, as the law requires.',
+ 'settings.delete_account': 'Delete my account',
+ 'settings.delete_confirm': 'Enter your passphrase to confirm. This cannot be undone.',
+ 'settings.delete_confirm_btn': 'Delete for good',
+ 'settings.cancel': 'Cancel',
'settings.appearance': 'Appearance',
'settings.theme': 'Theme',
'settings.theme_light': 'Light',
@@ -190,6 +200,11 @@ const en = {
'admin.btn_suspend': 'Suspend',
'admin.btn_unsuspend': 'Unsuspend',
'admin.btn_details': 'Details',
+ 'admin.btn_delete': 'Delete',
+ 'admin.delete_confirm': 'Delete the account "{user}"? This cannot be undone. '
+ + 'Files they uploaded stay on the nodes that host them, and each node keeps '
+ + 'the identity it pinned until its operator unpins it. Suspending is the '
+ + 'reversible option.',
'admin.user_detail': 'User details',
'admin.btn_close': 'Close',
'admin.self_note': '(you)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 361ff7e..d948dc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1420,3 +1420,16 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 8px;
background: var(--bg-surface);
}
+
+/* Irreversible actions look like it. */
+.btn-danger {
+ background: var(--error);
+ color: #fff;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9em;
+}
+.btn-danger:hover { filter: brightness(1.1); }
+.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }