diff --git a/CLAUDE.md b/CLAUDE.md index ed6fb4c..b76e9a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,8 +115,11 @@ long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender` (`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). - `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`. -- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram - (в MVP — только привязка существующего аккаунта, регистрация из бота — backlog). +- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram — + либо привязка существующего аккаунта (с сайта), либо регистрация прямо из бота (`RegisterViaTelegramCommand`): + логин — Telegram `@username`, при отсутствии/занятости — Telegram id; пароль генерируется и + присылается в чат один раз (логин можно сменить позже в Настройках, `ChangeUserNameCommand`). + Новый аккаунт получает роль `user` и `IsActivated = false` — активация нужна как обычно. - Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет, не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует). - Детали флоу — [telegram-bot.md](docs/telegram-bot.md). diff --git a/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs index e72a26d..83db3d9 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs @@ -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(); 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(); group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent); @@ -82,6 +84,12 @@ public static class AuthEndpoints return result.ToHttpResult(); } + private static async Task ChangeUserName(ChangeUserNameCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + private static async Task Me(ISender sender, CancellationToken cancellationToken) { var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken); diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 5adfdc5..c015a63 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -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{Escape(linkResult.Value.ConnectionString)}"; + + 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(); + 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(); + 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" + + $"Логин: {result.Value.UserName}\n" + + $"Пароль: {result.Value.Password}\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 TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken) { var identityService = services.GetRequiredService(); diff --git a/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs b/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs new file mode 100644 index 0000000..45839b3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs new file mode 100644 index 0000000..b6eebaa --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommandHandler.cs @@ -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 +{ + public Task 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); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs new file mode 100644 index 0000000..e5ba4fa --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangeUserName/ChangeUserNameCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.ChangeUserName; + +public sealed class ChangeUserNameCommandValidator : AbstractValidator +{ + public ChangeUserNameCommandValidator() + { + RuleFor(x => x.NewUserName) + .NotEmpty() + .Length(3, 32) + .Matches("^[a-zA-Z0-9_.-]+$") + .WithMessage("Имя пользователя может содержать только латиницу, цифры, '_', '.', '-'."); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 449dc38..831b48c 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -23,6 +23,8 @@ public interface IIdentityService Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken); + Task ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken); + /// /// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от /// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется diff --git a/backend/src/PnvPanel.Application/Telegram/RegisterViaTelegramCommand.cs b/backend/src/PnvPanel.Application/Telegram/RegisterViaTelegramCommand.cs new file mode 100644 index 0000000..75461d5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/RegisterViaTelegramCommand.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram; + +/// Вызывается только из TelegramBotHostedService (обработка кнопки "Зарегистрироваться"). +public sealed record RegisterViaTelegramCommand(long TelegramUserId, string? TelegramUsername) + : ICommand>; + +public sealed record RegisterViaTelegramResult(Guid UserId, string UserName, string Password); diff --git a/backend/src/PnvPanel.Application/Telegram/RegisterViaTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/RegisterViaTelegramCommandHandler.cs new file mode 100644 index 0000000..c4ba81b --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/RegisterViaTelegramCommandHandler.cs @@ -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> +{ + public async Task> Handle( + RegisterViaTelegramCommand command, CancellationToken cancellationToken) + { + var existingUserId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken); + if (existingUserId is not null) + return Result.Failure(TelegramErrors.AlreadyLinked); + + var password = GeneratePassword(); + + // Сначала пробуем @username из Telegram как логин; если его нет или он уже занят на сайте — + // используем Telegram id (гарантированно уникален). Логин потом можно сменить в настройках. + var userName = command.TelegramUsername; + var createResult = string.IsNullOrWhiteSpace(userName) + ? Result.Failure(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(createResult.Error); + } + + await identityService.LinkTelegramAsync(createResult.Value, command.TelegramUserId, command.TelegramUsername, cancellationToken); + + return Result.Success(new RegisterViaTelegramResult(createResult.Value, userName!, password)); + } + + /// + /// Гарантирует заглавную букву, строчную и цифру (см. Password.Require* в DependencyInjection.cs), + /// без спецсимволов (RequireNonAlphanumeric отключён) — присылается пользователю текстом в Telegram. + /// + 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 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); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs index 7570912..c22dada 100644 --- a/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs +++ b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs @@ -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."); } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index 3d7f395..3866edc 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -77,6 +77,23 @@ internal sealed class IdentityService(UserManager userManager, SignInMa string.Join("; ", result.Errors.Select(e => e.Description)))); } + public async Task 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 ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); diff --git a/docs/api-design.md b/docs/api-design.md index 4d4083d..4a07b10 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -19,6 +19,7 @@ REST поверх HTTP/JSON, авторизация — `Authorization: Bearer < | POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie | | POST | `/api/auth/logout` | user | — | `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 }` | | DELETE| `/api/auth/me` | user | — | `204 No Content` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 24925a5..88dfc18 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -90,6 +90,13 @@ - Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions`. - Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`. - Флоу привязки (`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}`). - Команды бота: `/start` (+ `link_`/`login_` deep-link payload), «Мои конфиги» (`/configs` — по сообщению на конфиг, с inline-кнопкой «🔗 Показать ссылку», раскрывающей connection @@ -119,7 +126,8 @@ ## Backlog (после MVP) - Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only. -- Полная регистрация аккаунта через Telegram (в MVP — только привязка); Telegram Login Widget как альтернатива. +- Telegram Login Widget как альтернатива кнопке-боту (сама регистрация/вход через бота уже реализованы — + см. M7 и [telegram-bot.md](telegram-bot.md)). - Тарифы/биллинг/платежи, автопродление, промокоды. - Реферальная программа; расширенные уведомления (через Telegram/веб — email в проекте не используется). - Балансировка/выбор оптимальной ноды, автоскейл. diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 265ca93..d305822 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -18,13 +18,18 @@ Telegram-бот — **второй канал доставки** (presentation- 4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка, принудительный отзыв конфига админом. 5. **Отвязка** — `/unlink`. +6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен + привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless- + входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется + Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните + его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает + роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте. **Не реализовано / backlog:** - QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). - Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт только через обычный passwordless-вход (`/start login_`) + смену пароля в настройках на сайте. - Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет. -- Регистрация нового аккаунта из бота (только привязка существующего). - Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только просмотр списка и показ существующей ссылки по кнопке). @@ -92,10 +97,30 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl `refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая `Secure = request.IsHttps`). Запрос помечается `Consumed`. -Если Telegram **не привязан** — бот сразу сообщает «Сначала привяжите Telegram к аккаунту на сайте», -подтвердить вход невозможно. Регистрация целиком через Telegram — вне MVP. +Если Telegram **не привязан** — подтвердить вход невозможно; бот присылает `NotLinkedMessage` +(«Сначала зарегистрируйтесь и войдите на сайте, затем привяжите 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-сообщением (``). +4. Сразу после создания — `identityService.LinkTelegramAsync(...)`, аккаунт уже привязан, без + отдельного шага как во Флоу 1. +5. Новый аккаунт — роль `user`, `IsActivated = false`: активация нужна как для обычной регистрации на + сайте, `/configs` будет недоступен до неё. +6. Логин можно сменить в Настройках на сайте (`ChangeUserNameCommand`, `POST /api/auth/change-username`) + — актуально, если логином стал Telegram id. + +## Флоу 4 — Просмотр конфигов в боте 1. Привязанный пользователь: `/configs`. 2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`. @@ -103,10 +128,12 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет — «У вас пока нет конфигов.» 4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что - эндпоинт `/api/configs/{id}/link`) от текущего пользователя и присылает connection string отдельным - сообщением. Ссылка не дублируется никуда до явного нажатия. QR-картинки нет — только текст. + эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение** + (`EditMessageText`), дописывая ссылку моноширинным блоком (``, тап = копирование целиком) и + убирая кнопку — не плодит отдельное сообщение с сырым URL. Ссылка не раскрывается нигде до явного + нажатия. QR-картинки нет — только текст. -## Флоу 4 — Обработка активации админом в боте +## Флоу 5 — Обработка активации админом в боте 1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) → `RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает @@ -133,6 +160,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да | | `/unlink` | Отвязать Telegram от аккаунта | да | | `/help` | Справка (то же сообщение, что `/start`) | нет | +| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) | | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env | diff --git a/docs/vision.md b/docs/vision.md index 66f2bf7..4e1b61f 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -107,13 +107,12 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует - Создание/просмотр/отзыв конфигов пользователем (проверки активации, квоты, доступа роли к инбаунду); ссылка-подписка + QR. - Синхронизация трафика (фоновая) + realtime-обновления по SignalR. - Базовая статистика для админа. -- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка Telegram и passwordless-вход. +- Telegram-бот: ссылка на сайт, просмотр конфигов, привязка/регистрация через Telegram и passwordless-вход. - Светлая/тёмная тема сайта. - Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано). - Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose. **За рамками MVP (backlog):** -- Полная регистрация аккаунта через Telegram (в MVP — только привязка существующего). - Тарифы/биллинг/платежи. - Многоуровневые квоты, автопродление, промокоды. - Балансировка нагрузки между нодами, автоскейл. diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts index ee9c3fe..e8ebdef 100644 --- a/frontend/src/features/auth/api.ts +++ b/frontend/src/features/auth/api.ts @@ -22,6 +22,10 @@ export function changePassword(currentPassword: string, newPassword: string) { return apiRequest('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } }) } +export function changeUserName(newUserName: string) { + return apiRequest('/auth/change-username', { method: 'POST', body: { newUserName } }) +} + export function deleteAccount() { return apiRequest('/auth/me', { method: 'DELETE' }) } diff --git a/frontend/src/features/settings/ChangeUserNameForm.tsx b/frontend/src/features/settings/ChangeUserNameForm.tsx new file mode 100644 index 0000000..55165cc --- /dev/null +++ b/frontend/src/features/settings/ChangeUserNameForm.tsx @@ -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 + +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({ 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 ( + + + {t('settings.changeUserName')} + {user && {t('settings.changeUserNameHint', { current: user.userName })}} + + +
+
+ + + {errors.newUserName &&

{t('auth.userNameHint')}

} +
+ +
+
+
+ ) +} diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 5148e31..bfc8832 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useRequireAuth } from '@/features/auth/guards' import { ChangePasswordForm } from '@/features/settings/ChangePasswordForm' +import { ChangeUserNameForm } from '@/features/settings/ChangeUserNameForm' import { TelegramLinkCard } from '@/features/settings/TelegramLinkCard' import { DeleteAccountSection } from '@/features/settings/DeleteAccountSection' @@ -17,6 +18,7 @@ function SettingsPage() {

{t('nav.settings')}

+
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 12a76a6..cfaf84b 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -124,6 +124,10 @@ const resources = { currentPasswordInvalid: 'Неверный текущий пароль.', newPassword: 'Новый пароль', passwordChanged: 'Пароль изменён.', + changeUserName: 'Сменить логин', + changeUserNameHint: 'Текущий логин: {{current}}.', + newUserName: 'Новый логин', + userNameChanged: 'Логин изменён.', telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.', telegramLinkedStatus: 'Привязан', link: 'Привязать Telegram', @@ -391,6 +395,10 @@ const resources = { currentPasswordInvalid: 'Current password is incorrect.', newPassword: 'New password', 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.', telegramLinkedStatus: 'Linked', link: 'Link Telegram',