From b12cc5289261a22d2166c74bb36b0b49effc9557 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 22:30:21 -0800 Subject: [PATCH] feat: admin dashboard with user management and delete capabilities - Add tabbed admin UI (Spaces | Users) with auth gate - Add admin API endpoints on EncryptID: list users, delete user, clean space members - Add admin force-delete space endpoint on rSpace (bypasses owner check) - Protect all admin endpoints with ADMIN_DIDS env var - Add ADMIN_DIDS to both Docker Compose configs Co-Authored-By: Claude Opus 4.6 --- docker-compose.encryptid.yml | 1 + docker-compose.yml | 1 + server/spaces.ts | 57 ++++ src/encryptid/db.ts | 54 ++++ src/encryptid/server.ts | 51 +++ website/admin.html | 591 +++++++++++++++++++++++++---------- 6 files changed, 598 insertions(+), 157 deletions(-) diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml index 3c66333..b0a55b0 100644 --- a/docker-compose.encryptid.yml +++ b/docker-compose.encryptid.yml @@ -24,6 +24,7 @@ services: - RECOVERY_URL=${RECOVERY_URL:-https://auth.rspace.online/recover} - MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080} - MAILCOW_API_KEY=${MAILCOW_API_KEY:-} + - ADMIN_DIDS=${ADMIN_DIDS} labels: # Traefik auto-discovery - "traefik.enable=true" diff --git a/docker-compose.yml b/docker-compose.yml index 2485cec..811d006 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: - IMAP_PORT=993 - IMAP_TLS_REJECT_UNAUTHORIZED=false - TWENTY_API_URL=https://rnetwork.online + - ADMIN_DIDS=${ADMIN_DIDS} depends_on: rspace-db: condition: service_healthy diff --git a/server/spaces.ts b/server/spaces.ts index 7fc1318..ae7e47e 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -203,9 +203,21 @@ spaces.post("/", async (c) => { // ── Static routes must be defined BEFORE /:slug to avoid matching as a slug ── +const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean); + +function isAdmin(did: string | undefined): boolean { + return !!did && ADMIN_DIDS.includes(did); +} + // ── Admin: list ALL spaces with detailed stats ── spaces.get("/admin", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403); + const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; const slugs = await listCommunities(); @@ -262,6 +274,51 @@ spaces.get("/admin", async (c) => { return c.json({ spaces: spacesList, total: spacesList.length }); }); +// ── Admin: force-delete a space (bypasses owner check) ── + +spaces.delete("/admin/:slug", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403); + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + // Notify modules + for (const mod of getAllModules()) { + if (mod.onSpaceDelete) { + try { await mod.onSpaceDelete(slug); } catch (e) { + console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e); + } + } + } + + // Clean up in-memory request maps + for (const [id, req] of accessRequests) { + if (req.spaceSlug === slug) accessRequests.delete(id); + } + for (const [id, req] of nestRequests) { + if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id); + } + + // Clean up EncryptID space_members + try { + await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + } catch (e) { + console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e); + } + + await deleteCommunity(slug); + return c.json({ ok: true, message: `Space "${slug}" deleted by admin` }); +}); + // ── Get pending access requests for spaces the user owns ── spaces.get("/notifications", async (c) => { diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 750bcb5..c2e2d1a 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -766,6 +766,60 @@ export async function setEmailForward(userId: string, enabled: boolean, mailcowI `; } +// ============================================================================ +// ADMIN OPERATIONS +// ============================================================================ + +export interface AdminUserInfo { + userId: string; + username: string; + displayName: string | null; + did: string | null; + email: string | null; + createdAt: string; + credentialCount: number; + spaceMembershipCount: number; +} + +export async function listAllUsers(): Promise { + const rows = await sql` + SELECT u.id, u.username, u.display_name, u.did, u.email, u.created_at, + (SELECT COUNT(*)::int FROM credentials c WHERE c.user_id = u.id) as credential_count, + (SELECT COUNT(*)::int FROM space_members sm WHERE sm.user_did = u.did) as space_membership_count + FROM users u + ORDER BY u.created_at DESC + `; + return rows.map(row => ({ + userId: row.id, + username: row.username, + displayName: row.display_name || null, + did: row.did || null, + email: row.email || null, + createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), + credentialCount: Number(row.credential_count), + spaceMembershipCount: Number(row.space_membership_count), + })); +} + +export async function deleteUser(userId: string): Promise { + const user = await getUserById(userId); + if (!user) return false; + + // Remove space memberships for this user's DID + if (user.did) { + await sql`DELETE FROM space_members WHERE user_did = ${user.did}`; + } + + // Delete user (CASCADE handles credentials, recovery_tokens, guardians, etc.) + const result = await sql`DELETE FROM users WHERE id = ${userId}`; + return result.count > 0; +} + +export async function deleteSpaceMembers(spaceSlug: string): Promise { + const result = await sql`DELETE FROM space_members WHERE space_slug = ${spaceSlug}`; + return result.count; +} + // ============================================================================ // HEALTH CHECK // ============================================================================ diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 69c0e1c..e127140 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -64,6 +64,10 @@ import { deleteUserAddress, getEmailForwardStatus, setEmailForward, + listAllUsers, + deleteUser, + deleteSpaceMembers, + sql, } from './db.js'; import { isMailcowConfigured, @@ -97,6 +101,7 @@ const CONFIG = { from: process.env.SMTP_FROM || 'EncryptID ', }, recoveryUrl: process.env.RECOVERY_URL || 'https://auth.rspace.online/recover', + adminDIDs: (process.env.ADMIN_DIDS || '').split(',').filter(Boolean), allowedOrigins: [ // rspace.online — RP ID domain and all subdomains 'https://rspace.online', @@ -2435,6 +2440,52 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => { return c.json({ success: true }); }); +// ============================================================================ +// ADMIN ROUTES +// ============================================================================ + +function isAdmin(did: string | undefined): boolean { + if (!did || CONFIG.adminDIDs.length === 0) return false; + return CONFIG.adminDIDs.includes(did); +} + +// GET /api/admin/users — list all users (admin only) +app.get('/api/admin/users', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403); + + const users = await listAllUsers(); + return c.json({ users, total: users.length }); +}); + +// DELETE /api/admin/users/:userId — delete a user (admin only) +app.delete('/api/admin/users/:userId', async (c) => { + const userId = c.req.param('userId'); + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403); + + if (userId === claims.sub) { + return c.json({ error: 'Cannot delete your own account' }, 400); + } + + const deleted = await deleteUser(userId); + if (!deleted) return c.json({ error: 'User not found' }, 404); + return c.json({ ok: true, message: `User ${userId} deleted` }); +}); + +// DELETE /api/admin/spaces/:slug/members — clean up space members (admin only) +app.delete('/api/admin/spaces/:slug/members', async (c) => { + const slug = c.req.param('slug'); + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403); + + const count = await deleteSpaceMembers(slug); + return c.json({ ok: true, removed: count }); +}); + // ============================================================================ // SERVE STATIC FILES // ============================================================================ diff --git a/website/admin.html b/website/admin.html index 5a9a08e..6e9c835 100644 --- a/website/admin.html +++ b/website/admin.html @@ -33,7 +33,7 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 2rem; + margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } @@ -45,6 +45,35 @@ -webkit-text-fill-color: transparent; } + /* Tabs */ + .tab-nav { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .tab-btn { + padding: 12px 24px; + border: none; + background: transparent; + color: #94a3b8; + font-size: 1rem; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + } + + .tab-btn:hover { color: white; } + + .tab-btn.active { + color: #14b8a6; + border-bottom-color: #14b8a6; + } + + .tab-panel { display: none; } + .tab-panel.active { display: block; } + .stats-row { display: flex; gap: 1rem; @@ -144,7 +173,7 @@ border-color: #14b8a6; } - .spaces-table { + .admin-table { width: 100%; border-collapse: collapse; background: rgba(255, 255, 255, 0.03); @@ -153,11 +182,11 @@ border: 1px solid rgba(255, 255, 255, 0.08); } - .spaces-table thead { + .admin-table thead { background: rgba(255, 255, 255, 0.06); } - .spaces-table th { + .admin-table th { text-align: left; padding: 12px 16px; font-size: 0.75rem; @@ -165,37 +194,21 @@ text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; - cursor: pointer; - user-select: none; white-space: nowrap; } - .spaces-table th:hover { - color: #14b8a6; - } - - .spaces-table th .sort-arrow { - margin-left: 4px; - opacity: 0.4; - } - - .spaces-table th.sorted .sort-arrow { - opacity: 1; - color: #14b8a6; - } - - .spaces-table td { + .admin-table td { padding: 12px 16px; font-size: 0.9rem; border-top: 1px solid rgba(255, 255, 255, 0.06); vertical-align: middle; } - .spaces-table tbody tr { + .admin-table tbody tr { transition: background 0.15s; } - .spaces-table tbody tr:hover { + .admin-table tbody tr:hover { background: rgba(255, 255, 255, 0.04); } @@ -205,12 +218,12 @@ gap: 2px; } - .space-name { + .space-name, .user-name { font-weight: 600; color: white; } - .space-slug { + .space-slug, .user-id { font-size: 0.8rem; color: #64748b; } @@ -223,25 +236,10 @@ font-weight: 500; } - .badge-public { - background: rgba(34, 197, 94, 0.15); - color: #4ade80; - } - - .badge-public_read { - background: rgba(59, 130, 246, 0.15); - color: #60a5fa; - } - - .badge-authenticated { - background: rgba(251, 191, 36, 0.15); - color: #fbbf24; - } - - .badge-members_only { - background: rgba(239, 68, 68, 0.15); - color: #f87171; - } + .badge-public { background: rgba(34, 197, 94, 0.15); color: #4ade80; } + .badge-public_read { background: rgba(59, 130, 246, 0.15); color: #60a5fa; } + .badge-authenticated { background: rgba(251, 191, 36, 0.15); color: #fbbf24; } + .badge-members_only { background: rgba(239, 68, 68, 0.15); color: #f87171; } .num-cell { text-align: right; @@ -249,7 +247,7 @@ color: #cbd5e1; } - .owner-cell { + .owner-cell, .did-cell { font-size: 0.8rem; color: #94a3b8; max-width: 120px; @@ -264,6 +262,11 @@ white-space: nowrap; } + .email-cell { + font-size: 0.85rem; + color: #94a3b8; + } + .actions-cell { display: flex; gap: 0.5rem; @@ -281,6 +284,8 @@ border: 1px solid rgba(255, 255, 255, 0.1); transition: all 0.15s; white-space: nowrap; + cursor: pointer; + background: none; } .action-link:hover { @@ -289,19 +294,15 @@ background: rgba(20, 184, 166, 0.08); } - .shape-types { - display: flex; - flex-wrap: wrap; - gap: 4px; + .action-link.danger { + color: #f87171; + border-color: rgba(248, 113, 113, 0.3); } - .shape-type-tag { - font-size: 0.7rem; - padding: 2px 6px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.06); - color: #94a3b8; - white-space: nowrap; + .action-link.danger:hover { + background: rgba(239, 68, 68, 0.15); + border-color: #f87171; + color: #fca5a5; } .loading { @@ -343,12 +344,23 @@ color: #94a3b8; } + .auth-gate { + text-align: center; + padding: 6rem 2rem; + color: #64748b; + } + + .auth-gate h2 { + color: #94a3b8; + margin-bottom: 0.5rem; + } + @media (max-width: 768px) { .admin-container { padding: 1rem; } - .spaces-table { + .admin-table { display: block; overflow-x: auto; } @@ -381,52 +393,106 @@ ← Back to rSpace -
-
-
--
-
Total Spaces
-
-
-
--
-
Total Shapes
-
-
-
--
-
Total Members
-
-
-
--
-
Storage Used
-
-
- -
- - - - - - - -
- -
+ +
-

Loading spaces...

+

Checking admin access...

+
+
+ + +