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
- 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>
363 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|