Add admin panel restricted to a single designated account
Adds a full admin view (users + projects, with view/delete) gated server-side by ADMIN_EMAIL in server/auth.js (defaults to the site owner's account, overridable via env var for other deployments). The gate is enforced on every /api/admin/* route, not just hidden in the UI — verified a non-admin session gets 403 even when it hits the endpoints directly. Deleting a user leaves their projects in place (not cascade-deleted) so admin cleanup can't accidentally destroy someone's study data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,4 @@ coverage/
|
|||||||
.vite.log
|
.vite.log
|
||||||
.api.pid
|
.api.pid
|
||||||
.api.log
|
.api.log
|
||||||
|
.DS_Store
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Study App Improvement Suggestions
|
# Study App Improvement Suggestions
|
||||||
|
|
||||||
_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), and read-only share links._
|
_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { authenticator } from 'otplib';
|
import { authenticator } from 'otplib';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
import { getUserById } from './db.js';
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
// The single admin account for this deployment. Override via env var if you
|
||||||
|
// redeploy this app for someone else — don't hardcode your own email into a
|
||||||
|
// fork without changing this.
|
||||||
|
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || 'nmemmert@duck.com').toLowerCase();
|
||||||
|
|
||||||
export function isValidEmail(email) {
|
export function isValidEmail(email) {
|
||||||
return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email);
|
return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email);
|
||||||
@@ -28,6 +33,19 @@ export function requireAuth(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAdminEmail(email) {
|
||||||
|
return typeof email === 'string' && email.toLowerCase() === ADMIN_EMAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocks the request unless the signed-in account is the designated admin. */
|
||||||
|
export function requireAdmin(req, res, next) {
|
||||||
|
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||||
|
if (!user || !isAdminEmail(user.email)) {
|
||||||
|
return res.status(403).json({ error: 'Admin access only.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app)
|
// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -251,6 +251,53 @@ export function getProjectByShareToken(token) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin — unscoped views across every user/project. Callers must gate access
|
||||||
|
// themselves (see requireAdmin in server/auth.js); nothing here checks who's asking.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Every account, with a project count, for the admin users list. */
|
||||||
|
export function adminGetAllUsers() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled,
|
||||||
|
(SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount
|
||||||
|
FROM users u
|
||||||
|
ORDER BY u.created_at ASC
|
||||||
|
`).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
||||||
|
export function adminDeleteUser(userId) {
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every project across every user, with the owner's email, for the admin projects list. */
|
||||||
|
export function adminGetAllProjects() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||||
|
p.share_token AS shareToken, u.email AS ownerEmail
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN users u ON u.id = p.user_id
|
||||||
|
ORDER BY p.last_edited DESC
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full project data by id, regardless of owner — for admin inspection. */
|
||||||
|
export function adminGetProject(id) {
|
||||||
|
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes any project by id, regardless of owner. */
|
||||||
|
export function adminDeleteProject(id) {
|
||||||
|
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Session store backing (used by server/sessionStore.js)
|
// Session store backing (used by server/sessionStore.js)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+66
-5
@@ -9,10 +9,11 @@ import {
|
|||||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||||
|
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { SqliteSessionStore } from './sessionStore.js';
|
import { SqliteSessionStore } from './sessionStore.js';
|
||||||
import {
|
import {
|
||||||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth,
|
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||||
generateBackupCodes, hashBackupCodes, consumeBackupCode,
|
generateBackupCodes, hashBackupCodes, consumeBackupCode,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
@@ -81,7 +82,7 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null });
|
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/register error:', err);
|
console.error('POST /api/auth/register error:', err);
|
||||||
@@ -109,7 +110,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
return res.json({ mfaRequired: true });
|
return res.json({ mfaRequired: true });
|
||||||
}
|
}
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null });
|
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/login error:', err);
|
console.error('POST /api/auth/login error:', err);
|
||||||
@@ -147,7 +148,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null });
|
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/mfa/verify error:', err);
|
console.error('POST /api/auth/mfa/verify error:', err);
|
||||||
@@ -165,7 +166,10 @@ app.post('/api/auth/logout', (req, res) => {
|
|||||||
app.get('/api/auth/me', (req, res) => {
|
app.get('/api/auth/me', (req, res) => {
|
||||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null });
|
res.json({
|
||||||
|
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
||||||
|
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -358,6 +362,63 @@ app.get('/api/share/:token', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||||
|
// in server/auth.js). Full visibility/control over every user and project.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(adminGetAllUsers());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/users error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list users.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.params.id === req.session.userId) {
|
||||||
|
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||||
|
}
|
||||||
|
adminDeleteUser(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/admin/users/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete user.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(adminGetAllProjects());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/projects error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list projects.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = adminGetProject(req.params.id);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
adminDeleteProject(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Serve Vite production build (when NODE_ENV=production)
|
// Serve Vite production build (when NODE_ENV=production)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+201
@@ -35,6 +35,11 @@ import {
|
|||||||
enableSharing,
|
enableSharing,
|
||||||
disableSharing,
|
disableSharing,
|
||||||
getSharedProject,
|
getSharedProject,
|
||||||
|
adminListUsers,
|
||||||
|
adminDeleteUser,
|
||||||
|
adminListProjects,
|
||||||
|
adminGetProject,
|
||||||
|
adminDeleteProject,
|
||||||
} from './syncService.js';
|
} from './syncService.js';
|
||||||
|
|
||||||
const COMMENTARY_OPTIONS = [
|
const COMMENTARY_OPTIONS = [
|
||||||
@@ -1068,6 +1073,13 @@ const App = () => {
|
|||||||
const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
|
const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
|
||||||
const [sharedProject, setSharedProject] = useState(null);
|
const [sharedProject, setSharedProject] = useState(null);
|
||||||
const [sharedError, setSharedError] = useState('');
|
const [sharedError, setSharedError] = useState('');
|
||||||
|
// Admin (only reachable/rendered when authUser.isAdmin)
|
||||||
|
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
|
||||||
|
const [adminUsers, setAdminUsers] = useState([]);
|
||||||
|
const [adminProjects, setAdminProjects] = useState([]);
|
||||||
|
const [adminLoading, setAdminLoading] = useState(false);
|
||||||
|
const [adminError, setAdminError] = useState('');
|
||||||
|
const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed
|
||||||
const [project, setProject] = useState(null);
|
const [project, setProject] = useState(null);
|
||||||
// 'home' | 'setup' | 'study' | 'settings'
|
// 'home' | 'setup' | 'study' | 'settings'
|
||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
@@ -1570,6 +1582,24 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
}, [project?.id, currentPage]);
|
}, [project?.id, currentPage]);
|
||||||
|
|
||||||
|
const loadAdminData = () => {
|
||||||
|
setAdminLoading(true);
|
||||||
|
setAdminError('');
|
||||||
|
Promise.all([adminListUsers(), adminListProjects()]).then(([usersResult, projectsResult]) => {
|
||||||
|
setAdminLoading(false);
|
||||||
|
if (!usersResult.ok || !projectsResult.ok) {
|
||||||
|
setAdminError(usersResult.error ?? projectsResult.error ?? 'Failed to load admin data.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminUsers(usersResult.data);
|
||||||
|
setAdminProjects(projectsResult.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage === 'admin' && authUser?.isAdmin) loadAdminData();
|
||||||
|
}, [currentPage, authUser?.isAdmin]);
|
||||||
|
|
||||||
// Once we know who's signed in, reconcile the local project index against the server.
|
// Once we know who's signed in, reconcile the local project index against the server.
|
||||||
// Runs on every authUser change (including logout -> different login) so a previous
|
// Runs on every authUser change (including logout -> different login) so a previous
|
||||||
// account's stale suggestions never linger after switching users. Projects that exist
|
// account's stale suggestions never linger after switching users. Projects that exist
|
||||||
@@ -3366,6 +3396,15 @@ const deleteProject = (id) => {
|
|||||||
const authStatus = authUser && (
|
const authStatus = authUser && (
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<span className="hidden sm:inline">{authUser.email}</span>
|
<span className="hidden sm:inline">{authUser.email}</span>
|
||||||
|
{authUser.isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage('admin')}
|
||||||
|
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||||
|
>
|
||||||
|
🛡 Admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCurrentPage('settings')}
|
onClick={() => setCurrentPage('settings')}
|
||||||
@@ -3750,6 +3789,168 @@ const deleteProject = (id) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ADMIN PAGE — restricted to the designated admin account (server-enforced)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
if (currentPage === 'admin' && authUser?.isAdmin) {
|
||||||
|
const handleDeleteUserAdmin = async (id, email) => {
|
||||||
|
if (!window.confirm(`Delete account "${email}"? Their projects are kept, not deleted, but become inaccessible until reassigned.`)) return;
|
||||||
|
const result = await adminDeleteUser(id);
|
||||||
|
if (result.ok) loadAdminData();
|
||||||
|
else alert(result.error ?? 'Failed to delete user.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProjectAdmin = async (id, title) => {
|
||||||
|
if (!window.confirm(`Permanently delete project "${title}"? This cannot be undone.`)) return;
|
||||||
|
const result = await adminDeleteProject(id);
|
||||||
|
if (result.ok) loadAdminData();
|
||||||
|
else alert(result.error ?? 'Failed to delete project.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewProjectAdmin = async (id) => {
|
||||||
|
const result = await adminGetProject(id);
|
||||||
|
if (result.ok) setAdminViewProject(result.data);
|
||||||
|
else alert(result.error ?? 'Failed to load project.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
|
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
{authStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setAdminTab('users')}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||||
|
Users ({adminUsers.length})
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setAdminTab('projects')}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||||
|
Projects ({adminProjects.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||||
|
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
|
||||||
|
|
||||||
|
{!adminLoading && !adminError && adminTab === 'users' && (
|
||||||
|
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Email</th>
|
||||||
|
<th className="px-4 py-3">Joined</th>
|
||||||
|
<th className="px-4 py-3">2FA</th>
|
||||||
|
<th className="px-4 py-3">Projects</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adminUsers.map((u) => (
|
||||||
|
<tr key={u.id} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-800">
|
||||||
|
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{u.id !== authUser.id && (
|
||||||
|
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
||||||
|
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!adminLoading && !adminError && adminTab === 'projects' && (
|
||||||
|
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Title</th>
|
||||||
|
<th className="px-4 py-3">Owner</th>
|
||||||
|
<th className="px-4 py-3">Passage</th>
|
||||||
|
<th className="px-4 py-3">Last edited</th>
|
||||||
|
<th className="px-4 py-3">Shared</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adminProjects.map((p) => (
|
||||||
|
<tr key={p.id} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">
|
||||||
|
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
|
||||||
|
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
|
||||||
|
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{adminViewProject && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
|
||||||
|
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
|
||||||
|
<button type="button" onClick={() => setAdminViewProject(null)}
|
||||||
|
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
title="Project preview"
|
||||||
|
srcDoc={buildExportHtml(adminViewProject)}
|
||||||
|
sandbox="allow-popups"
|
||||||
|
className="h-[calc(100%-3rem)] w-full border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SETTINGS PAGE
|
// SETTINGS PAGE
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -158,3 +158,27 @@ export async function confirmMfaSetup(token) {
|
|||||||
export async function disableMfa(password) {
|
export async function disableMfa(password) {
|
||||||
return request('POST', '/auth/mfa/disable', { password });
|
return request('POST', '/auth/mfa/disable', { password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin (restricted server-side to the designated admin account)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function adminListUsers() {
|
||||||
|
return request('GET', '/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteUser(id) {
|
||||||
|
return request('DELETE', `/admin/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminListProjects() {
|
||||||
|
return request('GET', '/admin/projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminGetProject(id) {
|
||||||
|
return request('GET', `/admin/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteProject(id) {
|
||||||
|
return request('DELETE', `/admin/projects/${id}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user