Move admin state into AdminPage
Build and Push Docker Image / build-and-push (push) Failing after 21s
Build and Push Docker Image / build-and-push (push) Failing after 21s
All admin state (tab, users, projects, loading/error, preview, reset result) and the data loader were only used by AdminPage, so they now live there as local state instead of in App's context. The page loads its data when it opens (still gated on authUser.isAdmin; the server enforces it too). Add tests: admin sees users and projects, deleting a user reloads the list, and non-admins get no Admin button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49ffefed54
commit
8c55765ae6
3 files changed
+88
-50
No files matched your search
-37
@@ -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,
|
||||
|
||||
@@ -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(<App />);
|
||||
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(<App />);
|
||||
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(<App />);
|
||||
expect(await screen.findByText(admin.email)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+30
-13
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user