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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user