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(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(null); const query = useDebounced(q.trim(), 200); const [creating, setCreating] = useState(false); const [confirm, setConfirm] = useState(null); const [secret, setSecret] = useState<{ username: string; password: string } | null>(null); const [actionError, setActionError] = useState(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[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 (

{t('users.title')}

{t('users.subtitle')}

{ROLES.map((r) => ( ))}
{actionError || list.error ? errorText(actionError ?? list.error) : null}
{users.map((u) => { const items = actionsFor(u); const st = status(u); return ( ); })}
{t('users.col_user')} {t('users.col_role')} {t('users.col_status')} {t('users.col_last_login')}
{u.display_name ?? u.username} {u.id === me.id && ({t('users.you')})}
@{u.username}
{t(`common:roles.${u.role}`)} {t(`users.status_${st}`)} {u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')} {items.length > 0 && ( } items={items} /> )}
{list.isSuccess && users.length === 0 &&
{t('users.empty')}
} {list.isPending &&
{t('common:loading')}
}
{list.hasNextPage && ( )} {creating && ( setCreating(false)} onCreated={(username, password) => { setCreating(false); setSecret({ username, password }); void refresh(); }} /> )} {confirm && ( setConfirm(null)} footer={ <> } >

{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}

)} {secret && setSecret(null)} />}
); } 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('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 ( } >
{roles.length > 1 && ( )}

{t('create.password_note')}

{create.error && !usernameError && !nameError ? errorText(create.error) : null}
); } 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 ( {t('temp_password.done')} } >

{t('temp_password.text', { username })}

{password}
); }