From 318230508c7f34fb3794d3553e030483efa1a367 Mon Sep 17 00:00:00 2001 From: Tam-Leal Date: Wed, 29 Jul 2026 18:45:59 -0400 Subject: [PATCH] feat(hub): add delete account cascade from Settings Allow users to permanently delete Hub account data (keys, machines, snapshots) with email confirmation and CSRF, matching the Privacy Policy claim. --- hub/auth.js | 8 +++ hub/dashboard.html | 155 ++++++++++++++++++++++++++++++++++++++++++++- hub/db.js | 15 +++++ hub/server.js | 32 ++++++++++ 4 files changed, 207 insertions(+), 3 deletions(-) diff --git a/hub/auth.js b/hub/auth.js index 0ddd7cd..138681e 100644 --- a/hub/auth.js +++ b/hub/auth.js @@ -73,6 +73,13 @@ function destroySession(token) { if (value) sessions.delete(value); } +function destroySessionsForUser(userId) { + if (!userId) return; + for (const [sessionId, s] of sessions.entries()) { + if (s && s.userId === userId) sessions.delete(sessionId); + } +} + function apiKeyFromHeader(authHeader) { if (!authHeader || typeof authHeader !== "string") return null; const m = authHeader.trim().match(/^Bearer\s+(.+)$/i); @@ -103,5 +110,6 @@ module.exports = { createSession, verifySessionCookie, destroySession, + destroySessionsForUser, apiKeyFromHeader, }; diff --git a/hub/dashboard.html b/hub/dashboard.html index f8624ca..6e67052 100644 --- a/hub/dashboard.html +++ b/hub/dashboard.html @@ -913,8 +913,79 @@ .btn-primary:hover { background: var(--accent-hover); box-shadow: none; } .btn-danger { border-color: var(--border); color: var(--t2); } .btn-danger:hover { border-color: rgba(248,81,73,0.4); color: var(--red); background: rgba(248,81,73,0.06); } + .btn-danger-solid { + background: rgba(248,81,73,0.14); + border-color: rgba(248,81,73,0.45); + color: var(--red); + font-weight: 600; + } + .btn-danger-solid:hover { + background: rgba(248,81,73,0.22); + border-color: rgba(248,81,73,0.6); + color: #fecaca; + } + .btn-danger-solid:disabled { opacity: 0.45; cursor: not-allowed; } .btn-sm { padding: 5px 10px; font-size: 12px; border-radius: var(--r); } + .danger-card { + margin-top: 32px; + padding: 0; + background: var(--bg1); + border-radius: var(--r-lg); + border: 1px solid rgba(248,81,73,0.28); + overflow: hidden; + } + .danger-card-bar { + padding: 8px 14px; + background: rgba(248,81,73,0.08); + border-bottom: 1px solid rgba(248,81,73,0.22); + font-family: var(--mono); + font-size: 11px; + color: var(--red); + } + .danger-card-body { padding: 16px 16px 18px; } + .danger-card .section-title { margin-bottom: 8px; color: var(--red); } + .danger-card .danger-lead { + color: var(--t2); + font-size: 13px; + line-height: 1.5; + margin-bottom: 12px; + } + .danger-card .danger-lead a { color: #93c5fd; } + .danger-card .danger-lead a:hover { color: #bfdbfe; } + .danger-card label { + display: block; + font-family: var(--mono); + font-size: 11px; + color: var(--t3); + margin-bottom: 6px; + } + .danger-card input[type="email"], + .danger-card input[type="text"] { + width: 100%; + max-width: 360px; + padding: 9px 12px; + border-radius: var(--r); + border: 1px solid var(--border); + background: var(--bg0); + color: var(--t1); + font-family: var(--font); + font-size: 13px; + margin-bottom: 12px; + } + .danger-card input:focus { + outline: none; + border-color: rgba(248,81,73,0.5); + background: var(--bg2); + } + .danger-error { + color: var(--red); + font-size: 13px; + margin-bottom: 10px; + display: none; + } + .danger-error.visible { display: block; } + .plan-card { margin-bottom: 24px; padding: 0; @@ -1260,6 +1331,25 @@

Loading…

+ +
+
danger zone
+
+
Delete account
+

+ Permanently deletes your Hub account, API keys, machines, and snapshots. This cannot be undone. + Local GitDock on your machines is not affected. +

+ + + + + +
+
@@ -1324,12 +1414,18 @@

Loading…

return r.json(); }); } - function apiDelete(path) { + function apiDelete(path, body) { var headers = {}; + if (body !== undefined) headers['Content-Type'] = 'application/json'; if (csrfToken) headers['X-CSRF-TOKEN'] = csrfToken; - return fetch(API + path, { method: 'DELETE', credentials: 'include', headers: headers }).then(function(r) { + var opts = { method: 'DELETE', credentials: 'include', headers: headers }; + if (body !== undefined) opts.body = JSON.stringify(body); + return fetch(API + path, opts).then(function(r) { if (r.status === 401) throw new Error('Unauthorized'); - return r.json(); + return r.json().then(function(data) { + data._httpStatus = r.status; + return data; + }); }); } @@ -1933,9 +2029,11 @@

Loading…

var plan = (me && me.plan) ? me.plan : 'free'; var statusEl = document.getElementById('planStatus'); var btnEl = document.getElementById('upgradeToProBtn'); + var proNote = document.getElementById('dangerProNote'); if (plan === 'pro') { statusEl.textContent = 'You are on Pro. Unlimited machines.'; btnEl.style.display = 'none'; + if (proNote) proNote.style.display = 'block'; } else { statusEl.textContent = 'Free plan: 1 machine. Upgrade to Pro for unlimited machines.'; if (me.checkoutUrl) { @@ -1944,6 +2042,12 @@

Loading…

} else { btnEl.style.display = 'none'; } + if (proNote) proNote.style.display = 'none'; + } + if (me && me.email) { + var emailEl = document.getElementById('userEmail'); + if (emailEl) emailEl.textContent = me.email; + updateDeleteAccountBtn(); } }).catch(function() {}); apiGet('/api/keys').then(function(data) { @@ -1969,6 +2073,51 @@

Loading…

}).catch(function() { checkAuth(); }); } + function updateDeleteAccountBtn() { + var input = document.getElementById('deleteAccountEmail'); + var btn = document.getElementById('deleteAccountBtn'); + var emailEl = document.getElementById('userEmail'); + if (!input || !btn || !emailEl) return; + var typed = (input.value || '').trim().toLowerCase(); + var account = (emailEl.textContent || '').trim().toLowerCase(); + btn.disabled = !account || typed !== account; + } + + document.getElementById('deleteAccountEmail').addEventListener('input', function() { + var err = document.getElementById('deleteAccountError'); + if (err) { err.textContent = ''; err.classList.remove('visible'); } + updateDeleteAccountBtn(); + }); + + document.getElementById('deleteAccountBtn').addEventListener('click', function() { + var input = document.getElementById('deleteAccountEmail'); + var err = document.getElementById('deleteAccountError'); + var btn = document.getElementById('deleteAccountBtn'); + var email = (input && input.value || '').trim(); + if (!email || btn.disabled) return; + if (!confirm('Delete your Hub account permanently? This cannot be undone.')) return; + btn.disabled = true; + apiDelete('/api/auth/account', { confirmEmail: email }).then(function(data) { + if (data && data.success) { + csrfToken = null; + input.value = ''; + checkAuth(); + return; + } + if (err) { + err.textContent = (data && data.error) || 'Could not delete account'; + err.classList.add('visible'); + } + updateDeleteAccountBtn(); + }).catch(function() { + if (err) { + err.textContent = 'Could not delete account'; + err.classList.add('visible'); + } + updateDeleteAccountBtn(); + }); + }); + document.getElementById('createKeyBtn').addEventListener('click', function() { var label = document.getElementById('newKeyLabel').value.trim() || 'Default'; apiPost('/api/keys', { label: label }).then(function(data) { diff --git a/hub/db.js b/hub/db.js index 16541be..901cad4 100644 --- a/hub/db.js +++ b/hub/db.js @@ -137,6 +137,20 @@ function setUserPlan(userId, plan) { return r.changes > 0; } +/** Permanently remove a user and all Hub data owned by them. */ +function deleteUserAccount(userId) { + const d = getDb(); + const tx = d.transaction(() => { + d.prepare("DELETE FROM snapshots WHERE user_id = ?").run(userId); + d.prepare("DELETE FROM machines WHERE user_id = ?").run(userId); + d.prepare("DELETE FROM api_keys WHERE user_id = ?").run(userId); + d.prepare("DELETE FROM audit_log WHERE user_id = ?").run(userId); + const r = d.prepare("DELETE FROM users WHERE id = ?").run(userId); + return r.changes > 0; + }); + return tx(); +} + // --- API Keys --- function createApiKey(id, label, keyHash, userId) { const d = getDb(); @@ -274,6 +288,7 @@ module.exports = { getUserByEmail, getUserById, setUserPlan, + deleteUserAccount, createApiKey, getApiKeyHashesForVerification, listApiKeys, diff --git a/hub/server.js b/hub/server.js index 7a63da3..69468f8 100644 --- a/hub/server.js +++ b/hub/server.js @@ -290,6 +290,38 @@ app.get("/api/auth/me", apiLimiter, requireSession, (req, res) => { res.json({ email: user.email, plan, checkoutUrl, csrfToken: req.session.csrfToken }); }); +app.delete("/api/auth/account", apiLimiter, requireSession, requireCsrf, (req, res) => { + const user = db.getUserById(req.userId); + if (!user) return res.status(401).json({ error: "Unauthorized" }); + const confirmEmail = + (req.body && req.body.confirmEmail && String(req.body.confirmEmail).trim().toLowerCase()) || ""; + if (!confirmEmail || confirmEmail !== String(user.email || "").toLowerCase()) { + return res.status(400).json({ + success: false, + error: "Type your account email to confirm deletion", + }); + } + const userId = req.userId; + const wasPro = (user.plan || "free") === "pro"; + const deleted = db.deleteUserAccount(userId); + if (!deleted) { + return res.status(500).json({ success: false, error: "Failed to delete account" }); + } + auth.destroySessionsForUser(userId); + const token = req.cookies && req.cookies[auth.SESSION_COOKIE]; + if (token) auth.destroySession(token); + res.clearCookie(auth.SESSION_COOKIE, { path: "/" }); + res.clearCookie(CSRF_COOKIE, { path: "/" }); + console.log(`[auth] account deleted userId=${userId} wasPro=${wasPro}`); + res.json({ + success: true, + wasPro, + message: wasPro + ? "Account deleted. If you have an active Pro subscription, cancel it in Lemon Squeezy or email support@gitdock.dev." + : "Account deleted.", + }); +}); + // --- Agent: receive snapshot --- app.post("/api/agent/snapshot", snapshotLimiter, requireApiKey, (req, res) => { const body = req.body || {};