diff --git a/src/App.jsx b/src/App.jsx
index d445a39..868f80d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -47,8 +47,6 @@ import {
enableSharing,
disableSharing,
getSharedProject,
- adminListUsers,
- adminListProjects,
} from './syncService.js';
// Less-common pages are split into their own chunks so the first load only
@@ -89,14 +87,6 @@ const App = () => {
const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [sharedProject, setSharedProject] = useState(null);
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 [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home');
@@ -301,24 +291,6 @@ const App = () => {
});
}, [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.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
@@ -2291,15 +2263,6 @@ const deleteProject = (id) => {
speakOriginalWord,
loadVerseText,
importFinalScriptDocx,
- // Admin
- adminTab, setAdminTab,
- adminUsers,
- adminProjects,
- adminLoading,
- adminError,
- adminViewProject, setAdminViewProject,
- adminResetResult, setAdminResetResult,
- loadAdminData,
// Functions defined inside App (cannot be module-level exports)
formatCrossRef,
buildGreekDefinitionHtml,
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 611ae7e..ada1852 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -437,3 +437,61 @@ describe('Authentication', () => {
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
});
});
+
+// ---------------------------------------------------------------------------
+// Admin page (state is local to AdminPage)
+// ---------------------------------------------------------------------------
+describe('Admin page', () => {
+ const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true };
+ const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
+
+ function stubAdminApi(sessionUser) {
+ let users = [
+ { id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 },
+ { id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 },
+ ];
+ const fallback = buildFetchMock();
+ const fetchMock = vi.fn((url, opts = {}) => {
+ if (!url.startsWith('/api/')) return fallback(url, opts);
+ const route = `${opts.method ?? 'GET'} ${url}`;
+ if (route === 'GET /api/auth/me') return json(200, sessionUser);
+ if (route === 'GET /api/admin/users') return json(200, users);
+ if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]);
+ if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); return json(200, { ok: true }); }
+ if (route === 'GET /api/projects') return json(200, []);
+ if (route === 'GET /api/reader/ink') return json(200, {});
+ return json(404, { error: 'not mocked' });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+ }
+
+ test('an admin can open the page and see users and projects', async () => {
+ stubAdminApi(admin);
+ const u = userEvent.setup();
+ render();
+ await u.click(await screen.findByRole('button', { name: /admin/i }));
+ expect(await screen.findByText('student@example.com')).toBeInTheDocument();
+ await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
+ expect(await screen.findByText('Romans Study')).toBeInTheDocument();
+ });
+
+ test('deleting a user removes them after the list reloads', async () => {
+ const fetchMock = stubAdminApi(admin);
+ window.confirm.mockReturnValue(true);
+ const u = userEvent.setup();
+ render();
+ await u.click(await screen.findByRole('button', { name: /admin/i }));
+ const row = (await screen.findByText('student@example.com')).closest('tr');
+ await u.click(within(row).getByRole('button', { name: /delete/i }));
+ await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
+ expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
+ });
+
+ test('non-admins get no Admin button', async () => {
+ stubAdminApi({ ...admin, isAdmin: false });
+ render();
+ expect(await screen.findByText(admin.email)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index b86ff14..2dde6a8 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -1,6 +1,9 @@
+import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
+ adminListUsers,
+ adminListProjects,
adminDeleteUser,
adminDeleteProject,
adminGetProject,
@@ -8,19 +11,33 @@ import {
} from '../syncService.js';
export default function AdminPage() {
- const {
- authUser,
- authStatus,
- goHome,
- adminTab, setAdminTab,
- adminUsers,
- adminProjects,
- adminLoading,
- adminError,
- adminViewProject, setAdminViewProject,
- adminResetResult, setAdminResetResult,
- loadAdminData,
- } = useApp();
+ const { authUser, authStatus, 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;