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')}
+
+