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:
@@ -1,6 +1,7 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Auth.ChangePassword;
|
||||
using PnvPanel.Application.Auth.ChangeUserName;
|
||||
using PnvPanel.Application.Auth.DeleteMyAccount;
|
||||
using PnvPanel.Application.Auth.Login;
|
||||
using PnvPanel.Application.Auth.Logout;
|
||||
@@ -26,6 +27,7 @@ public static class AuthEndpoints
|
||||
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
|
||||
group.MapPost("/logout", Logout).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.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
@@ -82,6 +84,12 @@ public static class AuthEndpoints
|
||||
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)
|
||||
{
|
||||
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class PnvBotUpdateHandler(
|
||||
else if (payload.StartsWith("login_", StringComparison.Ordinal))
|
||||
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
|
||||
else
|
||||
await SendWelcomeAsync(botClient, chatId, cancellationToken);
|
||||
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public sealed class PnvBotUpdateHandler(
|
||||
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
break;
|
||||
case "/help":
|
||||
await SendWelcomeAsync(botClient, chatId, cancellationToken);
|
||||
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
|
||||
@@ -104,6 +104,12 @@ public sealed class PnvBotUpdateHandler(
|
||||
if (data is null || chatId is null)
|
||||
return;
|
||||
|
||||
if (data == "reg:new")
|
||||
{
|
||||
await HandleRegisterCallbackAsync(botClient, services, chatId.Value, fromId, callback.From.Username, callback.Id, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = data.Split(':');
|
||||
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
||||
return;
|
||||
@@ -159,10 +165,19 @@ public sealed class PnvBotUpdateHandler(
|
||||
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
|
||||
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId.Value,
|
||||
linkResult.IsSuccess ? linkResult.Value.ConnectionString : $"Не удалось получить ссылку: {linkResult.Error.Message}",
|
||||
cancellationToken: cancellationToken);
|
||||
if (!linkResult.IsSuccess)
|
||||
{
|
||||
await botClient.SendMessage(chatId.Value, $"Не удалось получить ссылку: {linkResult.Error.Message}", 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;
|
||||
}
|
||||
@@ -196,7 +211,10 @@ public sealed class PnvBotUpdateHandler(
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -216,7 +234,10 @@ public sealed class PnvBotUpdateHandler(
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
+ "/configs — мои конфиги\n"
|
||||
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
|
||||
+ "/unlink — отвязать Telegram\n"
|
||||
+ "/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)
|
||||
{
|
||||
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> ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от
|
||||
/// 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 =
|
||||
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))));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
|
||||
Reference in New Issue
Block a user