-
- {pending.kind === 'delete'
- ? t('ADMIN.DELETE_TITLE', {email: pending.user.email})
- : pending.user.blocked ? t('ADMIN.UNBLOCK') : t('ADMIN.BLOCK_TITLE', {email: pending.user.email})}
-
-
- {pending.kind === 'delete' ? t('ADMIN.DELETE_TEXT') : pending.user.blocked ? pending.user.email : t('ADMIN.BLOCK_TEXT')}
-
+ {dialog.title}
+ {dialog.text}
{pending.kind === 'delete' && (
@@ -209,10 +252,10 @@ export default function AdminUsersPage() {
-
diff --git a/src/pages/AdminUsersPage.test.jsx b/src/pages/AdminUsersPage.test.jsx
index 8cef8b5..21ba009 100644
--- a/src/pages/AdminUsersPage.test.jsx
+++ b/src/pages/AdminUsersPage.test.jsx
@@ -12,8 +12,11 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const PLAYER = {
id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00',
- lastLoginDate: null, steamLinked: true, blocked: false, deletedAt: null,
+ lastLoginDate: null, steamLinked: true, blocked: false, deletedAt: null, status: 'ACTIVE',
};
+const NEWCOMER = {...PLAYER, id: 8, email: 'new@example.com', steamLinked: false, status: 'PENDING'};
+const DENIED = {...PLAYER, id: 9, email: 'denied@example.com', steamLinked: false, status: 'DENIED',
+ deletedAt: '2026-10-01T10:00:00Z'};
const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false};
const page = (content, totalPages = 1) => ({content, page: 0, size: 20, totalElements: content.length, totalPages});
@@ -96,6 +99,46 @@ describe('AdminUsersPage', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_TARGET_IS_ADMIN'));
});
+ it('accepts or denies a registration that waits, and offers no block on it', async () => {
+ withRoutes({
+ 'GET /api/admin/users': json(200, page([NEWCOMER])),
+ 'PUT /api/admin/users/8/decision': json(204),
+ });
+ renderPage();
+ expect(await screen.findByText('ADMIN.PENDING')).toBeTruthy();
+ expect(screen.queryByRole('button', {name: 'ADMIN.BLOCK'})).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ACCEPT'}));
+ fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.ACCEPT'}).at(-1));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ACCEPT'));
+ expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({accepted: true});
+
+ fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.DENY'}));
+ fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.DENY'}).at(-1));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_DENY'));
+ expect(JSON.parse(calls('PUT')[1][1].body)).toEqual({accepted: false});
+ });
+
+ it('shows a denied registration as denied, with no action', async () => {
+ withRoutes({'GET /api/admin/users': json(200, page([DENIED]))});
+ renderPage();
+
+ expect(await screen.findByText('ADMIN.DENIED')).toBeTruthy();
+ expect(screen.queryByText('ADMIN.DELETED')).toBeNull();
+ expect(screen.queryByRole('button', {name: 'ADMIN.DELETE'})).toBeNull();
+ });
+
+ it('lists only the waiting registrations on request, from the first page', async () => {
+ renderPage();
+ await screen.findByText('player@example.com');
+
+ fireEvent.click(screen.getByRole('button', {name: 'ADMIN.PENDING_ONLY'}));
+
+ await waitFor(() => expect(calls('GET').at(-1)[0]).toContain('status=PENDING&page=0'));
+ });
+
it('says no access to a user without the admin role', async () => {
withRoutes({'GET /api/admin/users': json(403, {})});
renderPage();
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
index e8c0162..b648aa7 100644
--- a/src/pages/LoginPage.jsx
+++ b/src/pages/LoginPage.jsx
@@ -15,6 +15,7 @@ const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
TOO_MANY_ATTEMPTS: 'LOGIN.ERROR_TOO_MANY_ATTEMPTS',
ACCOUNT_BLOCKED: 'LOGIN.ERROR_ACCOUNT_BLOCKED',
+ ACCOUNT_PENDING: 'LOGIN.ERROR_ACCOUNT_PENDING',
};
export default function LoginPage() {
diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx
index 915cd82..12896fa 100644
--- a/src/pages/LoginPage.test.jsx
+++ b/src/pages/LoginPage.test.jsx
@@ -85,6 +85,15 @@ describe('LoginPage', () => {
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
});
+ it('says that an account waits for the admin', async () => {
+ fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_PENDING')});
+ renderLogin();
+
+ submit();
+
+ expect(await screen.findByText('LOGIN.ERROR_ACCOUNT_PENDING')).toBeTruthy();
+ });
+
it('says that a blocked account is blocked', async () => {
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_BLOCKED')});
renderLogin();
diff --git a/src/utils/admin.js b/src/utils/admin.js
index 3120184..053125c 100644
--- a/src/utils/admin.js
+++ b/src/utils/admin.js
@@ -6,12 +6,26 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
/**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
+ *
+ * @param status PENDING, ACTIVE or DENIED; empty for all
*/
-export const fetchUsers = (query, page, size, init) =>
- apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`, init);
+export const fetchUsers = (query, status, page, size, init) =>
+ apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, ...(status && {status}), page, size})}`, init);
+
+export const fetchPendingCount = () => apiFetch(`${AUTH()}/api/admin/users/pending-count`);
/**
- * 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
+ * Accept or deny a registration that waits: 204, or 409 NOT_PENDING when it is decided already.
+ */
+export const decideUser = (userId, accepted) =>
+ apiFetch(`${AUTH()}/api/admin/users/${userId}/decision`, {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({accepted}),
+ });
+
+/**
+ * 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED or TARGET_IS_PENDING.
*/
export const setUserBlocked = (userId, blocked) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/blocked`, {