diff --git a/README.md b/README.md index 48b4d6d..cc613a9 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ To Be Played is split into three repositories: a repaired cover comes back as `imageUrl`. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is - typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge + typed to confirm a delete), and set the role of another user in the "Roles" dialog, which also lists the role + changes. "View as" in the menu switches the own session to User or Auditor and back; a badge shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet. diff --git a/public/i18n/de.json b/public/i18n/de.json index a4da644..8f06761 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -166,7 +166,8 @@ "DONE": "Dein Konto ist gelöscht.", "WRONG_PASSWORD": "Das Passwort ist falsch.", "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", - "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." + "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen.", + "LAST_ADMIN": "Du bist der letzte Admin. Mache zuerst einen anderen Nutzer zum Admin." }, "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "FAVORITE_PLATFORM": "Lieblingsplattform", @@ -647,7 +648,18 @@ "PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.", "DONE_PASSWORD_RESET": "Reset-Mail gesendet.", "ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.", - "ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab." + "ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab.", + "ROLES": "Rollen", + "ROLES_TITLE": "Rollen von {{email}}", + "ROLES_TEXT": "Die Änderung wirkt nach etwa 30 Sekunden. Der Nutzer muss die Seite eventuell neu laden.", + "SAVE_ROLE": "Speichern", + "DONE_ROLE": "Rolle gespeichert.", + "ROLE_HISTORY": "Änderungen", + "ROLE_HISTORY_EMPTY": "Noch keine Rollenänderungen.", + "ROLE_HISTORY_ERROR": "Die Änderungen konnten nicht geladen werden.", + "ERROR_ROLE_CHANGED": "Die Rolle hat sich inzwischen geändert. Die Liste wird neu geladen.", + "ERROR_ACTOR_NOT_ADMIN": "Du bist kein Admin mehr.", + "ERROR_TARGET_IS_BLOCKED": "Entsperre den Nutzer zuerst." }, "CORRECTION": { "OPEN": "Daten korrigieren", diff --git a/public/i18n/en.json b/public/i18n/en.json index 9ec5bae..4e932dd 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -166,7 +166,8 @@ "DONE": "Your account is deleted.", "WRONG_PASSWORD": "The password is wrong.", "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", - "ERROR": "The account could not be deleted. Please try again later." + "ERROR": "The account could not be deleted. Please try again later.", + "LAST_ADMIN": "You are the last admin. Make another user admin first." }, "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "FAVORITE_PLATFORM": "Favorite platform", @@ -647,7 +648,18 @@ "PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.", "DONE_PASSWORD_RESET": "Reset mail sent.", "ERROR_NOT_PENDING": "This registration is decided already.", - "ERROR_TARGET_IS_PENDING": "Accept or deny the registration first." + "ERROR_TARGET_IS_PENDING": "Accept or deny the registration first.", + "ROLES": "Roles", + "ROLES_TITLE": "Roles of {{email}}", + "ROLES_TEXT": "The change works within about 30 seconds. The user may need to reload the page.", + "SAVE_ROLE": "Save", + "DONE_ROLE": "Role saved.", + "ROLE_HISTORY": "Changes", + "ROLE_HISTORY_EMPTY": "No role changes yet.", + "ROLE_HISTORY_ERROR": "The changes could not be loaded.", + "ERROR_ROLE_CHANGED": "The role changed in the meantime. The list is loaded again.", + "ERROR_ACTOR_NOT_ADMIN": "You are no admin any more.", + "ERROR_TARGET_IS_BLOCKED": "Unblock the user first." }, "CORRECTION": { "OPEN": "Correct data", diff --git a/src/components/DeleteAccountCard.jsx b/src/components/DeleteAccountCard.jsx index c51ac65..443855a 100644 --- a/src/components/DeleteAccountCard.jsx +++ b/src/components/DeleteAccountCard.jsx @@ -48,6 +48,8 @@ export default function DeleteAccountCard({email}) { } if (response.status === 403) { toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD')); + } else if (response.status === 409) { + toast.error(t('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN')); } else if (response.status === 429) { const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes})); diff --git a/src/components/DeleteAccountCard.test.jsx b/src/components/DeleteAccountCard.test.jsx index 154c521..1b6b291 100644 --- a/src/components/DeleteAccountCard.test.jsx +++ b/src/components/DeleteAccountCard.test.jsx @@ -78,6 +78,18 @@ describe('DeleteAccountCard', () => { expect(localStorage.getItem('tbp_session')).toBe('1'); }); + it('tells the last admin to make another admin first and stays on the page', async () => { + deleteAnswers(json(409, {code: 'LAST_ADMIN'})); + renderCard(); + openAndFill(EMAIL, 'secret'); + + fireEvent.click(confirmButton()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN')); + expect(confirmButton()).toBeTruthy(); + expect(localStorage.getItem('tbp_session')).toBe('1'); + }); + it('shows the lock message after too many wrong passwords', async () => { deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})}); renderCard(); diff --git a/src/components/UserRoleDialog.jsx b/src/components/UserRoleDialog.jsx new file mode 100644 index 0000000..bcf6b44 --- /dev/null +++ b/src/components/UserRoleDialog.jsx @@ -0,0 +1,122 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Loader2} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import {changeUserRole, fetchRoleChanges, REFUSAL_KEY, ROLES} from '../utils/admin.js'; +import {formatDate} from '../utils/format.js'; + +function RoleDialogContent({user, onClose, onChanged}) { + const {t, i18n} = useTranslation(); + const [chosen, setChosen] = useState(user.role); + const [busy, setBusy] = useState(false); + // null while loading, 'error' when it failed + const [changes, setChanges] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + fetchRoleChanges(user.id, {signal: controller.signal}) + .then(async (response) => { + if (controller.signal.aborted || response.status === 401) return; + setChanges(response.ok ? (await response.json()).results : 'error'); + }) + .catch((err) => { + if (controller.signal.aborted) return; + console.error('Role changes error:', err); + setChanges('error'); + }); + return () => controller.abort(); + }, [user.id]); + + const save = async () => { + setBusy(true); + try { + const response = await changeUserRole(user.id, chosen, user.role); + if (response.status === 401) return; + if (response.ok) { + toast.success(t('ADMIN.DONE_ROLE')); + onChanged(); + onClose(); + return; + } + const data = await response.json().catch(() => ({})); + toast.error(t(REFUSAL_KEY[data.code] ?? 'ADMIN.ERROR')); + if (data.code === 'ROLE_CHANGED') { + // The dialog holds the old user; reopen it from the reloaded list. + onChanged(); + onClose(); + } + } catch (err) { + console.error('Role change error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return ( + + + {t('ADMIN.ROLES_TITLE', {email: user.email})} + {t('ADMIN.ROLES_TEXT')} + +
+ {ROLES.map((role) => ( + + ))} +
+
+

{t('ADMIN.ROLE_HISTORY')}

+ {changes === null && } + {changes === 'error' &&

{t('ADMIN.ROLE_HISTORY_ERROR')}

} + {Array.isArray(changes) && changes.length === 0 && ( +

{t('ADMIN.ROLE_HISTORY_EMPTY')}

+ )} + {Array.isArray(changes) && changes.length > 0 && ( +
    + {changes.map((change, index) => ( +
  • + {formatDate(change.changedAt, i18n.language)} + {change.actorEmail ?? `#${change.actorUserId}`} + {`${t(`ROLES.${change.oldRole}`)} → ${t(`ROLES.${change.newRole}`)}`} +
  • + ))} +
+ )} +
+ + + + + + +
+ ); +} + +/** + * Sets the role of another user and shows its role changes. Open while `user` is set. + */ +export default function UserRoleDialog({user, onClose, onChanged}) { + return ( + { if (!open) onClose(); }}> + {user && } + + ); +} diff --git a/src/components/UserRoleDialog.test.jsx b/src/components/UserRoleDialog.test.jsx new file mode 100644 index 0000000..8cc7504 --- /dev/null +++ b/src/components/UserRoleDialog.test.jsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {toast} from 'sonner'; +import UserRoleDialog from './UserRoleDialog.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const USER = {id: 7, email: 'player@example.com', role: 'USER'}; +const LOG = {results: [{actorUserId: 1, actorEmail: 'admin@example.com', oldRole: 'USER', newRole: 'AUDITOR', + changedAt: '2026-10-01T10:00:00Z'}]}; + +const withRoutes = (routes) => fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, LOG), ...routes}); +const renderDialog = () => { + const onClose = vi.fn(); + const onChanged = vi.fn(); + render(); + return {onClose, onChanged}; +}; +const save = () => screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}); +const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT'); + +describe('UserRoleDialog', () => { + beforeEach(() => { + localStorage.setItem('tbp_session', '1'); + withRoutes({'PUT /api/admin/users/7/role': json(204)}); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('shows the change log of the user', async () => { + renderDialog(); + + expect(await screen.findByText('admin@example.com')).toBeTruthy(); + expect(screen.getByText('ROLES.USER → ROLES.AUDITOR')).toBeTruthy(); + }); + + it('says so when the log is empty or cannot be loaded', async () => { + withRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})}); + renderDialog(); + expect(await screen.findByText('ADMIN.ROLE_HISTORY_EMPTY')).toBeTruthy(); + cleanup(); + + withRoutes({'GET /api/admin/users/7/role-changes': json(500, {})}); + renderDialog(); + expect(await screen.findByText('ADMIN.ROLE_HISTORY_ERROR')).toBeTruthy(); + }); + + it('starts at the current role and cannot save an unchanged role', () => { + renderDialog(); + + expect(screen.getByRole('button', {name: 'ROLES.USER'}).getAttribute('aria-pressed')).toBe('true'); + expect(save().disabled).toBe(true); + }); + + it('saves the chosen role with the role it saw, then reloads and closes', async () => { + const {onChanged, onClose} = renderDialog(); + + fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'})); + fireEvent.click(save()); + + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + expect(JSON.parse(puts()[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'}); + expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE'); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows why Auth refuses', async () => { + withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ACTOR_NOT_ADMIN'})}); + const {onChanged} = renderDialog(); + + fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'})); + fireEvent.click(save()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ACTOR_NOT_ADMIN')); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('loads the list again when the role changed in the meantime', async () => { + withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ROLE_CHANGED'})}); + const {onChanged} = renderDialog(); + + fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'})); + fireEvent.click(save()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED')); + expect(onChanged).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index 4c6c454..3bf25b1 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -1,6 +1,6 @@ import {useCallback, useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; -import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react'; +import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, UserCog, X} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -18,21 +18,13 @@ import { import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import PageContainer from '../components/PageContainer.jsx'; -import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js'; +import UserRoleDialog from '../components/UserRoleDialog.jsx'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, REFUSAL_KEY, sendPasswordReset, setUserBlocked} from '../utils/admin.js'; import {formatDate} from '../utils/format.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js'; const PAGE_SIZE = 20; -const REFUSAL_KEY = { - SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION', - TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN', - ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED', - NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING', - TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING', - TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED', - NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED', - NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE', -}; const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null); @@ -54,6 +46,8 @@ export default function AdminUsersPage() { const [busy, setBusy] = useState(false); // Only the newest load may show its answer: a slow answer to an older search must not replace the list. const nextSignal = useAbortPrevious(); + const ownId = useCurrentUserContext().user?.id; + const [roleUser, setRoleUser] = useState(null); const load = useCallback(async () => { const signal = nextSignal(); @@ -223,6 +217,11 @@ export default function AdminUsersPage() { )} + {user.status === 'ACTIVE' && !user.blocked && !user.deletedAt && user.id !== ownId && ( + + )} {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
{user.status === 'ACTIVE' && !user.blocked && ( @@ -289,6 +288,7 @@ export default function AdminUsersPage() { )} + setRoleUser(null)} onChanged={load}/> ); } diff --git a/src/pages/AdminUsersPage.test.jsx b/src/pages/AdminUsersPage.test.jsx index 388aa85..7e55034 100644 --- a/src/pages/AdminUsersPage.test.jsx +++ b/src/pages/AdminUsersPage.test.jsx @@ -9,6 +9,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); +vi.mock('../context/CurrentUserContext.jsx', () => ({useCurrentUserContext: () => ({user: {id: 1}})})); const PLAYER = { id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00', @@ -187,6 +188,67 @@ describe('AdminUsersPage', () => { expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/); }); + it('offers the roles of an active user and of another admin, not of the own, blocked, deleted or waiting rows', async () => { + withRoutes({ + 'GET /api/admin/users': json(200, page([ADMIN, {...ADMIN, id: 2, email: 'admin2@example.com'}, PLAYER, + {...PLAYER, id: 11, email: 'blocked@example.com', blocked: true}, DENIED, NEWCOMER, UNCONFIRMED])), + }); + renderPage(); + await screen.findByText('player@example.com'); + + expect(screen.getAllByRole('button', {name: 'ADMIN.ROLES'})).toHaveLength(2); + }); + + it('saves a new role from the dialog and loads the list again', async () => { + withRoutes({ + 'GET /api/admin/users/7/role-changes': json(200, {results: []}), + 'PUT /api/admin/users/7/role': json(204), + }); + renderPage(); + await screen.findByText('player@example.com'); + + fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'})); + fireEvent.click(await screen.findByRole('button', {name: 'ROLES.AUDITOR'})); + fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'})); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE')); + expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'}); + await waitFor(() => expect(calls('GET').filter(([url]) => url.endsWith('/api/admin/users') || url.includes('/api/admin/users?'))).toHaveLength(2)); + }); + + it('afterRoleChangedTheNextSaveSendsTheReloadedRole', async () => { + let lists = 0; + let puts = 0; + withRoutes({ + 'GET /api/admin/users': () => { + lists += 1; + return json(200, page([ADMIN, lists === 1 ? PLAYER : {...PLAYER, role: 'AUDITOR'}])); + }, + 'GET /api/admin/users/7/role-changes': json(200, {results: []}), + 'PUT /api/admin/users/7/role': () => { + puts += 1; + return puts === 1 ? json(409, {code: 'ROLE_CHANGED'}) : json(204); + }, + }); + renderPage(); + await screen.findByText('player@example.com'); + + fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'})); + fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'})); + fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'})); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED')); + await waitFor(() => expect(lists).toBe(2)); + + if (!screen.queryByRole('button', {name: 'ADMIN.SAVE_ROLE'})) { + fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'})); + } + fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'})); + fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'})); + + await waitFor(() => expect(calls('PUT')).toHaveLength(2)); + expect(JSON.parse(calls('PUT')[1][1].body)).toEqual({role: 'PLATFORM_ADMIN', expectedRole: 'AUDITOR'}); + }); + it('says no access to a user without the admin role', async () => { withRoutes({'GET /api/admin/users': json(403, {})}); renderPage(); diff --git a/src/utils/admin.js b/src/utils/admin.js index 1ad1ccb..2fa23be 100644 --- a/src/utils/admin.js +++ b/src/utils/admin.js @@ -4,6 +4,20 @@ const AUTH = () => import.meta.env.VITE_API_URL; export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN']; +export const REFUSAL_KEY = { + SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION', + TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN', + ACTOR_NOT_ADMIN: 'ADMIN.ERROR_ACTOR_NOT_ADMIN', + ROLE_CHANGED: 'ADMIN.ERROR_ROLE_CHANGED', + TARGET_IS_BLOCKED: 'ADMIN.ERROR_TARGET_IS_BLOCKED', + ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED', + NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING', + TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING', + TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED', + NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED', + NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE', +}; + /** * GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise). * @@ -34,6 +48,23 @@ export const setUserBlocked = (userId, blocked) => body: JSON.stringify({blocked}), }); +/** + * Sets the role of another user. `expectedRole` is the role the admin saw. 204, or 409 with code SELF_ACTION, + * ACTOR_NOT_ADMIN, ROLE_CHANGED, TARGET_IS_BLOCKED, ALREADY_DELETED, TARGET_IS_PENDING or TARGET_IS_UNCONFIRMED. + */ +export const changeUserRole = (userId, role, expectedRole) => + apiFetch(`${AUTH()}/api/admin/users/${userId}/role`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({role, expectedRole}), + }); + +/** + * GET the role changes of a user, newest first: {results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}. + * `actorEmail` is null after the actor is purged. + */ +export const fetchRoleChanges = (userId, init) => apiFetch(`${AUTH()}/api/admin/users/${userId}/role-changes`, init); + /** * Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status. */ diff --git a/src/utils/admin.test.js b/src/utils/admin.test.js index 60a5004..9db1c14 100644 --- a/src/utils/admin.test.js +++ b/src/utils/admin.test.js @@ -1,7 +1,7 @@ // @vitest-environment jsdom import {describe, expect, it} from 'vitest'; import {refreshSession} from './api.js'; -import {viewAs} from './admin.js'; +import {changeUserRole, fetchRoleChanges, viewAs} from './admin.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; describe('viewAs', () => { @@ -25,3 +25,27 @@ describe('viewAs', () => { await switching; }); }); + +describe('role changes', () => { + it('changeUserRole puts the new and the expected role', async () => { + fetchRoutes({'PUT /api/admin/users/7/role': json(204)}); + + await changeUserRole(7, 'AUDITOR', 'USER'); + + const [url, init] = fetch.mock.calls[0]; + expect(url).toMatch(/\/api\/admin\/users\/7\/role$/); + expect(init.method).toBe('PUT'); + expect(JSON.parse(init.body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'}); + }); + + it('fetchRoleChanges gets the log of the user', async () => { + fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})}); + const signal = new AbortController().signal; + + await fetchRoleChanges(7, {signal}); + + const [url, init] = fetch.mock.calls[0]; + expect(url).toMatch(/\/api\/admin\/users\/7\/role-changes$/); + expect(init.signal).toBe(signal); + }); +});