Files
pipu-music/apps/web/src/pages/admin/UsersPage.tsx
T
mrleo1nidandClaude Opus 5.5 ab092def54
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
feat(web): sign-in, first-run setup, app shell and user admin (M1)
- core: AuthSession keeps the access token in memory, refreshes it
  once for all parallel requests (single-flight) and retries them;
  refresh races between tabs are retried, reuse signs out; ApiError
  carries problem+json codes and field errors
- web: TanStack Router with guards — guests go to /login, the bootstrap
  admin to /setup/admin, temporary passwords to /setup/password, admin
  pages by role
- login, setup screens, Classic shell (top bar, role-based sidebar,
  empty player), settings: name, theme, language, password, devices
- users admin: search, role filter, create with a one-time temporary
  password, reset, block, role change, delete as the policy allows;
  audit log for admins
- ru/en strings for auth, admin and error codes; language and theme
  are saved in the profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:33:46 +03:00

363 lines
13 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
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 { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser'];
const ROLES: Role[] = ['admin', 'moderator', 'user'];
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return v;
}
function status(u: AdminUser): 'blocked' | 'must_change' | 'active' {
if (u.is_blocked) return 'blocked';
if (u.must_change_password || u.must_change_credentials) return 'must_change';
return 'active';
}
export function UsersPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const me = useUser();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [q, setQ] = useState('');
const [role, setRole] = useState<Role | null>(null);
const query = useDebounced(q.trim(), 200);
const [creating, setCreating] = useState(false);
const [confirm, setConfirm] = useState<Confirm | null>(null);
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const list = useInfiniteQuery({
queryKey: ['admin-users', query, role],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/users', {
params: { query: { q: query || undefined, role: role ?? undefined, cursor: pageParam, limit: 50 } },
}),
),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const users = list.data?.pages.flatMap((p) => p.items) ?? [];
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] });
const patch = useMutation({
mutationFn: async ({ id, body }: { id: string; body: components['schemas']['UpdateUserRequest'] }) =>
unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', { params: { path: { id } }, body })),
onSuccess: refresh,
onError: setActionError,
});
const reset = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(
await session.client.POST('/api/v1/admin/users/{id}/reset-password', { params: { path: { id: u.id } } }),
),
onSuccess: (r, u) => {
setSecret({ username: u.username, password: r.temporary_password });
void refresh();
},
onError: setActionError,
});
const remove = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
onSuccess: refresh,
onError: setActionError,
});
const runConfirm = () => {
if (!confirm) return;
const { kind, user } = confirm;
setConfirm(null);
setActionError(null);
if (kind === 'reset') reset.mutate(user);
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
if (kind === 'delete') remove.mutate(user);
};
const actionsFor = (u: AdminUser): MenuItem[] => {
const items: MenuItem[] = [];
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('block'))
items.push(
u.is_blocked
? { label: t('users.unblock'), onSelect: () => patch.mutate({ id: u.id, body: { is_blocked: false } }) }
: { label: t('users.block'), onSelect: () => setConfirm({ kind: 'block', user: u }) },
);
if (can('change_role'))
for (const r of ROLES.filter((r) => r !== u.role))
items.push({
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
};
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('users.title')}</h1>
<p className={s.subtitle}>{t('users.subtitle')}</p>
</div>
<Button variant="primary" icon={addIcon} onClick={() => setCreating(true)}>
{t('users.create')}
</Button>
</div>
<div className={s.toolbar}>
<label className={s.search}>
<Icon src={searchIcon} size={18} className={s.searchIcon} />
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('users.search')}
aria-label={t('users.search')}
/>
</label>
<div className={s.chips} role="group" aria-label={t('users.col_role')}>
<button type="button" className={s.chip} aria-pressed={role === null} onClick={() => setRole(null)}>
{t('users.all')}
</button>
{ROLES.map((r) => (
<button key={r} type="button" className={s.chip} aria-pressed={role === r} onClick={() => setRole(r)}>
{t(`common:roles.${r}`)}
</button>
))}
</div>
</div>
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('users.col_user')}</th>
<th>{t('users.col_role')}</th>
<th>{t('users.col_status')}</th>
<th>{t('users.col_last_login')}</th>
<th aria-hidden />
</tr>
</thead>
<tbody>
{users.map((u) => {
const items = actionsFor(u);
const st = status(u);
return (
<tr key={u.id}>
<td>
<div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} />
<div>
<div className={s.userName}>
{u.display_name ?? u.username}
{u.id === me.id && <span className={s.you}>({t('users.you')})</span>}
</div>
<div className={s.userLogin}>@{u.username}</div>
</div>
</div>
</td>
<td>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
</td>
<td>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td>
<td className={s.muted}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td>
<td>
{items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('users.more')}
</Button>
)}
{creating && (
<CreateUserDialog
actorRole={me.role}
onClose={() => setCreating(false)}
onCreated={(username, password) => {
setCreating(false);
setSecret({ username, password });
void refresh();
}}
/>
)}
{confirm && (
<Dialog
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : 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}>
{t('users.confirm')}
</Button>
</>
}
>
<p className={s.note}>{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}</p>
</Dialog>
)}
{secret && <TemporaryPasswordDialog {...secret} onClose={() => setSecret(null)} />}
</div>
);
}
function CreateUserDialog({
actorRole,
onClose,
onCreated,
}: {
actorRole: Role;
onClose: () => void;
onCreated: (username: string, password: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const roles = assignableRoles(actorRole);
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<Role>('user');
const create = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/users', {
body: { username: username.trim(), display_name: name.trim() || undefined, role },
}),
),
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
});
const usernameError = fieldError(create.error, 'username');
const nameError = fieldError(create.error, 'display_name');
const submit = (e: FormEvent) => {
e.preventDefault();
create.mutate();
};
return (
<Dialog
title={t('create.title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button type="submit" form="create-user" variant="primary" busy={create.isPending} disabled={!username.trim()}>
{t('create.submit')}
</Button>
</>
}
>
<form id="create-user" onSubmit={submit} noValidate className={s.sessions}>
<Field
label={t('create.username')}
value={username}
onChange={setUsername}
autoCapitalize="none"
spellCheck={false}
hint={t('create.username_hint')}
error={usernameError}
mono
/>
<Field label={t('create.display_name')} value={name} onChange={setName} maxLength={64} error={nameError} />
{roles.length > 1 && (
<label className={s.sessions}>
<span className={s.subtitle}>{t('create.role')}</span>
<select className={s.select} value={role} onChange={(e) => setRole(e.target.value as Role)}>
{roles.map((r) => (
<option key={r} value={r}>
{t(`common:roles.${r}`)}
</option>
))}
</select>
</label>
)}
<p className={s.note}>{t('create.password_note')}</p>
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
</form>
</Dialog>
);
}
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
} catch {
// Буфер обмена недоступен (http, права) — пароль виден на экране.
}
};
return (
<Dialog
title={t('temp_password.title')}
onClose={onClose}
footer={
<Button variant="primary" onClick={onClose}>
{t('temp_password.done')}
</Button>
}
>
<p className={s.note}>{t('temp_password.text', { username })}</p>
<div className={s.secret}>
<span data-testid="temporary-password">{password}</span>
<Button variant="ghost" icon={copied ? checkIcon : copyIcon} onClick={() => void copy()}>
{copied ? t('common:form.copied') : t('common:form.copy')}
</Button>
</div>
</Dialog>
);
}