feat(admin): add the admin users page and view as
Build, Test & Push Frontend / quality-check (push) Successful in 17s
Build, Test & Push Frontend / docker (push) Successful in 28s

This commit is contained in:
lobo committed 2026-09-27 12:18:10 +02:00
1 parent 46587dfb5c
commit afe00ceae9
14 files changed
+649 -8

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import {apiFetch, rotatingFetch} from './api.js';
const AUTH = () => import.meta.env.VITE_API_URL;
export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
/**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
*/
export const fetchUsers = (query, page, size) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`);
/**
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
*/
export const setUserBlocked = (userId, blocked) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/blocked`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({blocked}),
});
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
/**
* "View as": new cookies that act with the role; null goes back to the own role. Under /api/auth, so it needs the
* refresh cookie and no access token; 403 ROLE_NOT_ALLOWED above the own role.
*/
export const viewAs = (role) =>
rotatingFetch(`${AUTH()}/api/auth/view-as`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role}),
});
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import {describe, expect, it, vi} from 'vitest';
import {refreshSession} from './api.js';
import {viewAs} from './admin.js';
describe('viewAs', () => {
it('waits for a running refresh before it sends the refresh cookie', async () => {
let finishRefresh;
globalThis.fetch = vi.fn((url) => (url.endsWith('/api/auth/refresh')
? new Promise((resolve) => {
finishRefresh = () => resolve({ok: true, status: 204});
})
: Promise.resolve({ok: true, status: 204})));
const refreshing = refreshSession();
const switching = viewAs('USER');
await Promise.resolve();
expect(fetch.mock.calls.map(([url]) => url).filter((url) => url.endsWith('/api/auth/view-as'))).toHaveLength(0);
finishRefresh();
await refreshing;
await switching;
});
});
+10
View File
@@ -44,6 +44,16 @@ export function refreshSession() {
return runningRefresh;
}
/**
* A request that rotates the refresh token like a refresh (e.g. "view as"). It waits for a running refresh and
* takes the same lock, so no two requests send one refresh token.
*/
export async function rotatingFetch(url, init = {}) {
await runningRefresh?.catch(() => undefined);
const send = () => clientFetch(url, init);
return globalThis.navigator?.locks ? navigator.locks.request('tbp-refresh', send) : send();
}
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails,
* the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees