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
@@ -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);
}
}
@@ -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.");
}