Фронт: вход по паролю, окно установки пароля, профиль и админка

Страница входа начинается с карточки «Вход» (ник и пароль), в ней же
переключатель на регистрацию с повтором пароля. 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:
2026-09-13 14:11:57 +03:00
co-authored by Claude Opus 5
parent c73b4519cb
commit 462079d835
8 changed files with 775 additions and 17 deletions
+4
View File
@@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
import { BottomBar } from "./BottomBar";
import { NotificationBell } from "./NotificationBell";
import { NotificationToaster } from "./NotificationToaster";
import { SetPasswordDialog } from "./SetPasswordDialog";
import { SideMenu } from "./SideMenu";
const TITLES: Record<string, string> = {
@@ -51,6 +52,9 @@ export function AppShell() {
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
<BottomBar onMenu={() => setMenuOpen(true)} />
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
</div>
);
}
@@ -0,0 +1,95 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { useLogout, useSetPassword } from "../hooks/auth";
/**
* Обязательная установка пароля — для аккаунтов из Telegram и созданных до появления
* паролей. Закрыть окно нельзя: только задать пароль или выйти. Кнопка «Позже» есть
* лишь в dev-сборке (import.meta.env.DEV) — чтобы тестовые stub-игроки не упирались в окно.
*/
export function SetPasswordDialog({ nickname }: { nickname: string }) {
const setPassword = useSetPassword();
const logout = useLogout();
const navigate = useNavigate();
const [password, setPasswordValue] = useState("");
const [repeat, setRepeat] = useState("");
const [error, setError] = useState<string | null>(null);
const [skipped, setSkipped] = useState(false);
if (skipped) return null;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== repeat) {
setError("Пароли не совпадают.");
return;
}
setError(null);
try {
// Успех кладёт в кэш профиль с has_password=true — окно исчезает само.
await setPassword.mutateAsync({ new_password: password });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль");
}
};
const doLogout = async () => {
await logout.mutateAsync().catch(() => {});
navigate("/login", { replace: true });
};
return (
<div className="modal-overlay">
<form className="modal" onSubmit={submit}>
<div className="modal-title">Задайте пароль</div>
<p className="modal-msg">
Теперь на сайт входят по логину и паролю. Ваш логин — никнейм <b>{nickname}</b>.
Придумайте пароль, чтобы продолжить; вход через Telegram тоже останется.
</p>
{/* Скрытый логин — чтобы менеджер паролей сохранил пару целиком. */}
<input type="text" name="username" autoComplete="username" value={nickname} readOnly hidden />
<div className="field">
<label className="label" htmlFor="set-password">
Пароль (не короче 8 символов)
</label>
<input
id="set-password"
type="password"
autoComplete="new-password"
autoFocus
value={password}
onChange={(e) => setPasswordValue(e.target.value)}
/>
</div>
<div className="field">
<label className="label" htmlFor="set-password-repeat">
Повторите пароль
</label>
<input
id="set-password-repeat"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
/>
</div>
{error && <p className="error-text">{error}</p>}
<div className="modal-actions">
{import.meta.env.DEV && (
<button type="button" className="btn btn-ghost" onClick={() => setSkipped(true)}>
Позже (dev)
</button>
)}
<button type="button" className="btn btn-ghost" onClick={doLogout}>
Выйти
</button>
<button type="submit" className="btn btn-primary" disabled={setPassword.isPending || !password}>
Сохранить
</button>
</div>
</form>
</div>
);
}