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:
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
+9
-1
@@ -90,6 +90,13 @@
|
||||
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
||||
- Домен: поля 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_<token>`/`login_<requestId>` 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 в проекте не используется).
|
||||
- Балансировка/выбор оптимальной ноды, автоскейл.
|
||||
|
||||
+35
-7
@@ -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_<n>`) + смену пароля в настройках на сайте.
|
||||
- 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-сообщением (`<code>`).
|
||||
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`), дописывая ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
|
||||
убирая кнопку — не плодит отдельное сообщение с сырым 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 |
|
||||
|
||||
|
||||
+1
-2
@@ -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 — только привязка существующего).
|
||||
- Тарифы/биллинг/платежи.
|
||||
- Многоуровневые квоты, автопродление, промокоды.
|
||||
- Балансировка нагрузки между нодами, автоскейл.
|
||||
|
||||
@@ -22,6 +22,10 @@ export function changePassword(currentPassword: string, newPassword: string) {
|
||||
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() {
|
||||
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,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() {
|
||||
<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>
|
||||
<ChangePasswordForm />
|
||||
<ChangeUserNameForm />
|
||||
<TelegramLinkCard />
|
||||
<DeleteAccountSection />
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user