Files
ForbiddenStarsApp/frontend/src/pages/LoginPage.tsx
T
NotBigGhostandClaude Opus 5 462079d835 Фронт: вход по паролю, окно установки пароля, профиль и админка
Страница входа начинается с карточки «Вход» (ник и пароль), в ней же
переключатель на регистрацию с повтором пароля. 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
2026-09-13 14:11:57 +03:00

339 lines
12 KiB
TypeScript

import { useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Spinner } from "../components/Spinner";
import { TelegramLoginButton } from "../components/TelegramLoginButton";
import {
useAuthConfig,
useCreateDevUser,
useDevUsers,
useMe,
usePasswordLogin,
useRegister,
useStubLogin,
useTelegramLogin,
useTelegramRegister,
} from "../hooks/auth";
// Данные виджета Telegram, отложенные до выбора ника (когда тег занят).
interface PendingTelegram {
payload: Record<string, unknown>;
suggested: string;
}
export function LoginPage() {
const { data: me, isLoading } = useMe();
const { data: config } = useAuthConfig();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
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 → блок вырезается из бандла).
const showStub = import.meta.env.DEV && methods.includes("stub");
const telegram = useTelegramLogin();
if (isLoading) return <Spinner />;
if (me) return <Navigate to="/" replace />;
const onTelegram = async (user: Record<string, unknown>) => {
setError(null);
try {
await telegram.mutateAsync(user);
navigate("/", { replace: true });
} catch (e) {
// Тег занят/некорректен → просим выбрать ник и регистрируем тем же payload.
if (e instanceof ApiError && e.code === "TELEGRAM_NICKNAME_REQUIRED") {
const suggested = (e.details as { suggested?: string } | undefined)?.suggested ?? "";
setPendingTg({ payload: user, suggested });
return;
}
setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
}
};
return (
<div className="app-shell">
<div className="app-main" style={{ paddingBottom: 24 }}>
<h1 className="center" style={{ marginTop: 24 }}>
Forbidden Stars
</h1>
<p className="muted center">Учёт партий</p>
{pendingTg ? (
<TelegramNicknamePicker
pending={pendingTg}
onCancel={() => {
setPendingTg(null);
setError(null);
}}
onDone={() => navigate("/", { replace: true })}
/>
) : (
<>
{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>
)}
</>
)}
{showStub && !pendingTg && <DevLogin onError={setError} />}
{error && <p className="error-text center">{error}</p>}
</div>
</div>
);
}
// Основной вход: логин (ник) и пароль, с переключением на регистрацию.
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({
pending,
onCancel,
onDone,
}: {
pending: PendingTelegram;
onCancel: () => void;
onDone: () => void;
}) {
const register = useTelegramRegister();
const [nickname, setNickname] = useState(pending.suggested);
const [error, setError] = useState<string | null>(
pending.suggested ? `Ник «${pending.suggested}» уже занят — выберите другой.` : null,
);
const submit = async () => {
const nick = nickname.trim();
if (!nick) {
setError("Введите никнейм.");
return;
}
setError(null);
try {
await register.mutateAsync({ ...pending.payload, nickname: nick });
onDone();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось зарегистрироваться");
}
};
return (
<div className="card">
<h3>Выберите никнейм</h3>
<p className="muted small">
Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации.
</p>
<div className="field">
<div className="row">
<input
placeholder="Никнейм"
value={nickname}
autoFocus
onChange={(e) => setNickname(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<button className="btn btn-primary" onClick={submit} disabled={register.isPending}>
Зарегистрироваться
</button>
</div>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-ghost small" onClick={onCancel}>
Отмена
</button>
</div>
);
}
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
const { data: devUsers } = useDevUsers(true);
const stubLogin = useStubLogin();
const createUser = useCreateDevUser();
const navigate = useNavigate();
const [nickname, setNickname] = useState("");
const doLogin = async (nick: string) => {
onError(null);
try {
await stubLogin.mutateAsync(nick);
navigate("/", { replace: true });
} catch (e) {
onError(e instanceof ApiError ? e.message : "Не удалось войти");
}
};
const onCreate = async () => {
const nick = nickname.trim();
if (!nick) return;
try {
await createUser.mutateAsync(nick);
await doLogin(nick);
} catch (e) {
onError(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div className="card">
<h3>Тестовый вход (dev)</h3>
<div className="field">
<label className="label">Новый игрок</label>
<div className="row">
<input placeholder="Ваш никнейм" value={nickname} onChange={(e) => setNickname(e.target.value)} />
<button className="btn btn-primary" onClick={onCreate} disabled={createUser.isPending}>
Войти
</button>
</div>
</div>
{devUsers && devUsers.length > 0 && (
<>
<div className="label">Или выберите существующего:</div>
<div className="stack">
{devUsers.map((u) => (
<button
key={u.id}
className="btn"
disabled={!u.is_active}
title={u.is_active ? undefined : "Аккаунт отключён администратором"}
style={u.is_active ? undefined : { opacity: 0.45, color: "var(--text-muted)" }}
onClick={() => doLogin(u.nickname)}
>
{u.nickname}
{!u.is_active && <span className="small"> · отключён</span>}
</button>
))}
</div>
</>
)}
</div>
);
}