feat(web): sign-in, first-run setup, app shell and user admin (M1)
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
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>
This commit is contained in:
1 parent
f075863f15
commit
ab092def54
44 files changed
+4070
-203
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Logo } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
||||
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||
return (
|
||||
<div className={s.screen}>
|
||||
<div className={s.backdrop} aria-hidden>
|
||||
<span className={s.blob1} />
|
||||
<span className={s.blob2} />
|
||||
<span className={s.blob3} />
|
||||
</div>
|
||||
<header className={s.top}>
|
||||
<Logo />
|
||||
<LanguageSwitch />
|
||||
</header>
|
||||
<main className={s.center}>
|
||||
<section className={s.card}>{children}</section>
|
||||
{footer && <div className={s.footer}>{footer}</div>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUser } from '../app/session';
|
||||
import { playIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import s from './home.module.css';
|
||||
|
||||
/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
return (
|
||||
<div className={s.home}>
|
||||
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
|
||||
<div className={s.orb} aria-disabled="true">
|
||||
<Icon src={playIcon} size={44} />
|
||||
<span className={s.orbLabel}>{t('home.wave')}</span>
|
||||
</div>
|
||||
<p className={s.soon}>{t('home.wave_soon')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
|
||||
const info = useQuery({
|
||||
queryKey: ['server-info'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/server-info')).data ?? null,
|
||||
});
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Переход выполнит роутер: смена состояния сессии пересчитывает guards.
|
||||
await session.login(username.trim(), password);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
setPassword('');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
<Field
|
||||
label={t('login.username')}
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('login.password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className={s.submit}
|
||||
busy={busy}
|
||||
disabled={!username.trim() || !password}
|
||||
>
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||
import { formatDateTime } from '../shared/lib/format';
|
||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`${s.page} ${s.narrow}`}>
|
||||
<div className={s.head}>
|
||||
<h1>{t('settings.title')}</h1>
|
||||
</div>
|
||||
<ProfileSection />
|
||||
<AppearanceSection />
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const errorText = useErrorText();
|
||||
const [name, setName] = useState(user.display_name ?? '');
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/me', { body: { display_name: name.trim() } })),
|
||||
onSuccess: (u) => session.setUser(u),
|
||||
});
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('settings.account')}</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
<div className={s.row}>
|
||||
<Field
|
||||
label={t('settings.display_name')}
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setName(v);
|
||||
save.reset();
|
||||
}}
|
||||
maxLength={64}
|
||||
hint={`@${user.username}`}
|
||||
/>
|
||||
<Button type="submit" busy={save.isPending} disabled={name.trim() === (user.display_name ?? '')}>
|
||||
{t('settings.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{save.isSuccess && <span className={s.ok}>{t('settings.saved')}</span>}
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const theme: Theme = user.ui_prefs.theme ?? 'dark';
|
||||
const setTheme = async (value: Theme) => {
|
||||
// Оптимистично: тема применяется сразу, сервер подтверждает.
|
||||
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } });
|
||||
try {
|
||||
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } })));
|
||||
} catch {
|
||||
session.setUser(user);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('settings.appearance')}</h2>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.theme')}</span>
|
||||
<Segmented<Theme>
|
||||
label={t('settings.theme')}
|
||||
value={theme}
|
||||
onChange={(v) => void setTheme(v)}
|
||||
options={[
|
||||
{ value: 'dark', label: t('settings.theme_dark') },
|
||||
{ value: 'light', label: t('settings.theme_light') },
|
||||
{ value: 'system', label: t('settings.theme_system') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('language.label')}</span>
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordSection() {
|
||||
const { t } = useTranslation(['common', 'auth']);
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [repeat, setRepeat] = useState('');
|
||||
const mismatch = repeat !== '' && repeat !== next;
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }),
|
||||
),
|
||||
onSuccess: async (tokens) => {
|
||||
await session.apply(tokens);
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
setRepeat('');
|
||||
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||
},
|
||||
});
|
||||
const err = change.error;
|
||||
const currentError = fieldError(err, 'current_password');
|
||||
const nextError = fieldError(err, 'new_password');
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!mismatch) change.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('settings.password')}</h2>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<Field
|
||||
label={t('settings.current_password')}
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={setCurrent}
|
||||
autoComplete="current-password"
|
||||
error={currentError}
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.new_password')}
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={setNext}
|
||||
autoComplete="new-password"
|
||||
hint={t('auth:setup.min_length', { min: user.role === 'user' ? 8 : 12 })}
|
||||
error={nextError}
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.repeat_password')}
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={setRepeat}
|
||||
autoComplete="new-password"
|
||||
error={mismatch ? t('form.passwords_mismatch') : undefined}
|
||||
/>
|
||||
{change.isSuccess && <span className={s.ok}>{t('settings.password_changed')}</span>}
|
||||
<FormError>{err && !currentError && !nextError ? errorText(err) : null}</FormError>
|
||||
<div className={s.actions}>
|
||||
<Button type="submit" variant="primary" busy={change.isPending} disabled={!current || !next || next !== repeat}>
|
||||
{t('settings.change_password')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const sessions = useQuery({
|
||||
queryKey: ['sessions'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/sessions')).items,
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: async (id: string) =>
|
||||
unwrap(await session.client.DELETE('/api/v1/me/sessions/{id}', { params: { path: { id } } })),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sessions'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('settings.sessions')}</h2>
|
||||
<div className={s.sessions}>
|
||||
{sessions.data?.map((ss) => (
|
||||
<div key={ss.id} className={s.session}>
|
||||
<div>
|
||||
<div>{ss.device_name ?? ss.client_type}</div>
|
||||
<div className={s.sessionMeta}>
|
||||
{ss.current
|
||||
? t('settings.session_current')
|
||||
: t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })}
|
||||
</div>
|
||||
</div>
|
||||
{!ss.current && (
|
||||
<Button variant="ghost" busy={revoke.isPending && revoke.variables === ss.id} onClick={() => revoke.mutate(ss.id)}>
|
||||
{t('settings.session_end')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||
<div>
|
||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||
{t('settings.logout_all')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||
import { lockResetIcon, shieldIcon } from '../shared/ui/icons';
|
||||
import { Button, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/** Обязательный шаг после входа: новые логин и пароль админа (admin) или замена временного пароля (password). */
|
||||
export function SetupPage({ variant }: { variant: 'admin' | 'password' }) {
|
||||
return useAuthState().status === 'signed_in' ? <SetupForm variant={variant} /> : null;
|
||||
}
|
||||
|
||||
function SetupForm({ variant }: { variant: 'admin' | 'password' }) {
|
||||
const { t } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const admin = variant === 'admin';
|
||||
const minLength = user.role === 'user' ? 8 : 12;
|
||||
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [password, setPassword] = useState('');
|
||||
const [repeat, setRepeat] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const mismatch = repeat !== '' && repeat !== password;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (mismatch) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = admin
|
||||
? await session.client.POST('/api/v1/me/bootstrap-credentials', {
|
||||
body: { new_username: username.trim(), new_password: password },
|
||||
})
|
||||
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
|
||||
await session.apply(unwrap(res));
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usernameError = fieldError(error, 'new_username');
|
||||
const passwordError = fieldError(error, 'new_password');
|
||||
const generalError = error && !usernameError && !passwordError ? errorText(error) : null;
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<span className={s.badge}>
|
||||
<Icon src={admin ? shieldIcon : lockResetIcon} size={16} />
|
||||
{t(admin ? 'setup.admin_badge' : 'setup.password_badge')}
|
||||
</span>
|
||||
<h1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</h1>
|
||||
<p className={s.lead}>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
{admin && (
|
||||
<Field
|
||||
label={t('setup.new_username')}
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
error={usernameError}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label={t('setup.new_password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
autoComplete="new-password"
|
||||
hint={t('setup.min_length', { min: minLength })}
|
||||
error={passwordError}
|
||||
/>
|
||||
<Field
|
||||
label={t('setup.repeat_password')}
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={setRepeat}
|
||||
autoComplete="new-password"
|
||||
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
|
||||
/>
|
||||
<FormError>{generalError}</FormError>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className={s.submit}
|
||||
busy={busy}
|
||||
disabled={!password || password !== repeat || (admin && !username.trim())}
|
||||
>
|
||||
{t('setup.submit')}
|
||||
</Button>
|
||||
<Button variant="ghost" className={s.link} onClick={() => void session.logout()}>
|
||||
{t('setup.logout')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatDateTime } from '../../shared/lib/format';
|
||||
import { Button, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
type Entry = components['schemas']['AuditEntry'];
|
||||
|
||||
/** Краткое описание полезной нагрузки записи: «user → moderator», «anna (user)». */
|
||||
function details(e: Entry, roleName: (r: string) => string): string {
|
||||
const p = e.payload ?? {};
|
||||
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||
(v): v is string => typeof v === 'string' && v !== '',
|
||||
);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function AuditPage() {
|
||||
const { t, i18n } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['admin-audit'],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: async ({ pageParam }) =>
|
||||
unwrap(await session.client.GET('/api/v1/admin/audit', { params: { query: { cursor: pageParam, limit: 50 } } })),
|
||||
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||
});
|
||||
const entries = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const tr = t as unknown as (key: string, opts?: Record<string, unknown>) => string;
|
||||
const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r });
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('audit.title')}</h1>
|
||||
<p className={s.subtitle}>{t('audit.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('audit.col_when')}</th>
|
||||
<th>{t('audit.col_who')}</th>
|
||||
<th>{t('audit.col_action')}</th>
|
||||
<th>{t('audit.col_details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className={s.muted}>{formatDateTime(e.created_at, i18n.language)}</td>
|
||||
<td>{e.actor ? `@${e.actor.username}` : <span className={s.muted}>{t('audit.system')}</span>}</td>
|
||||
<td>{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}</td>
|
||||
<td className={s.muted}>{details(e, roleName)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.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('audit.more')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
.screen {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: var(--amb-op);
|
||||
}
|
||||
|
||||
.backdrop span {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
}
|
||||
|
||||
.blob1 {
|
||||
width: 62vmax;
|
||||
height: 62vmax;
|
||||
left: -18vmax;
|
||||
top: -24vmax;
|
||||
background: radial-gradient(circle, rgba(47, 128, 255, 0.34), transparent 65%);
|
||||
animation: drift1 16s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.blob2 {
|
||||
width: 55vmax;
|
||||
height: 55vmax;
|
||||
right: -20vmax;
|
||||
bottom: -26vmax;
|
||||
background: radial-gradient(circle, rgba(124, 92, 255, 0.3), transparent 65%);
|
||||
animation: drift2 21s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.blob3 {
|
||||
width: 40vmax;
|
||||
height: 40vmax;
|
||||
left: 40%;
|
||||
top: 30%;
|
||||
background: radial-gradient(circle, rgba(53, 211, 255, 0.16), transparent 65%);
|
||||
animation: drift1 26s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
@keyframes drift1 {
|
||||
to {
|
||||
transform: translate(6vmax, 4vmax);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drift2 {
|
||||
to {
|
||||
transform: translate(-5vmax, -6vmax);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.top {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 36px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
border: 1px solid var(--line2);
|
||||
box-shadow: var(--shadow);
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.card h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: -6px 0 4px;
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.badge {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--violet-soft);
|
||||
color: var(--violet);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.submit {
|
||||
height: 48px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.home {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.orb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
max-width: 70vw;
|
||||
max-height: 70vw;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%),
|
||||
radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%),
|
||||
radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%);
|
||||
box-shadow: 0 0 80px rgba(47, 128, 255, 0.35);
|
||||
opacity: 0.85;
|
||||
animation: breathe 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orbLabel {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.soon {
|
||||
max-width: 420px;
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.narrow {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-a);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row > :first-child {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px 0 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.chip[aria-pressed='true'] {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-a);
|
||||
}
|
||||
|
||||
/* Горизонтальная прокрутка только там, где таблица не помещается: она обрезала бы меню действий. */
|
||||
@media (max-width: 900px) {
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table td {
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.userCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.userLogin {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.you {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role_admin {
|
||||
background: var(--violet-soft);
|
||||
color: var(--violet);
|
||||
}
|
||||
|
||||
.role_moderator {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.role_user {
|
||||
background: var(--hover);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status_active {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.status_blocked {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status_must_change {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.more {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.secret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface2);
|
||||
border: 1px dashed var(--line2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.select {
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.sessionMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
Reference in new issue
Block a user