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

Страница входа начинается с карточки «Вход» (ник и пароль), в ней же
переключатель на регистрацию с повтором пароля. 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
+128
View File
@@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch";
import { TelegramLoginButton } from "../components/TelegramLoginButton";
import { useToast } from "../context/ToastContext";
import type { Me } from "../domain/types";
import {
useAuthConfig,
useDeleteAvatar,
useLinkTelegram,
useLogout,
useMe,
useSetPassword,
useUpdateBio,
useUpdateFavoriteFaction,
useUpdateHistoryPrefs,
@@ -218,6 +223,9 @@ export function AccountPage() {
Сохранить
</button>
</div>
<div className="muted small" style={{ marginTop: 6 }}>
Никнейм — это и логин для входа: после смены входите под новым.
</div>
</div>
</div>
@@ -256,9 +264,129 @@ export function AccountPage() {
</div>
</div>
<LoginSettingsCard me={me} />
<button className="btn btn-danger btn-block" onClick={doLogout}>
Выйти
</button>
</div>
);
}
// Вход в аккаунт: смена пароля и привязка Telegram (ник при привязке не меняется).
function LoginSettingsCard({ me }: { me: Me }) {
const setPassword = useSetPassword();
const linkTelegram = useLinkTelegram();
const { data: config } = useAuthConfig();
const toast = useToast();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [error, setError] = useState<string | null>(null);
const changePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (next !== repeat) {
setError("Пароли не совпадают.");
return;
}
setError(null);
try {
await setPassword.mutateAsync(
me.has_password ? { current_password: current, new_password: next } : { new_password: next },
);
setCurrent("");
setNext("");
setRepeat("");
toast.show(me.has_password ? "Пароль изменён" : "Пароль задан");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль");
}
};
const onTelegram = async (payload: Record<string, unknown>) => {
try {
await linkTelegram.mutateAsync(payload);
toast.show("Telegram привязан");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
}
};
const telegramReady = !!config?.telegram_bot_username && config.methods.includes("telegram");
return (
<div className="card">
<h3>Вход в аккаунт</h3>
<form onSubmit={changePassword}>
{/* Скрытый логин — чтобы менеджер паролей обновил сохранённую пару. */}
<input type="text" name="username" autoComplete="username" value={me.nickname} readOnly hidden />
{me.has_password && (
<div className="field">
<label className="label" htmlFor="current-password">
Текущий пароль
</label>
<input
id="current-password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</div>
)}
<div className="field">
<label className="label" htmlFor="new-password">
Новый пароль (не короче 8 символов)
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</div>
<div className="field">
<label className="label" htmlFor="new-password-repeat">
Повторите новый пароль
</label>
<input
id="new-password-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"
disabled={setPassword.isPending || !next || (me.has_password && !current)}
>
{me.has_password ? "Сменить пароль" : "Задать пароль"}
</button>
</form>
<div className="field" style={{ marginTop: 16, marginBottom: 0 }}>
<label className="label">Telegram</label>
{me.telegram_id != null ? (
<div>Привязан — можно входить и через Telegram.</div>
) : telegramReady ? (
<>
<p className="muted small" style={{ marginTop: 0 }}>
Привяжите Telegram, чтобы входить и через него. Никнейм не изменится.
</p>
<TelegramLoginButton
botUsername={config?.telegram_bot_username ?? ""}
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
/>
</>
) : (
<div className="muted small">Вход через Telegram не настроен.</div>
)}
</div>
</div>
);
}
+125 -15
View File
@@ -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({
+59 -2
View File
@@ -1,10 +1,10 @@
import { Pencil } from "lucide-react";
import { KeyRound, Pencil } from "lucide-react";
import { useState } from "react";
import { ApiError } from "../../api/client";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
@@ -12,17 +12,40 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
export function AdminAccountsPage() {
const { data: users, isLoading } = useAdminUsers();
const update = useAdminUpdateUser();
const setPassword = useAdminSetPassword();
const toast = useToast();
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
// Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование.
const [passwordId, setPasswordId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState("");
if (isLoading) return <Spinner />;
const startEdit = (id: number, nickname: string) => {
setPasswordId(null);
setEditId(id);
setEditName(nickname);
};
const startPassword = (id: number) => {
setEditId(null);
setPasswordId(id);
setNewPassword("");
};
const savePassword = async () => {
if (passwordId == null || !newPassword) return;
try {
await setPassword.mutateAsync({ userId: passwordId, password: newPassword });
toast.show("Пароль задан — сообщите его игроку");
setPasswordId(null);
setNewPassword("");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось задать пароль");
}
};
const saveName = async () => {
const name = editName.trim();
if (editId == null || !name) return;
@@ -73,12 +96,39 @@ export function AdminAccountsPage() {
Отмена
</button>
</div>
) : passwordId === u.id ? (
// Режим «задать пароль».
<div className="row">
<input
type="password"
autoComplete="new-password"
placeholder={`Новый пароль для ${u.nickname}`}
value={newPassword}
autoFocus
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") savePassword();
if (e.key === "Escape") setPasswordId(null);
}}
/>
<button
className="btn btn-primary small"
disabled={setPassword.isPending || !newPassword}
onClick={savePassword}
>
Задать
</button>
<button className="btn btn-ghost small" onClick={() => setPasswordId(null)}>
Отмена
</button>
</div>
) : (
<div className="row-between">
<div>
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
<div className="muted small">
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
{u.role !== "admin" && !u.has_password && " · без пароля"}
</div>
</div>
{u.role !== "admin" && (
@@ -90,6 +140,13 @@ export function AdminAccountsPage() {
>
<Pencil size={16} />
</button>
<button
className="btn btn-ghost small"
title="Задать пароль"
onClick={() => startPassword(u.id)}
>
<KeyRound size={16} />
</button>
{import.meta.env.DEV && (
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
)}