Build and Push Docker Image / build-and-push (push) Failing after 20s
App bar: brand, Studies / Read Bible / Reading Plans, and one account menu (theme, Settings, Admin, Log out). Study stage: notes get their own box above the resources in the right column. Settings, Admin and the login page use the reader themes; add a theme-aware danger colour. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
217 lines
11 KiB
React
217 lines
11 KiB
React
import { useEffect, useState } from 'react';
|
|
import { useApp } from '../context/AppContext.js';
|
|
import PageShell from '../components/study/PageShell.jsx';
|
|
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
|
import {
|
|
adminListUsers,
|
|
adminListProjects,
|
|
adminDeleteUser,
|
|
adminDeleteProject,
|
|
adminGetProject,
|
|
adminResetPassword,
|
|
} from '../syncService.js';
|
|
|
|
export default function AdminPage() {
|
|
const { authUser, goHome } = useApp();
|
|
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 [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once
|
|
|
|
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 (authUser?.isAdmin) loadAdminData();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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.');
|
|
};
|
|
|
|
const handleResetPasswordAdmin = async (id, email) => {
|
|
if (!window.confirm(`Reset the password for "${email}"? They'll be signed out everywhere and need the new temporary password to log back in.`)) return;
|
|
const result = await adminResetPassword(id);
|
|
if (result.ok) setAdminResetResult({ email, temporaryPassword: result.data.temporaryPassword });
|
|
else alert(result.error ?? 'Failed to reset password.');
|
|
};
|
|
|
|
return (
|
|
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
|
|
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => setAdminTab('users')}
|
|
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'users' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
|
Users ({adminUsers.length})
|
|
</button>
|
|
<button type="button" onClick={() => setAdminTab('projects')}
|
|
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
|
Projects ({adminProjects.length})
|
|
</button>
|
|
</div>
|
|
|
|
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
|
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
|
|
|
|
{!adminLoading && !adminError && adminTab === 'users' && (
|
|
<div className="overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)]">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[560px] text-sm">
|
|
<thead className="bg-[var(--r-bg)] text-left text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
|
<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-[var(--r-line)]">
|
|
<td className="px-4 py-3 font-medium text-[var(--r-text)]">
|
|
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-[var(--r-sub)]">(you)</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">{new Date(u.createdAt).toLocaleDateString()}</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">{u.totpEnabled ? '✓' : '—'}</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">{u.projectCount}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{u.id !== authUser.id && (
|
|
<div className="flex justify-end gap-2">
|
|
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
|
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
|
|
Reset Password
|
|
</button>
|
|
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!adminLoading && !adminError && adminTab === 'projects' && (
|
|
<div className="overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)]">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[640px] text-sm">
|
|
<thead className="bg-[var(--r-bg)] text-left text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
|
<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-[var(--r-line)]">
|
|
<td className="px-4 py-3 font-medium text-[var(--r-text)]">{p.title}</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">
|
|
{p.ownerEmail ?? <span className="italic text-[var(--r-sub)]">orphaned</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">{p.chapterSummary}</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
|
<td className="px-4 py-3 text-[var(--r-sub)]">{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-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
|
|
View
|
|
</button>
|
|
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
|
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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-[var(--r-bar)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between border-b border-[var(--r-line)] px-4 py-3">
|
|
<p className="text-sm font-medium text-[var(--r-text)]">{adminViewProject.title}</p>
|
|
<button type="button" onClick={() => setAdminViewProject(null)}
|
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
|
|
Close
|
|
</button>
|
|
</div>
|
|
<iframe
|
|
title="Project preview"
|
|
srcDoc={buildExportHtml(adminViewProject)}
|
|
sandbox="allow-popups"
|
|
className="h-[calc(100%-3rem)] w-full border-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{adminResetResult && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="w-full max-w-sm rounded-2xl bg-[var(--r-bar)] p-6 shadow-2xl">
|
|
<h3 className="text-sm font-medium text-[var(--r-text)]">Password reset</h3>
|
|
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
|
Relay this to <span className="font-medium text-[var(--r-text)]">{adminResetResult.email}</span> yourself
|
|
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
|
|
</p>
|
|
<p className="mt-4 rounded-xl border border-[var(--r-accent)] bg-[var(--r-bg)] px-3 py-2 text-center font-mono text-lg tracking-wider text-[var(--r-accent)]">
|
|
{adminResetResult.temporaryPassword}
|
|
</p>
|
|
<button type="button" onClick={() => setAdminResetResult(null)}
|
|
className="mt-4 w-full rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90">
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|