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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:33:46 +03:00
1 parent f075863f15
commit ab092def54
44 files changed
+4070 -203

No files matched your search

+230
View File
@@ -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>
);
}