From 9245c6032bf8710fdf78e8d9bef03c453fcbaee6 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Sat, 21 Mar 2026 18:55:07 -0400 Subject: [PATCH] v0.11.11 updated user suspend/delete rules --- backend/package.json | 2 +- backend/src/routes/users.js | 52 +++++++++++++++++++------- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/pages/UserManagerPage.jsx | 2 +- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/backend/package.json b/backend/package.json index b099092..237a92c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.11.10", + "version": "0.11.11", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 11a06b6..c5ded0c 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -86,7 +86,7 @@ router.post('/', authMiddleware, adminMiddleware, async (req, res) => { if (!name || !email) return res.status(400).json({ error: 'Name and email required' }); if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' }); try { - const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email = $1', [email]); + const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email = $1 AND status != 'deleted'", [email]); if (exists) return res.status(400).json({ error: 'Email already in use' }); const resolvedName = await resolveUniqueName(req.schema, name.trim()); const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234'; @@ -120,7 +120,7 @@ router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => { if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; } if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; } seenEmails.add(email); - const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email=$1', [email]); + const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email=$1 AND status != 'deleted'", [email]); if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; } try { const resolvedName = await resolveUniqueName(req.schema, name); @@ -208,22 +208,48 @@ router.delete('/:id', authMiddleware, adminMiddleware, async (req, res) if (!t) return res.status(404).json({ error: 'User not found' }); if (t.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' }); - // Mark deleted - await exec(req.schema, "UPDATE users SET status='deleted', updated_at=NOW() WHERE id=$1", [t.id]); + // ── 1. Anonymise the user record ───────────────────────────────────────── + // Scrub the email immediately so the address is free for re-use. + // Replace name/display_name/avatar/about_me so no PII is retained. + await exec(req.schema, ` + UPDATE users SET + status = 'deleted', + email = $1, + name = 'Deleted User', + display_name = NULL, + avatar = NULL, + about_me = NULL, + password = '', + updated_at = NOW() + WHERE id = $2 + `, [`deleted_${t.id}@deleted`, t.id]); - // Remove from all chat group memberships - await exec(req.schema, 'DELETE FROM group_members WHERE user_id=$1', [t.id]); + // ── 2. Anonymise their messages ─────────────────────────────────────────── + // Mark all their messages as deleted so they render as "This message was + // deleted" in conversation history — no content holes for other members. + await exec(req.schema, + 'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE user_id=$1 AND is_deleted=FALSE', + [t.id] + ); - // Remove from all user groups (managed groups) + // ── 3. Freeze any DMs that only had this user + one other person ────────── + // The surviving peer still has their DM visible but it becomes read-only + // (frozen) since the other party is gone. Group chats (3+ people) are + // left intact — the other members' history and ongoing chat is unaffected. + await exec(req.schema, ` + UPDATE groups SET is_readonly=TRUE, updated_at=NOW() + WHERE is_direct=TRUE + AND (direct_peer1_id=$1 OR direct_peer2_id=$1) + `, [t.id]); + + // ── 4. Remove memberships ──────────────────────────────────────────────── + await exec(req.schema, 'DELETE FROM group_members WHERE user_id=$1', [t.id]); await exec(req.schema, 'DELETE FROM user_group_members WHERE user_id=$1', [t.id]); - // Clear all active sessions so they cannot log in - await exec(req.schema, 'DELETE FROM active_sessions WHERE user_id=$1', [t.id]); - - // Remove push subscriptions + // ── 5. Purge sessions, push subscriptions, notifications ───────────────── + await exec(req.schema, 'DELETE FROM active_sessions WHERE user_id=$1', [t.id]); await exec(req.schema, 'DELETE FROM push_subscriptions WHERE user_id=$1', [t.id]); - - // Remove event availability responses + await exec(req.schema, 'DELETE FROM notifications WHERE user_id=$1', [t.id]); await exec(req.schema, 'DELETE FROM event_availability WHERE user_id=$1', [t.id]); res.json({ success: true }); diff --git a/build.sh b/build.sh index d6c04e2..53e5658 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.11.10}" +VERSION="${1:-0.11.11}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index d2db17a..3600a8b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.11.10", + "version": "0.11.11", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 4d9ab64..5b53d87 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -63,7 +63,7 @@ function UserRow({ u, onUpdated }) { }; const handleDelete = async () => { if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error'); - if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return; + if (!confirm(`Delete ${u.name}?\n\nThis will:\n• Anonymise their account and free their email for re-use\n• Remove all their messages from conversations\n• Freeze any direct messages they were part of\n• Remove all their group memberships\n\nThis cannot be undone.`)) return; try { await api.deleteUser(u.id); toast('User deleted', 'success'); onUpdated(); } catch (e) { toast(e.message, 'error'); } };