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;