Фронт: вход по паролю, окно установки пароля, профиль и админка
Страница входа начинается с карточки «Вход» (ник и пароль), в ней же переключатель на регистрацию с повтором пароля. 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:
Vendored
+303
@@ -21,6 +21,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/auth/telegram": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -122,6 +162,46 @@ export interface paths {
|
|||||||
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
||||||
trace?: never;
|
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": {
|
"/api/users/me/avatar": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -844,6 +924,26 @@ export interface paths {
|
|||||||
patch: operations["update_user_api_admin_users__user_id__patch"];
|
patch: operations["update_user_api_admin_users__user_id__patch"];
|
||||||
trace?: never;
|
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": {
|
"/api/admin/groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1224,6 +1324,11 @@ export interface components {
|
|||||||
/** Role */
|
/** Role */
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
|
/** AdminPasswordSet */
|
||||||
|
AdminPasswordSet: {
|
||||||
|
/** New Password */
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
/** AdminUserRead */
|
/** AdminUserRead */
|
||||||
AdminUserRead: {
|
AdminUserRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1240,6 +1345,11 @@ export interface components {
|
|||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
/** Created At */
|
/** Created At */
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Has Password
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_password: boolean;
|
||||||
};
|
};
|
||||||
/** AdminUserUpdate */
|
/** AdminUserUpdate */
|
||||||
AdminUserUpdate: {
|
AdminUserUpdate: {
|
||||||
@@ -1887,6 +1997,11 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
groups: components["schemas"]["GroupBrief"][];
|
groups: components["schemas"]["GroupBrief"][];
|
||||||
|
/**
|
||||||
|
* Has Password
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_password: boolean;
|
||||||
};
|
};
|
||||||
/** MemberAdd */
|
/** MemberAdd */
|
||||||
MemberAdd: {
|
MemberAdd: {
|
||||||
@@ -1990,6 +2105,27 @@ export interface components {
|
|||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
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 */
|
||||||
ProfileStats: {
|
ProfileStats: {
|
||||||
/** User Id */
|
/** 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: {
|
telegram_login_api_auth_telegram_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
upload_my_avatar_api_users_me_avatar_put: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
list_groups_api_admin_groups_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
|||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
import { NotificationToaster } from "./NotificationToaster";
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
|
import { SetPasswordDialog } from "./SetPasswordDialog";
|
||||||
import { SideMenu } from "./SideMenu";
|
import { SideMenu } from "./SideMenu";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
@@ -51,6 +52,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||||
|
|
||||||
|
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
||||||
|
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
interface AchievementInput {
|
||||||
|
|||||||
@@ -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<Me> =>
|
||||||
|
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<string, unknown>): Promise<Me> =>
|
||||||
|
unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })),
|
||||||
|
onSuccess: (me) => qc.setQueryData(qk.me, me),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||||||
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
||||||
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect";
|
|||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { Switch } from "../components/Switch";
|
import { Switch } from "../components/Switch";
|
||||||
|
import { TelegramLoginButton } from "../components/TelegramLoginButton";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import type { Me } from "../domain/types";
|
||||||
import {
|
import {
|
||||||
|
useAuthConfig,
|
||||||
useDeleteAvatar,
|
useDeleteAvatar,
|
||||||
|
useLinkTelegram,
|
||||||
useLogout,
|
useLogout,
|
||||||
useMe,
|
useMe,
|
||||||
|
useSetPassword,
|
||||||
useUpdateBio,
|
useUpdateBio,
|
||||||
useUpdateFavoriteFaction,
|
useUpdateFavoriteFaction,
|
||||||
useUpdateHistoryPrefs,
|
useUpdateHistoryPrefs,
|
||||||
@@ -218,6 +223,9 @@ export function AccountPage() {
|
|||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 6 }}>
|
||||||
|
Никнейм — это и логин для входа: после смены входите под новым.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,9 +264,129 @@ export function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LoginSettingsCard me={me} />
|
||||||
|
|
||||||
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
useCreateDevUser,
|
useCreateDevUser,
|
||||||
useDevUsers,
|
useDevUsers,
|
||||||
useMe,
|
useMe,
|
||||||
|
usePasswordLogin,
|
||||||
|
useRegister,
|
||||||
useStubLogin,
|
useStubLogin,
|
||||||
useTelegramLogin,
|
useTelegramLogin,
|
||||||
useTelegramRegister,
|
useTelegramRegister,
|
||||||
@@ -28,6 +30,7 @@ export function LoginPage() {
|
|||||||
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
||||||
|
|
||||||
const methods = config?.methods ?? [];
|
const methods = config?.methods ?? [];
|
||||||
|
const hasPassword = methods.includes("password");
|
||||||
const hasTelegram = methods.includes("telegram");
|
const hasTelegram = methods.includes("telegram");
|
||||||
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
||||||
// import.meta.env.DEV === false → блок вырезается из бандла).
|
// import.meta.env.DEV === false → блок вырезается из бандла).
|
||||||
@@ -72,22 +75,29 @@ export function LoginPage() {
|
|||||||
onDone={() => navigate("/", { replace: true })}
|
onDone={() => navigate("/", { replace: true })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
hasTelegram && (
|
<>
|
||||||
<div className="card">
|
{hasPassword && <PasswordAuthCard onDone={() => navigate("/", { replace: true })} />}
|
||||||
<h3>Вход через Telegram</h3>
|
{hasTelegram && (
|
||||||
{config?.telegram_bot_username ? (
|
<div className="card">
|
||||||
<TelegramLoginButton
|
<h3>Вход через Telegram</h3>
|
||||||
botUsername={config.telegram_bot_username}
|
{config?.telegram_bot_username ? (
|
||||||
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
<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 className="muted small">
|
||||||
|
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
||||||
|
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||||
|
Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле —
|
||||||
|
иначе вход через Telegram создаст новый аккаунт.
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
{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, когда тег занят/некорректен.
|
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
||||||
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
||||||
function TelegramNicknamePicker({
|
function TelegramNicknamePicker({
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Pencil } from "lucide-react";
|
import { KeyRound, Pencil } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
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.meta.env.DEV,
|
||||||
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||||
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||||
@@ -12,17 +12,40 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
|||||||
export function AdminAccountsPage() {
|
export function AdminAccountsPage() {
|
||||||
const { data: users, isLoading } = useAdminUsers();
|
const { data: users, isLoading } = useAdminUsers();
|
||||||
const update = useAdminUpdateUser();
|
const update = useAdminUpdateUser();
|
||||||
|
const setPassword = useAdminSetPassword();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
// Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование.
|
||||||
|
const [passwordId, setPasswordId] = useState<number | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
const startEdit = (id: number, nickname: string) => {
|
const startEdit = (id: number, nickname: string) => {
|
||||||
|
setPasswordId(null);
|
||||||
setEditId(id);
|
setEditId(id);
|
||||||
setEditName(nickname);
|
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 saveName = async () => {
|
||||||
const name = editName.trim();
|
const name = editName.trim();
|
||||||
if (editId == null || !name) return;
|
if (editId == null || !name) return;
|
||||||
@@ -73,12 +96,39 @@ export function AdminAccountsPage() {
|
|||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
||||||
|
{u.role !== "admin" && !u.has_password && " · без пароля"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{u.role !== "admin" && (
|
{u.role !== "admin" && (
|
||||||
@@ -90,6 +140,13 @@ export function AdminAccountsPage() {
|
|||||||
>
|
>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
title="Задать пароль"
|
||||||
|
onClick={() => startPassword(u.id)}
|
||||||
|
>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
</button>
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user