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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-21 08:52:31 +03:00
1 parent b27b915b63
commit 978f03b8f8
20 files changed
+551 -274

No files matched your search

+1 -1
View File
@@ -164,7 +164,7 @@ describe('users admin', () => {
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
+24 -4
View File
@@ -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>
</>
+3 -3
View File
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client';
export type Role = components['schemas']['Role'];
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
export type UserAction = 'reset_password' | 'reset_recommendations' | 'block' | 'change_role' | 'delete';
/**
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
@@ -9,9 +9,9 @@ export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
*/
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
const self = actor.id === target.id;
if (self && action !== 'reset_password') return false;
if (self && (action === 'block' || action === 'change_role' || action === 'delete')) return false;
if (actor.role === 'admin') return true;
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
if (actor.role === 'moderator') return action !== 'change_role' && action !== 'delete' && target.role === 'user';
return false;
}
+2
View File
@@ -223,6 +223,8 @@ export function fakeApi(initial: FakeUser[]) {
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
return new Response(null, { status: 204 });
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');