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 <noreply@anthropic.com>
This commit is contained in:
parent
34877b1f9e
commit
b12cc52892
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<AdminUserInfo[]> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
const result = await sql`DELETE FROM space_members WHERE space_slug = ${spaceSlug}`;
|
||||
return result.count;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HEALTH CHECK
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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 <noreply@rspace.online>',
|
||||
},
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<a href="/" class="back-link">← Back to rSpace</a>
|
||||
</div>
|
||||
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-total">--</div>
|
||||
<div class="stat-label">Total Spaces</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-shapes">--</div>
|
||||
<div class="stat-label">Total Shapes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-members">--</div>
|
||||
<div class="stat-label">Total Members</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-storage">--</div>
|
||||
<div class="stat-label">Storage Used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search spaces..." />
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="public">Public</button>
|
||||
<button class="filter-btn" data-filter="public_read">Public Read</button>
|
||||
<button class="filter-btn" data-filter="authenticated">Auth</button>
|
||||
<button class="filter-btn" data-filter="members_only">Private</button>
|
||||
<select class="sort-select" id="sort-select">
|
||||
<option value="created-desc">Newest First</option>
|
||||
<option value="created-asc">Oldest First</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="shapes-desc">Most Shapes</option>
|
||||
<option value="size-desc">Largest Size</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="table-container">
|
||||
<!-- Auth gate (shown until admin verified) -->
|
||||
<div id="auth-gate" class="auth-gate">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading spaces...</p>
|
||||
<p>Checking admin access...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content (hidden until admin verified) -->
|
||||
<div id="admin-content" style="display:none">
|
||||
<div class="tab-nav">
|
||||
<button class="tab-btn active" data-tab="spaces">Spaces</button>
|
||||
<button class="tab-btn" data-tab="users">Users</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SPACES TAB ═══ -->
|
||||
<div id="tab-spaces" class="tab-panel active">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-total">--</div>
|
||||
<div class="stat-label">Total Spaces</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-shapes">--</div>
|
||||
<div class="stat-label">Total Shapes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-members">--</div>
|
||||
<div class="stat-label">Total Members</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-storage">--</div>
|
||||
<div class="stat-label">Storage Used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search spaces..." />
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="public">Public</button>
|
||||
<button class="filter-btn" data-filter="public_read">Public Read</button>
|
||||
<button class="filter-btn" data-filter="authenticated">Auth</button>
|
||||
<button class="filter-btn" data-filter="members_only">Private</button>
|
||||
<select class="sort-select" id="sort-select">
|
||||
<option value="created-desc">Newest First</option>
|
||||
<option value="created-asc">Oldest First</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="shapes-desc">Most Shapes</option>
|
||||
<option value="size-desc">Largest Size</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="spaces-table-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading spaces...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ USERS TAB ═══ -->
|
||||
<div id="tab-users" class="tab-panel">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-users-total">--</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-users-credentials">--</div>
|
||||
<div class="stat-label">Total Passkeys</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-users-memberships">--</div>
|
||||
<div class="stat-label">Total Memberships</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input type="text" class="search-input" id="users-search-input" placeholder="Search users..." />
|
||||
<select class="sort-select" id="users-sort-select">
|
||||
<option value="created-desc">Newest First</option>
|
||||
<option value="created-asc">Oldest First</option>
|
||||
<option value="username-asc">Username A-Z</option>
|
||||
<option value="username-desc">Username Z-A</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="users-table-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { RStackIdentity } from "@shared/components/rstack-identity";
|
||||
import { RStackIdentity, getAccessToken } from "@shared/components/rstack-identity";
|
||||
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
|
||||
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
|
||||
import { RStackMi } from "@shared/components/rstack-mi";
|
||||
|
|
@ -440,6 +506,79 @@
|
|||
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
|
||||
}).catch(() => {});
|
||||
|
||||
const ENCRYPTID_URL = "https://auth.rspace.online";
|
||||
|
||||
function authHeaders() {
|
||||
const token = getAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// AUTH GATE — verify admin access before showing content
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
let adminVerified = false;
|
||||
|
||||
async function checkAdmin() {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
document.getElementById("auth-gate").innerHTML =
|
||||
'<div class="auth-gate"><h2>Sign in required</h2><p>Sign in with your EncryptID to access the admin dashboard.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/spaces/admin", { headers: authHeaders() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
document.getElementById("auth-gate").innerHTML =
|
||||
'<div class="auth-gate"><h2>Access Denied</h2><p>Your account does not have admin privileges.</p></div>';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error("Server error");
|
||||
|
||||
const data = await res.json();
|
||||
adminVerified = true;
|
||||
document.getElementById("auth-gate").style.display = "none";
|
||||
document.getElementById("admin-content").style.display = "block";
|
||||
|
||||
// Load spaces data
|
||||
allSpaces = data.spaces || [];
|
||||
updateSpaceStats();
|
||||
renderSpacesTable();
|
||||
} catch (err) {
|
||||
document.getElementById("auth-gate").innerHTML =
|
||||
`<div class="auth-gate"><h2>Error</h2><p>${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry after sign-in
|
||||
document.addEventListener("auth-change", () => {
|
||||
if (!adminVerified) checkAdmin();
|
||||
});
|
||||
|
||||
// Wait a moment for identity component to restore session
|
||||
setTimeout(checkAdmin, 500);
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// TAB SWITCHING
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
let usersLoaded = false;
|
||||
|
||||
document.querySelectorAll(".tab-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-panel").forEach(p => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
||||
if (btn.dataset.tab === "users" && !usersLoaded) loadUsers();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// SPACES TAB
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
let allSpaces = [];
|
||||
let currentFilter = "all";
|
||||
let currentSort = "created-desc";
|
||||
|
|
@ -453,13 +592,13 @@
|
|||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
if (!iso) return "\u2014";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function truncateDID(did) {
|
||||
if (!did) return "—";
|
||||
if (!did) return "\u2014";
|
||||
if (did.length <= 16) return did;
|
||||
return did.slice(0, 8) + "..." + did.slice(-6);
|
||||
}
|
||||
|
|
@ -474,15 +613,11 @@
|
|||
return labels[v] || v;
|
||||
}
|
||||
|
||||
function getFilteredSorted() {
|
||||
function getFilteredSortedSpaces() {
|
||||
let list = allSpaces;
|
||||
|
||||
// Filter
|
||||
if (currentFilter !== "all") {
|
||||
list = list.filter(s => s.visibility === currentFilter);
|
||||
}
|
||||
|
||||
// Search
|
||||
const q = document.getElementById("search-input").value.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(s =>
|
||||
|
|
@ -491,8 +626,6 @@
|
|||
(s.ownerDID || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const [field, dir] = currentSort.split("-");
|
||||
list = [...list].sort((a, b) => {
|
||||
let va, vb;
|
||||
|
|
@ -514,50 +647,47 @@
|
|||
}
|
||||
return dir === "asc" ? va - vb : vb - va;
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const list = getFilteredSorted();
|
||||
const container = document.getElementById("table-container");
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = str || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderSpacesTable() {
|
||||
const list = getFilteredSortedSpaces();
|
||||
const container = document.getElementById("spaces-table-container");
|
||||
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No spaces match your filters.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = list.map(s => {
|
||||
const topTypes = Object.entries(s.shapeTypes || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([t, c]) => `<span class="shape-type-tag">${t} (${c})</span>`)
|
||||
.join("");
|
||||
|
||||
return `<tr>
|
||||
<td>
|
||||
<div class="space-name-cell">
|
||||
<span class="space-name">${s.name}</span>
|
||||
<span class="space-slug">${s.slug}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge badge-${s.visibility}">${visibilityLabel(s.visibility)}</span></td>
|
||||
<td class="num-cell">${s.shapeCount}</td>
|
||||
<td class="num-cell">${s.memberCount}</td>
|
||||
<td class="num-cell">${formatBytes(s.fileSizeBytes)}</td>
|
||||
<td class="date-cell">${formatDate(s.createdAt)}</td>
|
||||
<td class="owner-cell" title="${s.ownerDID || ""}">${truncateDID(s.ownerDID)}</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
<a href="/${s.slug}/canvas" class="action-link">Open</a>
|
||||
<a href="/api/communities/${s.slug}/shapes" class="action-link" target="_blank">JSON</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
const rows = list.map(s => `<tr>
|
||||
<td>
|
||||
<div class="space-name-cell">
|
||||
<span class="space-name">${escapeHtml(s.name)}</span>
|
||||
<span class="space-slug">${escapeHtml(s.slug)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge badge-${s.visibility}">${visibilityLabel(s.visibility)}</span></td>
|
||||
<td class="num-cell">${s.shapeCount}</td>
|
||||
<td class="num-cell">${s.memberCount}</td>
|
||||
<td class="num-cell">${formatBytes(s.fileSizeBytes)}</td>
|
||||
<td class="date-cell">${formatDate(s.createdAt)}</td>
|
||||
<td class="owner-cell" title="${escapeHtml(s.ownerDID)}">${truncateDID(s.ownerDID)}</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
<a href="/${encodeURIComponent(s.slug)}/canvas" class="action-link">Open</a>
|
||||
<button class="action-link danger" onclick="window.__deleteSpace('${escapeHtml(s.slug)}', '${escapeHtml(s.name)}')">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="spaces-table">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
|
|
@ -574,50 +704,197 @@
|
|||
</table>`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
function updateSpaceStats() {
|
||||
const totalShapes = allSpaces.reduce((sum, s) => sum + s.shapeCount, 0);
|
||||
const totalMembers = allSpaces.reduce((sum, s) => sum + s.memberCount, 0);
|
||||
const totalBytes = allSpaces.reduce((sum, s) => sum + s.fileSizeBytes, 0);
|
||||
|
||||
document.getElementById("stat-total").textContent = allSpaces.length;
|
||||
document.getElementById("stat-shapes").textContent = totalShapes.toLocaleString();
|
||||
document.getElementById("stat-members").textContent = totalMembers;
|
||||
document.getElementById("stat-storage").textContent = formatBytes(totalBytes);
|
||||
}
|
||||
|
||||
// Filter buttons
|
||||
// Spaces filter buttons
|
||||
document.querySelectorAll(".filter-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
currentFilter = btn.dataset.filter;
|
||||
renderTable();
|
||||
renderSpacesTable();
|
||||
});
|
||||
});
|
||||
|
||||
// Sort select
|
||||
document.getElementById("sort-select").addEventListener("change", (e) => {
|
||||
currentSort = e.target.value;
|
||||
renderTable();
|
||||
renderSpacesTable();
|
||||
});
|
||||
|
||||
// Search
|
||||
document.getElementById("search-input").addEventListener("input", () => {
|
||||
renderTable();
|
||||
renderSpacesTable();
|
||||
});
|
||||
|
||||
// Load data
|
||||
fetch("/api/spaces/admin")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
allSpaces = data.spaces || [];
|
||||
updateStats();
|
||||
renderTable();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById("table-container").innerHTML =
|
||||
`<div class="empty-state">Failed to load spaces: ${err.message}</div>`;
|
||||
// Delete space
|
||||
window.__deleteSpace = async function(slug, name) {
|
||||
if (!confirm(`Delete space "${name}" (${slug})?\n\nThis will permanently delete all shapes, members, and data. This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/spaces/admin/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(`Failed to delete: ${data.error || res.statusText}`);
|
||||
return;
|
||||
}
|
||||
allSpaces = allSpaces.filter(s => s.slug !== slug);
|
||||
updateSpaceStats();
|
||||
renderSpacesTable();
|
||||
} catch (err) {
|
||||
alert(`Delete failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// USERS TAB
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
let allUsers = [];
|
||||
let usersSort = "created-desc";
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/admin/users`, { headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
document.getElementById("users-table-container").innerHTML =
|
||||
`<div class="empty-state">Failed to load users: ${data.error || res.statusText}</div>`;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
allUsers = data.users || [];
|
||||
usersLoaded = true;
|
||||
updateUserStats();
|
||||
renderUsersTable();
|
||||
} catch (err) {
|
||||
document.getElementById("users-table-container").innerHTML =
|
||||
`<div class="empty-state">Failed to load users: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredSortedUsers() {
|
||||
let list = allUsers;
|
||||
const q = document.getElementById("users-search-input").value.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(u =>
|
||||
(u.username || "").toLowerCase().includes(q) ||
|
||||
(u.displayName || "").toLowerCase().includes(q) ||
|
||||
(u.email || "").toLowerCase().includes(q) ||
|
||||
(u.did || "").toLowerCase().includes(q) ||
|
||||
(u.userId || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
const [field, dir] = usersSort.split("-");
|
||||
list = [...list].sort((a, b) => {
|
||||
let va, vb;
|
||||
if (field === "created") {
|
||||
va = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
vb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
} else if (field === "username") {
|
||||
va = (a.username || "").toLowerCase();
|
||||
vb = (b.username || "").toLowerCase();
|
||||
}
|
||||
if (typeof va === "string") {
|
||||
return dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
}
|
||||
return dir === "asc" ? va - vb : vb - va;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderUsersTable() {
|
||||
const list = getFilteredSortedUsers();
|
||||
const container = document.getElementById("users-table-container");
|
||||
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No users found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = list.map(u => `<tr>
|
||||
<td>
|
||||
<div class="space-name-cell">
|
||||
<span class="user-name">${escapeHtml(u.displayName || u.username)}</span>
|
||||
<span class="user-id">@${escapeHtml(u.username)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="email-cell">${escapeHtml(u.email) || '<span style="color:#475569">\u2014</span>'}</td>
|
||||
<td class="did-cell" title="${escapeHtml(u.did)}">${truncateDID(u.did)}</td>
|
||||
<td class="num-cell">${u.credentialCount}</td>
|
||||
<td class="num-cell">${u.spaceMembershipCount}</td>
|
||||
<td class="date-cell">${formatDate(u.createdAt)}</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
<button class="action-link danger" onclick="window.__deleteUser('${escapeHtml(u.userId)}', '${escapeHtml(u.username)}')">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>DID</th>
|
||||
<th style="text-align:right">Passkeys</th>
|
||||
<th style="text-align:right">Spaces</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function updateUserStats() {
|
||||
const totalCreds = allUsers.reduce((sum, u) => sum + u.credentialCount, 0);
|
||||
const totalMemberships = allUsers.reduce((sum, u) => sum + u.spaceMembershipCount, 0);
|
||||
document.getElementById("stat-users-total").textContent = allUsers.length;
|
||||
document.getElementById("stat-users-credentials").textContent = totalCreds;
|
||||
document.getElementById("stat-users-memberships").textContent = totalMemberships;
|
||||
}
|
||||
|
||||
document.getElementById("users-search-input").addEventListener("input", () => {
|
||||
renderUsersTable();
|
||||
});
|
||||
|
||||
document.getElementById("users-sort-select").addEventListener("change", (e) => {
|
||||
usersSort = e.target.value;
|
||||
renderUsersTable();
|
||||
});
|
||||
|
||||
// Delete user
|
||||
window.__deleteUser = async function(userId, username) {
|
||||
if (!confirm(`Delete user "@${username}"?\n\nThis will permanently remove their account, all passkeys, and space memberships. This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(`Failed to delete: ${data.error || res.statusText}`);
|
||||
return;
|
||||
}
|
||||
allUsers = allUsers.filter(u => u.userId !== userId);
|
||||
updateUserStats();
|
||||
renderUsersTable();
|
||||
} catch (err) {
|
||||
alert(`Delete failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue