feat(admin): reset a user's recommendations from the users panel
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / db / db (push) Successful in 2m35s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / db / db (push) Successful in 2m35s
ci / ts / ts (packages + web) (push) Successful in 1m23s
- POST /admin/users/{id}/reset-recommendations clears the taste profile
(onboarding, CF vector, bandit), play stats and wave impressions; likes,
dislikes and history stay, wave sessions stay so a playing wave keeps going
- moderators may reset plain users, admins anyone including themselves;
the reset is written to the audit log
- users panel gets the menu action with a confirmation; docs updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b27b915b63
commit
978f03b8f8
20 files changed
+551
-274
No files matched your search
@@ -8,7 +8,7 @@ import { useSession, useUser } from '../../app/session';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatRelative } from '../../shared/lib/format';
|
||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
@@ -16,7 +16,9 @@ type AdminUser = components['schemas']['AdminUser'];
|
||||
|
||||
const ROLES: Role[] = ['admin', 'moderator', 'user'];
|
||||
|
||||
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
|
||||
const TITLES = { reset: 'reset_password', recs: 'reset_recommendations', block: 'block', delete: 'delete' } as const;
|
||||
|
||||
type Confirm = { kind: 'reset' | 'recs' | 'block' | 'delete'; user: AdminUser };
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
@@ -47,6 +49,7 @@ export function UsersPage() {
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
const [recsDone, setRecsDone] = useState<string | null>(null);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['admin-users', query, role],
|
||||
@@ -79,6 +82,14 @@ export function UsersPage() {
|
||||
},
|
||||
onError: setActionError,
|
||||
});
|
||||
const resetRecs = useMutation({
|
||||
mutationFn: async (u: AdminUser) =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: u.id } } }),
|
||||
),
|
||||
onSuccess: (_, u) => setRecsDone(u.username),
|
||||
onError: setActionError,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (u: AdminUser) =>
|
||||
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
|
||||
@@ -91,7 +102,9 @@ export function UsersPage() {
|
||||
const { kind, user } = confirm;
|
||||
setConfirm(null);
|
||||
setActionError(null);
|
||||
setRecsDone(null);
|
||||
if (kind === 'reset') reset.mutate(user);
|
||||
if (kind === 'recs') resetRecs.mutate(user);
|
||||
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
|
||||
if (kind === 'delete') remove.mutate(user);
|
||||
};
|
||||
@@ -101,6 +114,12 @@ export function UsersPage() {
|
||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||
if (can('reset_password'))
|
||||
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||
if (can('reset_recommendations'))
|
||||
items.push({
|
||||
label: t('users.reset_recommendations'),
|
||||
icon: refreshIcon,
|
||||
onSelect: () => setConfirm({ kind: 'recs', user: u }),
|
||||
});
|
||||
if (can('block'))
|
||||
items.push(
|
||||
u.is_blocked
|
||||
@@ -154,6 +173,7 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
|
||||
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
|
||||
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
@@ -226,14 +246,14 @@ export function UsersPage() {
|
||||
|
||||
{confirm && (
|
||||
<Dialog
|
||||
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
|
||||
title={t(`users.${TITLES[confirm.kind]}`)}
|
||||
onClose={() => setConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
|
||||
<Button variant={confirm.kind === 'block' || confirm.kind === 'delete' ? 'danger' : 'primary'} onClick={runConfirm}>
|
||||
{t('users.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user