From 462079d8358f1d3e3dec281257a68ece11d3acf2 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 14:11:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D1=80=D0=BE=D0=BD=D1=82:=20=D0=B2=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BF=D0=BE=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8E,=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=83=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F,=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=20=D0=B8?= =?UTF-8?q?=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница входа начинается с карточки «Вход» (ник и пароль), в ней же переключатель на регистрацию с повтором пароля. 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 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- frontend/src/api/schema.d.ts | 303 ++++++++++++++++++ frontend/src/components/AppShell.tsx | 4 + frontend/src/components/SetPasswordDialog.tsx | 95 ++++++ frontend/src/hooks/admin.ts | 15 + frontend/src/hooks/auth.ts | 46 +++ frontend/src/pages/AccountPage.tsx | 128 ++++++++ frontend/src/pages/LoginPage.tsx | 140 +++++++- .../src/pages/admin/AdminAccountsPage.tsx | 61 +++- 8 files changed, 775 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/SetPasswordDialog.tsx diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 371d564..d245781 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -21,6 +21,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Password Register + * @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле. + */ + post: operations["password_register_api_auth_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Password Login + * @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429. + */ + post: operations["password_login_api_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/telegram": { parameters: { query?: never; @@ -122,6 +162,46 @@ export interface paths { patch: operations["update_my_profile_api_users_me_profile_patch"]; trace?: never; }; + "/api/users/me/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Change My Password + * @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий). + */ + put: operations["change_my_password_api_users_me_password_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/telegram": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Link My Telegram + * @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе. + */ + post: operations["link_my_telegram_api_users_me_telegram_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/avatar": { parameters: { query?: never; @@ -844,6 +924,26 @@ export interface paths { patch: operations["update_user_api_admin_users__user_id__patch"]; trace?: never; }; + "/api/admin/users/{user_id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set User Password + * @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется. + */ + put: operations["set_user_password_api_admin_users__user_id__password_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/groups": { parameters: { query?: never; @@ -1224,6 +1324,11 @@ export interface components { /** Role */ role: string; }; + /** AdminPasswordSet */ + AdminPasswordSet: { + /** New Password */ + new_password: string; + }; /** AdminUserRead */ AdminUserRead: { /** Id */ @@ -1240,6 +1345,11 @@ export interface components { telegram_id?: number | null; /** Created At */ created_at: string; + /** + * Has Password + * @default false + */ + has_password: boolean; }; /** AdminUserUpdate */ AdminUserUpdate: { @@ -1887,6 +1997,11 @@ export interface components { * @default [] */ groups: components["schemas"]["GroupBrief"][]; + /** + * Has Password + * @default false + */ + has_password: boolean; }; /** MemberAdd */ MemberAdd: { @@ -1990,6 +2105,27 @@ export interface components { /** Comment */ comment?: string | null; }; + /** PasswordChange */ + PasswordChange: { + /** Current Password */ + current_password?: string | null; + /** New Password */ + new_password: string; + }; + /** PasswordLogin */ + PasswordLogin: { + /** Nickname */ + nickname: string; + /** Password */ + password: string; + }; + /** PasswordRegister */ + PasswordRegister: { + /** Nickname */ + nickname: string; + /** Password */ + password: string; + }; /** ProfileStats */ ProfileStats: { /** User Id */ @@ -2198,6 +2334,72 @@ export interface operations { }; }; }; + password_register_api_auth_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordRegister"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + password_login_api_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordLogin"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; telegram_login_api_auth_telegram_post: { parameters: { query?: never; @@ -2370,6 +2572,72 @@ export interface operations { }; }; }; + change_my_password_api_users_me_password_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChange"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + link_my_telegram_api_users_me_telegram_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramAuthPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; upload_my_avatar_api_users_me_avatar_put: { parameters: { query?: never; @@ -3789,6 +4057,41 @@ export interface operations { }; }; }; + set_user_password_api_admin_users__user_id__password_put: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPasswordSet"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminUserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_groups_api_admin_groups_get: { parameters: { query?: never; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0b2460a..5088d43 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 = { @@ -51,6 +52,9 @@ export function AppShell() { setMenuOpen(false)} /> setMenuOpen(true)} /> + + {/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */} + {me && !me.has_password && } ); } diff --git a/frontend/src/components/SetPasswordDialog.tsx b/frontend/src/components/SetPasswordDialog.tsx new file mode 100644 index 0000000..3eb1ee0 --- /dev/null +++ b/frontend/src/components/SetPasswordDialog.tsx @@ -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(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 ( +
+
+
Задайте пароль
+

+ Теперь на сайт входят по логину и паролю. Ваш логин — никнейм {nickname}. + Придумайте пароль, чтобы продолжить; вход через Telegram тоже останется. +

+ {/* Скрытый логин — чтобы менеджер паролей сохранил пару целиком. */} + +
+ + setPasswordValue(e.target.value)} + /> +
+
+ + setRepeat(e.target.value)} + /> +
+ {error &&

{error}

} +
+ {import.meta.env.DEV && ( + + )} + + +
+
+
+ ); +} diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 8828c15..4ab7ff8 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -203,6 +203,21 @@ export function useAdminUpdateUser() { }); } +// Новый пароль игроку — когда он забыл свой (почту приложение не хранит). +export function useAdminSetPassword() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { userId: number; password: string }) => + unwrap( + await api.PUT("/api/admin/users/{user_id}/password", { + params: { path: { user_id: args.userId } }, + body: { new_password: args.password }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), + }); +} + // ─── Ачивки (редактор; выдача игрокам — на будущее) ────────────────────────── interface AchievementInput { diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 9442c2d..b86b54f 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -45,6 +45,52 @@ export function useStubLogin() { }); } +// Вход по логину (нику) и паролю. Перебор сервер ограничивает: 429 TOO_MANY_ATTEMPTS. +export function usePasswordLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { nickname: string; password: string }) => + unwrap(await api.POST("/api/auth/login", { body })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +// Регистрация по логину (нику) и паролю; Telegram привязывается позже в профиле. +export function useRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { nickname: string; password: string }) => + unwrap(await api.POST("/api/auth/register", { body })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +// Задать пароль (первый раз — без текущего) или сменить его (нужен текущий). +export function useSetPassword() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { new_password: string; current_password?: string }): Promise => + unwrap(await api.PUT("/api/users/me/password", { body })), + onSuccess: (me) => qc.setQueryData(qk.me, me), + }); +} + +// Привязка Telegram к аккаунту: тот же payload виджета, что при входе; ник не меняется. +export function useLinkTelegram() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Record): Promise => + unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })), + onSuccess: (me) => qc.setQueryData(qk.me, me), + }); +} + // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. // При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED — // тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета). diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index e4e8aa9..123d5b0 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -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() { Сохранить +
+ Никнейм — это и логин для входа: после смены входите под новым. +
@@ -256,9 +264,129 @@ export function AccountPage() { + + ); } + +// Вход в аккаунт: смена пароля и привязка 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(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) => { + 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 ( +
+

Вход в аккаунт

+
+ {/* Скрытый логин — чтобы менеджер паролей обновил сохранённую пару. */} + + {me.has_password && ( +
+ + setCurrent(e.target.value)} + /> +
+ )} +
+ + setNext(e.target.value)} + /> +
+
+ + setRepeat(e.target.value)} + /> +
+ {error &&

{error}

} + +
+ +
+ + {me.telegram_id != null ? ( +
Привязан — можно входить и через Telegram.
+ ) : telegramReady ? ( + <> +

+ Привяжите Telegram, чтобы входить и через него. Никнейм не изменится. +

+ onTelegram(u as unknown as Record)} + /> + + ) : ( +
Вход через Telegram не настроен.
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index f2cc532..6f821be 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -9,6 +9,8 @@ import { useCreateDevUser, useDevUsers, useMe, + usePasswordLogin, + useRegister, useStubLogin, useTelegramLogin, useTelegramRegister, @@ -28,6 +30,7 @@ export function LoginPage() { const [pendingTg, setPendingTg] = useState(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 && ( -
-

Вход через Telegram

- {config?.telegram_bot_username ? ( - onTelegram(u as unknown as Record)} - /> - ) : ( -

- Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN - в .env (бот от @BotFather + /setdomain на ваш домен). + <> + {hasPassword && navigate("/", { replace: true })} />} + {hasTelegram && ( +

+

Вход через Telegram

+ {config?.telegram_bot_username ? ( + onTelegram(u as unknown as Record)} + /> + ) : ( +

+ Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN + в .env (бот от @BotFather + /setdomain на ваш домен). +

+ )} +

+ Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле — + иначе вход через Telegram создаст новый аккаунт.

- )} -
- ) +
+ )} + )} {showStub && !pendingTg && } @@ -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(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 ( +
+

{isRegister ? "Регистрация" : "Вход"}

+
+ + setNickname(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> + {isRegister &&
Не короче 8 символов.
} +
+ {isRegister && ( +
+ + setRepeat(e.target.value)} + /> +
+ )} + {error &&

{error}

} + + +
+ ); +} + // Выбор ника при регистрации через Telegram, когда тег занят/некорректен. // Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново). function TelegramNicknamePicker({ diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 681589f..6997a06 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -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(null); const [editName, setEditName] = useState(""); + // Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование. + const [passwordId, setPasswordId] = useState(null); + const [newPassword, setNewPassword] = useState(""); if (isLoading) return ; 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() { Отмена + ) : passwordId === u.id ? ( + // Режим «задать пароль». +
+ setNewPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") savePassword(); + if (e.key === "Escape") setPasswordId(null); + }} + /> + + +
) : (
{u.nickname} {u.role === "admin" && админ}
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"} + {u.role !== "admin" && !u.has_password && " · без пароля"}
{u.role !== "admin" && ( @@ -90,6 +140,13 @@ export function AdminAccountsPage() { > + {import.meta.env.DEV && ( )}