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>
76 lines
2.3 KiB
TypeScript
76 lines
2.3 KiB
TypeScript
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>
|
|
);
|
|
}
|