Implement username change functionality and enhance Telegram bot registration flow
- Added a new endpoint for changing usernames, allowing users to update their login credentials via the API. - Integrated username change functionality into the settings page, providing a user-friendly interface for this action. - Enhanced the Telegram bot to support user registration directly through the bot, including username generation and password delivery. - Updated documentation to reflect the new username change endpoint and registration flow through the Telegram bot.
This commit is contained in:
@@ -115,8 +115,11 @@
|
|||||||
long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender`
|
long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender`
|
||||||
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...).
|
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...).
|
||||||
- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`.
|
- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`.
|
||||||
- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram
|
- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram —
|
||||||
(в MVP — только привязка существующего аккаунта, регистрация из бота — backlog).
|
либо привязка существующего аккаунта (с сайта), либо регистрация прямо из бота (`RegisterViaTelegramCommand`):
|
||||||
|
логин — Telegram `@username`, при отсутствии/занятости — Telegram id; пароль генерируется и
|
||||||
|
присылается в чат один раз (логин можно сменить позже в Настройках, `ChangeUserNameCommand`).
|
||||||
|
Новый аккаунт получает роль `user` и `IsActivated = false` — активация нужна как обычно.
|
||||||
- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет,
|
- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет,
|
||||||
не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует).
|
не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует).
|
||||||
- Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
|
- Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Auth.ChangePassword;
|
using PnvPanel.Application.Auth.ChangePassword;
|
||||||
|
using PnvPanel.Application.Auth.ChangeUserName;
|
||||||
using PnvPanel.Application.Auth.DeleteMyAccount;
|
using PnvPanel.Application.Auth.DeleteMyAccount;
|
||||||
using PnvPanel.Application.Auth.Login;
|
using PnvPanel.Application.Auth.Login;
|
||||||
using PnvPanel.Application.Auth.Logout;
|
using PnvPanel.Application.Auth.Logout;
|
||||||
@@ -26,6 +27,7 @@ public static class AuthEndpoints
|
|||||||
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
|
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
|
||||||
group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||||
group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||||
|
group.MapPost("/change-username", ChangeUserName).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||||
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
|
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
|
||||||
group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
@@ -82,6 +84,12 @@ public static class AuthEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ChangeUserName(ChangeUserNameCommand command, ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
|
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
else if (payload.StartsWith("login_", StringComparison.Ordinal))
|
else if (payload.StartsWith("login_", StringComparison.Ordinal))
|
||||||
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
|
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
|
||||||
else
|
else
|
||||||
await SendWelcomeAsync(botClient, chatId, cancellationToken);
|
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||||
break;
|
break;
|
||||||
case "/help":
|
case "/help":
|
||||||
await SendWelcomeAsync(botClient, chatId, cancellationToken);
|
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
|
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
|
||||||
@@ -104,6 +104,12 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
if (data is null || chatId is null)
|
if (data is null || chatId is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (data == "reg:new")
|
||||||
|
{
|
||||||
|
await HandleRegisterCallbackAsync(botClient, services, chatId.Value, fromId, callback.From.Username, callback.Id, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var parts = data.Split(':');
|
var parts = data.Split(':');
|
||||||
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
||||||
return;
|
return;
|
||||||
@@ -159,10 +165,19 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
|
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
|
||||||
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
await botClient.SendMessage(
|
if (!linkResult.IsSuccess)
|
||||||
chatId.Value,
|
{
|
||||||
linkResult.IsSuccess ? linkResult.Value.ConnectionString : $"Не удалось получить ссылку: {linkResult.Error.Message}",
|
await botClient.SendMessage(chatId.Value, $"Не удалось получить ссылку: {linkResult.Error.Message}", cancellationToken: cancellationToken);
|
||||||
cancellationToken: cancellationToken);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||||
|
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
||||||
|
var originalText = callback.Message?.Text ?? "";
|
||||||
|
var text = $"{Escape(originalText)}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||||
|
|
||||||
|
if (callback.Message is not null)
|
||||||
|
await botClient.EditMessageText(chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -196,7 +211,10 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||||
if (userId is null)
|
if (userId is null)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken);
|
await botClient.SendMessage(
|
||||||
|
chatId, NotLinkedMessage,
|
||||||
|
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +234,10 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
{
|
{
|
||||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken);
|
await botClient.SendMessage(
|
||||||
|
chatId, NotLinkedMessage,
|
||||||
|
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,16 +310,53 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SendWelcomeAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
|
private static async Task SendWelcomeAsync(
|
||||||
|
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
const string text = "Привет! Это бот PnvPanel.\n\n"
|
const string text = "Привет! Это бот PnvPanel.\n\n"
|
||||||
+ "/configs — мои конфиги\n"
|
+ "/configs — мои конфиги\n"
|
||||||
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
|
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
|
||||||
+ "/unlink — отвязать Telegram\n"
|
+ "/unlink — отвязать Telegram\n"
|
||||||
+ "/help — эта справка";
|
+ "/help — эта справка";
|
||||||
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
|
|
||||||
|
var identityService = services.GetRequiredService<IIdentityService>();
|
||||||
|
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||||
|
|
||||||
|
// Уже привязанным аккаунту предлагать регистрацию незачем.
|
||||||
|
var keyboard = userId is null
|
||||||
|
? new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task HandleRegisterCallbackAsync(
|
||||||
|
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sender = services.GetRequiredService<ISender>();
|
||||||
|
var result = await sender.Send(new RegisterViaTelegramCommand(fromId, username), cancellationToken);
|
||||||
|
|
||||||
|
await botClient.AnswerCallbackQuery(callbackId, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(chatId, $"Не удалось зарегистрироваться: {result.Error.Message}", cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = "✅ Аккаунт создан.\n\n"
|
||||||
|
+ $"Логин: <code>{result.Value.UserName}</code>\n"
|
||||||
|
+ $"Пароль: <code>{result.Value.Password}</code>\n\n"
|
||||||
|
+ "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n"
|
||||||
|
+ "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. "
|
||||||
|
+ "Входить можно как по паролю, так и кнопкой «Войти через Telegram».";
|
||||||
|
|
||||||
|
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
|
|
||||||
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
|
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var identityService = services.GetRequiredService<IIdentityService>();
|
var identityService = services.GetRequiredService<IIdentityService>();
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Auth.ChangeUserName;
|
||||||
|
|
||||||
|
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Auth.ChangeUserName;
|
||||||
|
|
||||||
|
public sealed class ChangeUserNameCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
|
||||||
|
: ICommandHandler<ChangeUserNameCommand, Result>
|
||||||
|
{
|
||||||
|
public Task<Result> Handle(ChangeUserNameCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
|
||||||
|
|
||||||
|
return identityService.ChangeUserNameAsync(userId, command.NewUserName, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Auth.ChangeUserName;
|
||||||
|
|
||||||
|
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
|
||||||
|
{
|
||||||
|
public ChangeUserNameCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.NewUserName)
|
||||||
|
.NotEmpty()
|
||||||
|
.Length(3, 32)
|
||||||
|
.Matches("^[a-zA-Z0-9_.-]+$")
|
||||||
|
.WithMessage("Имя пользователя может содержать только латиницу, цифры, '_', '.', '-'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ public interface IIdentityService
|
|||||||
|
|
||||||
Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken);
|
Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<Result> ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от
|
/// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от
|
||||||
/// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется
|
/// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Telegram;
|
||||||
|
|
||||||
|
/// <summary>Вызывается только из TelegramBotHostedService (обработка кнопки "Зарегистрироваться").</summary>
|
||||||
|
public sealed record RegisterViaTelegramCommand(long TelegramUserId, string? TelegramUsername)
|
||||||
|
: ICommand<Result<RegisterViaTelegramResult>>;
|
||||||
|
|
||||||
|
public sealed record RegisterViaTelegramResult(Guid UserId, string UserName, string Password);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Telegram;
|
||||||
|
|
||||||
|
public sealed class RegisterViaTelegramCommandHandler(IIdentityService identityService)
|
||||||
|
: ICommandHandler<RegisterViaTelegramCommand, Result<RegisterViaTelegramResult>>
|
||||||
|
{
|
||||||
|
public async Task<Result<RegisterViaTelegramResult>> Handle(
|
||||||
|
RegisterViaTelegramCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existingUserId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken);
|
||||||
|
if (existingUserId is not null)
|
||||||
|
return Result.Failure<RegisterViaTelegramResult>(TelegramErrors.AlreadyLinked);
|
||||||
|
|
||||||
|
var password = GeneratePassword();
|
||||||
|
|
||||||
|
// Сначала пробуем @username из Telegram как логин; если его нет или он уже занят на сайте —
|
||||||
|
// используем Telegram id (гарантированно уникален). Логин потом можно сменить в настройках.
|
||||||
|
var userName = command.TelegramUsername;
|
||||||
|
var createResult = string.IsNullOrWhiteSpace(userName)
|
||||||
|
? Result.Failure<Guid>(AuthErrors.DuplicateUserName)
|
||||||
|
: await identityService.CreateUserAsync(userName, password, cancellationToken);
|
||||||
|
|
||||||
|
if (!createResult.IsSuccess)
|
||||||
|
{
|
||||||
|
userName = command.TelegramUserId.ToString();
|
||||||
|
createResult = await identityService.CreateUserAsync(userName, password, cancellationToken);
|
||||||
|
if (!createResult.IsSuccess)
|
||||||
|
return Result.Failure<RegisterViaTelegramResult>(createResult.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await identityService.LinkTelegramAsync(createResult.Value, command.TelegramUserId, command.TelegramUsername, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(new RegisterViaTelegramResult(createResult.Value, userName!, password));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Гарантирует заглавную букву, строчную и цифру (см. Password.Require* в DependencyInjection.cs),
|
||||||
|
/// без спецсимволов (RequireNonAlphanumeric отключён) — присылается пользователю текстом в Telegram.
|
||||||
|
/// </summary>
|
||||||
|
private static string GeneratePassword()
|
||||||
|
{
|
||||||
|
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // без O/I — легко спутать при ручном вводе
|
||||||
|
const string lower = "abcdefghijkmnpqrstuvwxyz";
|
||||||
|
const string digits = "23456789";
|
||||||
|
const string all = upper + lower + digits;
|
||||||
|
|
||||||
|
Span<char> buffer = stackalloc char[12];
|
||||||
|
buffer[0] = upper[RandomNumberGenerator.GetInt32(upper.Length)];
|
||||||
|
buffer[1] = lower[RandomNumberGenerator.GetInt32(lower.Length)];
|
||||||
|
buffer[2] = digits[RandomNumberGenerator.GetInt32(digits.Length)];
|
||||||
|
for (var i = 3; i < buffer.Length; i++)
|
||||||
|
buffer[i] = all[RandomNumberGenerator.GetInt32(all.Length)];
|
||||||
|
|
||||||
|
for (var i = buffer.Length - 1; i > 0; i--)
|
||||||
|
{
|
||||||
|
var j = RandomNumberGenerator.GetInt32(i + 1);
|
||||||
|
(buffer[i], buffer[j]) = (buffer[j], buffer[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,7 @@ public static class TelegramErrors
|
|||||||
|
|
||||||
public static readonly Error NotLinked =
|
public static readonly Error NotLinked =
|
||||||
Error.Conflict("Telegram.NotLinked", "Telegram не привязан ни к одному аккаунту.");
|
Error.Conflict("Telegram.NotLinked", "Telegram не привязан ни к одному аккаунту.");
|
||||||
|
|
||||||
|
public static readonly Error AlreadyLinked =
|
||||||
|
Error.Conflict("Telegram.AlreadyLinked", "Этот Telegram уже привязан к аккаунту — используйте вход через Telegram.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
|
|||||||
string.Join("; ", result.Errors.Select(e => e.Description))));
|
string.Join("; ", result.Errors.Select(e => e.Description))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result> ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||||
|
if (user is null)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var result = await userManager.SetUserNameAsync(user, newUserName);
|
||||||
|
if (result.Succeeded)
|
||||||
|
return Result.Success();
|
||||||
|
|
||||||
|
return result.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName))
|
||||||
|
? Result.Failure(AuthErrors.DuplicateUserName)
|
||||||
|
: Result.Failure(Error.Validation(
|
||||||
|
"Auth.UserNameChangeFailed",
|
||||||
|
string.Join("; ", result.Errors.Select(e => e.Description))));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result> ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken)
|
public async Task<Result> ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <
|
|||||||
| POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie |
|
| POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie |
|
||||||
| POST | `/api/auth/logout` | user | — | `204 No Content` |
|
| POST | `/api/auth/logout` | user | — | `204 No Content` |
|
||||||
| POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` |
|
| POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` |
|
||||||
|
| POST | `/api/auth/change-username` | user | `{ newUserName }` | `204 No Content` |
|
||||||
| GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` |
|
| GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` |
|
||||||
| DELETE| `/api/auth/me` | user | — | `204 No Content` |
|
| DELETE| `/api/auth/me` | user | — | `204 No Content` |
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -90,6 +90,13 @@
|
|||||||
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
||||||
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
||||||
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
||||||
|
- Регистрация прямо из бота (`RegisterViaTelegramCommand`, кнопка «📝 Зарегистрироваться» при `/start`
|
||||||
|
и в местах, где боту нужен привязанный аккаунт): логин — `@username` из Telegram, при отсутствии
|
||||||
|
или занятости — Telegram id; пароль генерируется (`RandomNumberGenerator`, гарантированы заглавная
|
||||||
|
буква/строчная/цифра под текущую политику пароля) и присылается в чат один раз. Новый аккаунт —
|
||||||
|
роль `user`, `IsActivated = false`, активация как у обычной регистрации. Логин можно сменить в
|
||||||
|
Настройках (`ChangeUserNameCommand`, `POST /api/auth/change-username`) — актуально, если логин
|
||||||
|
получился числовым (Telegram id).
|
||||||
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
||||||
- Команды бота: `/start` (+ `link_<token>`/`login_<requestId>` deep-link payload), «Мои конфиги»
|
- Команды бота: `/start` (+ `link_<token>`/`login_<requestId>` deep-link payload), «Мои конфиги»
|
||||||
(`/configs` — по сообщению на конфиг, с inline-кнопкой «🔗 Показать ссылку», раскрывающей connection
|
(`/configs` — по сообщению на конфиг, с inline-кнопкой «🔗 Показать ссылку», раскрывающей connection
|
||||||
@@ -119,7 +126,8 @@
|
|||||||
|
|
||||||
## Backlog (после MVP)
|
## Backlog (после MVP)
|
||||||
- Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only.
|
- Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only.
|
||||||
- Полная регистрация аккаунта через Telegram (в MVP — только привязка); Telegram Login Widget как альтернатива.
|
- Telegram Login Widget как альтернатива кнопке-боту (сама регистрация/вход через бота уже реализованы —
|
||||||
|
см. M7 и [telegram-bot.md](telegram-bot.md)).
|
||||||
- Тарифы/биллинг/платежи, автопродление, промокоды.
|
- Тарифы/биллинг/платежи, автопродление, промокоды.
|
||||||
- Реферальная программа; расширенные уведомления (через Telegram/веб — email в проекте не используется).
|
- Реферальная программа; расширенные уведомления (через Telegram/веб — email в проекте не используется).
|
||||||
- Балансировка/выбор оптимальной ноды, автоскейл.
|
- Балансировка/выбор оптимальной ноды, автоскейл.
|
||||||
|
|||||||
+35
-7
@@ -18,13 +18,18 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
||||||
принудительный отзыв конфига админом.
|
принудительный отзыв конфига админом.
|
||||||
5. **Отвязка** — `/unlink`.
|
5. **Отвязка** — `/unlink`.
|
||||||
|
6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
|
||||||
|
привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless-
|
||||||
|
входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется
|
||||||
|
Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните
|
||||||
|
его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает
|
||||||
|
роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте.
|
||||||
|
|
||||||
**Не реализовано / backlog:**
|
**Не реализовано / backlog:**
|
||||||
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
||||||
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
||||||
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
||||||
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
||||||
- Регистрация нового аккаунта из бота (только привязка существующего).
|
|
||||||
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
|
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
|
||||||
просмотр списка и показ существующей ссылки по кнопке).
|
просмотр списка и показ существующей ссылки по кнопке).
|
||||||
|
|
||||||
@@ -92,10 +97,30 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
|
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
|
||||||
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
|
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
|
||||||
|
|
||||||
Если Telegram **не привязан** — бот сразу сообщает «Сначала привяжите Telegram к аккаунту на сайте»,
|
Если Telegram **не привязан** — подтвердить вход невозможно; бот присылает `NotLinkedMessage`
|
||||||
подтвердить вход невозможно. Регистрация целиком через Telegram — вне MVP.
|
(«Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram...») с кнопкой
|
||||||
|
«📝 Зарегистрироваться» — см. Флоу 3.
|
||||||
|
|
||||||
## Флоу 3 — Просмотр конфигов в боте
|
## Флоу 3 — Регистрация прямо из бота
|
||||||
|
|
||||||
|
Показывается кнопкой «📝 Зарегистрироваться» везде, где боту нужен привязанный аккаунт, а его нет
|
||||||
|
(`/start`, `/help`, `/configs`, запрос passwordless-входа для непривязанного Telegram).
|
||||||
|
|
||||||
|
1. Нажатие → callback `reg:new` → `RegisterViaTelegramCommand(telegramUserId, telegramUsername)`.
|
||||||
|
2. Если `TelegramUserId` уже привязан к какому-то аккаунту — `TelegramErrors.AlreadyLinked`, регистрация
|
||||||
|
не создаёт второй аккаунт.
|
||||||
|
3. Логин: пробуем `@username` из Telegram (`identityService.CreateUserAsync`); если username пуст или
|
||||||
|
занят на сайте — используем `TelegramUserId.ToString()` (гарантированно уникален). Пароль генерируется
|
||||||
|
(`RandomNumberGenerator`, 12 символов, гарантированы заглавная/строчная буква и цифра под текущую
|
||||||
|
политику пароля) и присылается в чат **один раз**, отдельным HTML-сообщением (`<code>`).
|
||||||
|
4. Сразу после создания — `identityService.LinkTelegramAsync(...)`, аккаунт уже привязан, без
|
||||||
|
отдельного шага как во Флоу 1.
|
||||||
|
5. Новый аккаунт — роль `user`, `IsActivated = false`: активация нужна как для обычной регистрации на
|
||||||
|
сайте, `/configs` будет недоступен до неё.
|
||||||
|
6. Логин можно сменить в Настройках на сайте (`ChangeUserNameCommand`, `POST /api/auth/change-username`)
|
||||||
|
— актуально, если логином стал Telegram id.
|
||||||
|
|
||||||
|
## Флоу 4 — Просмотр конфигов в боте
|
||||||
|
|
||||||
1. Привязанный пользователь: `/configs`.
|
1. Привязанный пользователь: `/configs`.
|
||||||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
||||||
@@ -103,10 +128,12 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет —
|
inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет —
|
||||||
«У вас пока нет конфигов.»
|
«У вас пока нет конфигов.»
|
||||||
4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что
|
4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что
|
||||||
эндпоинт `/api/configs/{id}/link`) от текущего пользователя и присылает connection string отдельным
|
эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение**
|
||||||
сообщением. Ссылка не дублируется никуда до явного нажатия. QR-картинки нет — только текст.
|
(`EditMessageText`), дописывая ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
|
||||||
|
убирая кнопку — не плодит отдельное сообщение с сырым URL. Ссылка не раскрывается нигде до явного
|
||||||
|
нажатия. QR-картинки нет — только текст.
|
||||||
|
|
||||||
## Флоу 4 — Обработка активации админом в боте
|
## Флоу 5 — Обработка активации админом в боте
|
||||||
|
|
||||||
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) →
|
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) →
|
||||||
`RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
|
`RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
|
||||||
@@ -133,6 +160,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да |
|
| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да |
|
||||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
||||||
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
||||||
|
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
|
||||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -107,13 +107,12 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
|
|||||||
- Создание/просмотр/отзыв конфигов пользователем (проверки активации, квоты, доступа роли к инбаунду); ссылка-подписка + QR.
|
- Создание/просмотр/отзыв конфигов пользователем (проверки активации, квоты, доступа роли к инбаунду); ссылка-подписка + QR.
|
||||||
- Синхронизация трафика (фоновая) + realtime-обновления по SignalR.
|
- Синхронизация трафика (фоновая) + realtime-обновления по SignalR.
|
||||||
- Базовая статистика для админа.
|
- Базовая статистика для админа.
|
||||||
- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка Telegram и passwordless-вход.
|
- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка/регистрация через Telegram и passwordless-вход.
|
||||||
- Светлая/тёмная тема сайта.
|
- Светлая/тёмная тема сайта.
|
||||||
- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано).
|
- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано).
|
||||||
- Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose.
|
- Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose.
|
||||||
|
|
||||||
**За рамками MVP (backlog):**
|
**За рамками MVP (backlog):**
|
||||||
- Полная регистрация аккаунта через Telegram (в MVP — только привязка существующего).
|
|
||||||
- Тарифы/биллинг/платежи.
|
- Тарифы/биллинг/платежи.
|
||||||
- Многоуровневые квоты, автопродление, промокоды.
|
- Многоуровневые квоты, автопродление, промокоды.
|
||||||
- Балансировка нагрузки между нодами, автоскейл.
|
- Балансировка нагрузки между нодами, автоскейл.
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function changePassword(currentPassword: string, newPassword: string) {
|
|||||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function changeUserName(newUserName: string) {
|
||||||
|
return apiRequest<void>('/auth/change-username', { method: 'POST', body: { newUserName } })
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteAccount() {
|
export function deleteAccount() {
|
||||||
return apiRequest<void>('/auth/me', { method: 'DELETE' })
|
return apiRequest<void>('/auth/me', { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { changeUserName } from '@/features/auth/api'
|
||||||
|
import { useAuthStore } from '@/features/auth/store'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
newUserName: z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(32)
|
||||||
|
.regex(/^[a-zA-Z0-9_.-]+$/),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export function ChangeUserNameForm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const setUser = useAuthStore((s) => s.setUser)
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
await changeUserName(values.newUserName)
|
||||||
|
if (user) setUser({ ...user, userName: values.newUserName })
|
||||||
|
toast.success(t('settings.userNameChanged'))
|
||||||
|
reset()
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof HttpError && error.status === 409 ? t('auth.duplicateUserName') : t('auth.genericError')
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('settings.changeUserName')}</CardTitle>
|
||||||
|
{user && <CardDescription>{t('settings.changeUserNameHint', { current: user.userName })}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
||||||
|
<Input id="newUserName" autoComplete="username" {...register('newUserName')} />
|
||||||
|
{errors.newUserName && <p className="text-sm text-red-500">{t('auth.userNameHint')}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="self-start">
|
||||||
|
{t('settings.changeUserName')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useRequireAuth } from '@/features/auth/guards'
|
import { useRequireAuth } from '@/features/auth/guards'
|
||||||
import { ChangePasswordForm } from '@/features/settings/ChangePasswordForm'
|
import { ChangePasswordForm } from '@/features/settings/ChangePasswordForm'
|
||||||
|
import { ChangeUserNameForm } from '@/features/settings/ChangeUserNameForm'
|
||||||
import { TelegramLinkCard } from '@/features/settings/TelegramLinkCard'
|
import { TelegramLinkCard } from '@/features/settings/TelegramLinkCard'
|
||||||
import { DeleteAccountSection } from '@/features/settings/DeleteAccountSection'
|
import { DeleteAccountSection } from '@/features/settings/DeleteAccountSection'
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ function SettingsPage() {
|
|||||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('nav.settings')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('nav.settings')}</h1>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
<ChangeUserNameForm />
|
||||||
<TelegramLinkCard />
|
<TelegramLinkCard />
|
||||||
<DeleteAccountSection />
|
<DeleteAccountSection />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ const resources = {
|
|||||||
currentPasswordInvalid: 'Неверный текущий пароль.',
|
currentPasswordInvalid: 'Неверный текущий пароль.',
|
||||||
newPassword: 'Новый пароль',
|
newPassword: 'Новый пароль',
|
||||||
passwordChanged: 'Пароль изменён.',
|
passwordChanged: 'Пароль изменён.',
|
||||||
|
changeUserName: 'Сменить логин',
|
||||||
|
changeUserNameHint: 'Текущий логин: {{current}}.',
|
||||||
|
newUserName: 'Новый логин',
|
||||||
|
userNameChanged: 'Логин изменён.',
|
||||||
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
|
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
|
||||||
telegramLinkedStatus: 'Привязан',
|
telegramLinkedStatus: 'Привязан',
|
||||||
link: 'Привязать Telegram',
|
link: 'Привязать Telegram',
|
||||||
@@ -391,6 +395,10 @@ const resources = {
|
|||||||
currentPasswordInvalid: 'Current password is incorrect.',
|
currentPasswordInvalid: 'Current password is incorrect.',
|
||||||
newPassword: 'New password',
|
newPassword: 'New password',
|
||||||
passwordChanged: 'Password changed.',
|
passwordChanged: 'Password changed.',
|
||||||
|
changeUserName: 'Change username',
|
||||||
|
changeUserNameHint: 'Current username: {{current}}.',
|
||||||
|
newUserName: 'New username',
|
||||||
|
userNameChanged: 'Username changed.',
|
||||||
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
|
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
|
||||||
telegramLinkedStatus: 'Linked',
|
telegramLinkedStatus: 'Linked',
|
||||||
link: 'Link Telegram',
|
link: 'Link Telegram',
|
||||||
|
|||||||
Reference in New Issue
Block a user