rspace-online/website/admin.html

904 lines
26 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>Admin Dashboard — rSpace</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: white;
min-height: 100vh;
display: flex;
flex-direction: column;
padding-top: 56px;
}
.admin-container {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.admin-header h1 {
font-size: 2rem;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text;
-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;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.25rem 1.5rem;
flex: 1;
min-width: 140px;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: #14b8a6;
}
.stat-card .stat-label {
font-size: 0.8rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
.controls {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-input {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: white;
font-size: 0.9rem;
flex: 1;
min-width: 200px;
}
.search-input:focus {
outline: none;
border-color: #14b8a6;
}
.search-input::placeholder {
color: #64748b;
}
.filter-btn {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #94a3b8;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: #14b8a6;
color: white;
}
.filter-btn.active {
border-color: #14b8a6;
background: rgba(20, 184, 166, 0.15);
color: #14b8a6;
}
.sort-select {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: white;
font-size: 0.85rem;
cursor: pointer;
}
.sort-select option {
background: #1e293b;
color: white;
}
.sort-select:focus {
outline: none;
border-color: #14b8a6;
}
.admin-table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-table thead {
background: rgba(255, 255, 255, 0.06);
}
.admin-table th {
text-align: left;
padding: 12px 16px;
font-size: 0.75rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
white-space: nowrap;
}
.admin-table td {
padding: 12px 16px;
font-size: 0.9rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: middle;
}
.admin-table tbody tr {
transition: background 0.15s;
}
.admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
.space-name-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.space-name, .user-name {
font-weight: 600;
color: white;
}
.space-slug, .user-id {
font-size: 0.8rem;
color: #64748b;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
font-size: 0.75rem;
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; }
.num-cell {
text-align: right;
font-variant-numeric: tabular-nums;
color: #cbd5e1;
}
.owner-cell, .did-cell {
font-size: 0.8rem;
color: #94a3b8;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-cell {
font-size: 0.85rem;
color: #94a3b8;
white-space: nowrap;
}
.email-cell {
font-size: 0.85rem;
color: #94a3b8;
}
.actions-cell {
display: flex;
gap: 0.5rem;
}
.action-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 6px;
font-size: 0.8rem;
color: #94a3b8;
text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.15s;
white-space: nowrap;
cursor: pointer;
background: none;
}
.action-link:hover {
color: #14b8a6;
border-color: #14b8a6;
background: rgba(20, 184, 166, 0.08);
}
.action-link.danger {
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
}
.action-link.danger:hover {
background: rgba(239, 68, 68, 0.15);
border-color: #f87171;
color: #fca5a5;
}
.loading {
text-align: center;
padding: 4rem;
color: #64748b;
}
.loading .spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 2px solid rgba(20, 184, 166, 0.3);
border-top-color: #14b8a6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 4rem;
color: #64748b;
}
.back-link {
display: inline-block;
color: #64748b;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
.back-link:hover {
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;
}
.admin-table {
display: block;
overflow-x: auto;
}
.stat-card {
min-width: 100px;
}
}
</style>
<link rel="stylesheet" href="/shell.css">
</head>
<body data-theme="dark">
<header class="rstack-header" data-theme="dark">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/logo.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current=""></rstack-app-switcher>
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>
</header>
<div class="admin-container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<a href="/" class="back-link">&larr; Back to rSpace</a>
</div>
<!-- Auth gate (shown until admin verified) -->
<div id="auth-gate" class="auth-gate">
<div class="loading">
<div class="spinner"></div>
<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, 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";
RStackIdentity.define();
RStackAppSwitcher.define();
RStackSpaceSwitcher.define();
RStackMi.define();
// NOTE: modules are loaded from /admin-data along with spaces (single-segment
// path to bypass Cloudflare wildcard subdomain redirect on multi-segment paths)
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("/admin-data", { 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 modules into app switcher
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
// 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";
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
function formatDate(iso) {
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 "\u2014";
if (did.length <= 16) return did;
return did.slice(0, 8) + "..." + did.slice(-6);
}
function visibilityLabel(v) {
const labels = {
public: "Public",
public_read: "Public Read",
authenticated: "Auth Required",
members_only: "Members Only",
};
return labels[v] || v;
}
function getFilteredSortedSpaces() {
let list = allSpaces;
if (currentFilter !== "all") {
list = list.filter(s => s.visibility === currentFilter);
}
const q = document.getElementById("search-input").value.trim().toLowerCase();
if (q) {
list = list.filter(s =>
s.name.toLowerCase().includes(q) ||
s.slug.toLowerCase().includes(q) ||
(s.ownerDID || "").toLowerCase().includes(q)
);
}
const [field, dir] = currentSort.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 === "name") {
va = (a.name || "").toLowerCase();
vb = (b.name || "").toLowerCase();
} else if (field === "shapes") {
va = a.shapeCount;
vb = b.shapeCount;
} else if (field === "size") {
va = a.fileSizeBytes;
vb = b.fileSizeBytes;
}
if (typeof va === "string") {
return dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
}
return dir === "asc" ? va - vb : vb - va;
});
return list;
}
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 => `<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="admin-table">
<thead>
<tr>
<th>Space</th>
<th>Visibility</th>
<th style="text-align:right">Shapes</th>
<th style="text-align:right">Members</th>
<th style="text-align:right">Size</th>
<th>Created</th>
<th>Owner</th>
<th>Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
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);
}
// 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;
renderSpacesTable();
});
});
document.getElementById("sort-select").addEventListener("change", (e) => {
currentSort = e.target.value;
renderSpacesTable();
});
document.getElementById("search-input").addEventListener("input", () => {
renderSpacesTable();
});
// 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("/admin-action", {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete-space", slug }),
});
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>