Implement username change functionality and enhance Telegram bot registration flow
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- 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:
Leonid Pershin
2026-07-02 18:57:36 +03:00
parent 1452e5c4af
commit cf3d8fcad8
19 changed files with 346 additions and 22 deletions
@@ -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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
{
var identityService = services.GetRequiredService<IIdentityService>();