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
+5 -2
View File
@@ -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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
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);
}
}
@@ -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());
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 — только привязка существующего).
- Тарифы/биллинг/платежи. - Тарифы/биллинг/платежи.
- Многоуровневые квоты, автопродление, промокоды. - Многоуровневые квоты, автопродление, промокоды.
- Балансировка нагрузки между нодами, автоскейл. - Балансировка нагрузки между нодами, автоскейл.
+4
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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',