Public Access
feat(admin): accept, deny and pending count
This commit is contained in:
1 parent
b8f70e7578
commit
f4e69e1a94
10 files changed
+230
-38
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react';
|
||||
@@ -22,7 +23,7 @@ import PageContainer from './PageContainer.jsx';
|
||||
import SteamAvatar from './SteamAvatar.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {ROLES, viewAs} from '../utils/admin.js';
|
||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -49,6 +50,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
||||
const seesAdminPage = isAdmin && !viewingAs;
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [menuOpenings, setMenuOpenings] = useState(0);
|
||||
|
||||
// Loaded again on every page change and menu opening, so a decision on the admin page shows soon.
|
||||
useEffect(() => {
|
||||
if (!seesAdminPage) return undefined;
|
||||
let current = true;
|
||||
fetchPendingCount()
|
||||
.then(async (response) => {
|
||||
if (current && response.ok) setPendingCount((await response.json()).count);
|
||||
})
|
||||
.catch((err) => console.error('Pending count error:', err));
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [seesAdminPage, pathname, menuOpenings]);
|
||||
const shownCount = seesAdminPage ? pendingCount : 0;
|
||||
|
||||
const switchRole = async (role) => {
|
||||
try {
|
||||
@@ -108,20 +127,30 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}
|
||||
className="relative">
|
||||
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
||||
{shownCount > 0 && (
|
||||
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
|
||||
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{shownCount > 9 ? '9+' : shownCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !viewingAs && (
|
||||
{seesAdminPage && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
||||
<Link to="/admin/users">
|
||||
<Shield/>{t('NAV.ADMIN')}
|
||||
{shownCount > 0 && <Badge className="ml-auto">{shownCount}</Badge>}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
||||
|
||||
@@ -46,7 +46,8 @@ describe('AppHeader', () => {
|
||||
});
|
||||
|
||||
it('offers the admin page and "view as" to an admin', async () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'});
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
||||
await screen.findByRole('button', {name: 'NAV.MENU'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
|
||||
@@ -55,6 +56,24 @@ describe('AppHeader', () => {
|
||||
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
|
||||
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
|
||||
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the admin how many registrations wait', async () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||
{'GET /api/admin/users/pending-count': json(200, {count: 3})});
|
||||
|
||||
expect(await screen.findByLabelText('NAV.PENDING_REQUESTS:3')).toBeTruthy();
|
||||
openMenu();
|
||||
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('shows no count when Auth does not know the call yet', async () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||
{'GET /api/admin/users/pending-count': json(404, {})});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('pending-count'), expect.anything()));
|
||||
|
||||
expect(screen.queryByLabelText(/NAV.PENDING_REQUESTS/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the badge and hides the admin page while an admin views as a user', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {decideUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
@@ -27,21 +27,25 @@ 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',
|
||||
};
|
||||
|
||||
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
||||
|
||||
/**
|
||||
* PLATFORM_ADMIN only: list, block and delete users. Auth checks the role; a 403 shows "no access".
|
||||
* PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait. Auth checks the role; a
|
||||
* 403 shows "no access".
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [status, setStatus] = useState('loading');
|
||||
// {kind: 'block' | 'delete', user}
|
||||
// {kind: 'block' | 'delete' | 'accept' | 'deny', user}
|
||||
const [pending, setPending] = useState(null);
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -51,7 +55,7 @@ export default function AdminUsersPage() {
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||
const response = await fetchUsers(search, pendingOnly ? 'PENDING' : '', page, PAGE_SIZE, {signal});
|
||||
if (signal.aborted) return;
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
@@ -71,7 +75,7 @@ export default function AdminUsersPage() {
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [nextSignal, search, page]);
|
||||
}, [nextSignal, search, pendingOnly, page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -82,14 +86,35 @@ export default function AdminUsersPage() {
|
||||
setTypedEmail('');
|
||||
};
|
||||
|
||||
const email = pending?.user.email;
|
||||
const dialog = pending && {
|
||||
delete: {
|
||||
title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE',
|
||||
done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id),
|
||||
},
|
||||
block: {
|
||||
...(pending.user.blocked
|
||||
? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'}
|
||||
: {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}),
|
||||
run: () => setUserBlocked(pending.user.id, !pending.user.blocked),
|
||||
},
|
||||
accept: {
|
||||
title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT',
|
||||
done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true),
|
||||
},
|
||||
deny: {
|
||||
title: t('ADMIN.DENY_TITLE', {email}), text: t('ADMIN.DENY_TEXT'), action: 'ADMIN.DENY',
|
||||
done: 'ADMIN.DONE_DENY', destructive: true, run: () => decideUser(pending.user.id, false),
|
||||
},
|
||||
}[pending.kind];
|
||||
|
||||
const confirm = async () => {
|
||||
const {kind, user} = pending;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||
const response = await dialog.run();
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||
toast.success(t(dialog.done));
|
||||
closeDialog();
|
||||
await load();
|
||||
} else {
|
||||
@@ -127,6 +152,13 @@ export default function AdminUsersPage() {
|
||||
<Input aria-label={t('ADMIN.SEARCH')} placeholder={t('ADMIN.SEARCH')} value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}/>
|
||||
<Button type="submit" variant="outline" size="icon" aria-label={t('ADMIN.SEARCH')}><Search/></Button>
|
||||
<Button type="button" variant={pendingOnly ? 'default' : 'outline'} aria-pressed={pendingOnly}
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
setPendingOnly(!pendingOnly);
|
||||
}}>
|
||||
{t('ADMIN.PENDING_ONLY')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
@@ -149,10 +181,27 @@ export default function AdminUsersPage() {
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{user.role !== 'USER' && <Badge variant="outline">{t(`ROLES.${user.role}`)}</Badge>}
|
||||
{user.steamLinked && <Badge variant="secondary">{t('ADMIN.STEAM')}</Badge>}
|
||||
{user.status === 'PENDING' && <Badge>{t('ADMIN.PENDING')}</Badge>}
|
||||
{user.blocked && <Badge variant="destructive">{t('ADMIN.BLOCKED')}</Badge>}
|
||||
{user.deletedAt && <Badge variant="destructive">{t('ADMIN.DELETED')}</Badge>}
|
||||
{user.deletedAt && (
|
||||
<Badge variant="destructive">
|
||||
{t(user.status === 'DENIED' ? 'ADMIN.DENIED' : 'ADMIN.DELETED')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && (
|
||||
{user.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'accept', user})}>
|
||||
<Check/>{t('ADMIN.ACCEPT')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="border-destructive text-destructive"
|
||||
onClick={() => setPending({kind: 'deny', user})}>
|
||||
<X/>{t('ADMIN.DENY')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => setPending({kind: 'block', user})}>
|
||||
@@ -189,14 +238,8 @@ export default function AdminUsersPage() {
|
||||
{pending && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{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})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pending.kind === 'delete' ? t('ADMIN.DELETE_TEXT') : pending.user.blocked ? pending.user.email : t('ADMIN.BLOCK_TEXT')}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{dialog.title}</DialogTitle>
|
||||
<DialogDescription>{dialog.text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pending.kind === 'delete' && (
|
||||
<div className="space-y-2">
|
||||
@@ -209,10 +252,10 @@ export default function AdminUsersPage() {
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant={pending.kind === 'delete' ? 'destructive' : 'default'}
|
||||
<Button type="button" variant={dialog.destructive ? 'destructive' : 'default'}
|
||||
disabled={confirmDisabled} onClick={confirm}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t(pending.kind === 'delete' ? 'ADMIN.DELETE' : pending.user.blocked ? 'ADMIN.UNBLOCK' : 'ADMIN.BLOCK')}
|
||||
{t(dialog.action)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+17
-3
@@ -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`, {
|
||||
|
||||
Reference in new issue
Block a user