Фронт: вход по паролю, окно установки пароля, профиль и админка
Страница входа начинается с карточки «Вход» (ник и пароль), в ней же переключатель на регистрацию с повтором пароля. Telegram стоит ниже вторым способом. Под ним подсказка: аккаунт, созданный по паролю, сначала привязывают к Telegram в профиле, иначе вход через Telegram заведёт новый аккаунт. Поле логина без автозаглавной буквы и автоисправления, у полей правильный autocomplete для менеджеров паролей. SetPasswordDialog показывается в AppShell всем, у кого has_password=false: аккаунтам из Telegram и созданным до паролей. Закрыть окно нельзя, только задать пароль или выйти. Успешный ответ кладёт в кэш профиль с has_password=true, и окно исчезает само. Кнопка «Позже (dev)» есть лишь под import.meta.env.DEV: в прод-бандле её строки нет, это проверено по dist. В профиле появилась карточка «Вход в аккаунт»: смена пароля (текущий, если он задан, новый и повтор) и привязка Telegram тем же виджетом, что при входе. У смены ника подсказка, что ник — это и логин. В админке у игрока появилась кнопка «Задать пароль» с вводом в той же строке, как у переименования, а в строке игрока — пометка «без пароля». schema.d.ts перегенерирован с живого бэкенда. #24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
useCreateDevUser,
|
||||
useDevUsers,
|
||||
useMe,
|
||||
usePasswordLogin,
|
||||
useRegister,
|
||||
useStubLogin,
|
||||
useTelegramLogin,
|
||||
useTelegramRegister,
|
||||
@@ -28,6 +30,7 @@ export function LoginPage() {
|
||||
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
||||
|
||||
const methods = config?.methods ?? [];
|
||||
const hasPassword = methods.includes("password");
|
||||
const hasTelegram = methods.includes("telegram");
|
||||
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
||||
// import.meta.env.DEV === false → блок вырезается из бандла).
|
||||
@@ -72,22 +75,29 @@ export function LoginPage() {
|
||||
onDone={() => navigate("/", { replace: true })}
|
||||
/>
|
||||
) : (
|
||||
hasTelegram && (
|
||||
<div className="card">
|
||||
<h3>Вход через Telegram</h3>
|
||||
{config?.telegram_bot_username ? (
|
||||
<TelegramLoginButton
|
||||
botUsername={config.telegram_bot_username}
|
||||
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
) : (
|
||||
<p className="muted small">
|
||||
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
||||
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||
<>
|
||||
{hasPassword && <PasswordAuthCard onDone={() => navigate("/", { replace: true })} />}
|
||||
{hasTelegram && (
|
||||
<div className="card">
|
||||
<h3>Вход через Telegram</h3>
|
||||
{config?.telegram_bot_username ? (
|
||||
<TelegramLoginButton
|
||||
botUsername={config.telegram_bot_username}
|
||||
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
) : (
|
||||
<p className="muted small">
|
||||
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
||||
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||
</p>
|
||||
)}
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле —
|
||||
иначе вход через Telegram создаст новый аккаунт.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
||||
@@ -98,6 +108,106 @@ export function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Основной вход: логин (ник) и пароль, с переключением на регистрацию.
|
||||
function PasswordAuthCard({ onDone }: { onDone: () => void }) {
|
||||
const login = usePasswordLogin();
|
||||
const register = useRegister();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isRegister = mode === "register";
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const nick = nickname.trim();
|
||||
if (!nick || !password) {
|
||||
setError("Введите никнейм и пароль.");
|
||||
return;
|
||||
}
|
||||
if (isRegister && password !== repeat) {
|
||||
setError("Пароли не совпадают.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
if (isRegister) await register.mutateAsync({ nickname: nick, password });
|
||||
else await login.mutateAsync({ nickname: nick, password });
|
||||
onDone();
|
||||
} catch (err) {
|
||||
const fallback = isRegister ? "Не удалось зарегистрироваться" : "Не удалось войти";
|
||||
setError(err instanceof ApiError ? err.message : fallback);
|
||||
}
|
||||
};
|
||||
|
||||
const switchMode = () => {
|
||||
setMode(isRegister ? "login" : "register");
|
||||
setRepeat("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h3>{isRegister ? "Регистрация" : "Вход"}</h3>
|
||||
<div className="field">
|
||||
<label className="label" htmlFor="auth-nickname">
|
||||
Никнейм
|
||||
</label>
|
||||
<input
|
||||
id="auth-nickname"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label" htmlFor="auth-password">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="auth-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={isRegister ? "new-password" : "current-password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{isRegister && <div className="muted small">Не короче 8 символов.</div>}
|
||||
</div>
|
||||
{isRegister && (
|
||||
<div className="field">
|
||||
<label className="label" htmlFor="auth-repeat">
|
||||
Повторите пароль
|
||||
</label>
|
||||
<input
|
||||
id="auth-repeat"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={repeat}
|
||||
onChange={(e) => setRepeat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={login.isPending || register.isPending}
|
||||
>
|
||||
{isRegister ? "Зарегистрироваться" : "Войти"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-block small" style={{ marginTop: 8 }} onClick={switchMode}>
|
||||
{isRegister ? "Уже есть аккаунт? Войти" : "Нет аккаунта? Зарегистрироваться"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
||||
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
||||
function TelegramNicknamePicker({
|
||||
|
||||
Reference in New Issue
Block a user