diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs index d95ca34..056bcf7 100644 --- a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs @@ -6,6 +6,7 @@ public sealed class RequestActivationCommandValidator : AbstractValidator x.Comment).MaximumLength(500); + // Комментарий обязателен: по нему админ понимает, кто заявитель и откуда. + RuleFor(x => x.Comment).NotEmpty().MaximumLength(500); } } diff --git a/backend/tests/PnvPanel.IntegrationTests/Activation/ActivationFlowTests.cs b/backend/tests/PnvPanel.IntegrationTests/Activation/ActivationFlowTests.cs index 31aefb0..f9438ee 100644 --- a/backend/tests/PnvPanel.IntegrationTests/Activation/ActivationFlowTests.cs +++ b/backend/tests/PnvPanel.IntegrationTests/Activation/ActivationFlowTests.cs @@ -56,7 +56,7 @@ public class ActivationFlowTests(PnvPanelWebApplicationFactory factory) var requestResponse = await userClient.PostJsonAsync( "/api/activation/request", - new { comment = (string?)null } + new { comment = "Erin, a friend of the admin" } ); var request = await requestResponse.ReadAsAsync(); @@ -85,14 +85,29 @@ public class ActivationFlowTests(PnvPanelWebApplicationFactory factory) var first = await userClient.PostJsonAsync( "/api/activation/request", - new { comment = (string?)null } + new { comment = "Frank, a colleague of the admin" } ); Assert.Equal(HttpStatusCode.OK, first.StatusCode); var second = await userClient.PostJsonAsync( "/api/activation/request", - new { comment = (string?)null } + new { comment = "Frank, a colleague of the admin" } ); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } + + [Fact] + public async Task Request_WhenCommentEmpty_ReturnsBadRequest() + { + using var userClient = factory.CreateClient(); + var userName = $"gina_{Guid.NewGuid():N}"[..20]; + var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123"); + userClient.UseBearerToken(userToken); + + var response = await userClient.PostJsonAsync( + "/api/activation/request", + new { comment = (string?)null } + ); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } diff --git a/docs/api-design.md b/docs/api-design.md index b572dae..8b41e35 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -146,7 +146,7 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | Метод | Путь | Роль | Тело запроса | Тело ответа | | ----- | --------------------------- | ---- | ----------------- | -------------------------------------------------------- | | GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` | -| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` | +| POST | `/api/activation/request` | user | `{ comment }` (обязателен, ≤500) | `{ id, comment, createdAt }` | ## Billing (пользователь) diff --git a/docs/domain-model.md b/docs/domain-model.md index 846a236..73a8f17 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -706,7 +706,7 @@ Application-хендлере поверх результата `IIdentityService | ------------ | ----------------------- | ---------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (заявитель) | -| `Comment` | `string?` | Комментарий заявителя, напр. «я Никита» — чтобы админ понял, кто это | +| `Comment` | `string?` | Комментарий заявителя (кто и откуда), напр. «я Никита, коллега Артёма» — обязателен при создании заявки через API (`NotEmpty`, ≤500); nullable в схеме ради исторических записей | | `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` | | `DecidedBy` | `Guid?` | Админ, принявший решение | | `DecidedAt` | `DateTimeOffset?` | | diff --git a/docs/vision.md b/docs/vision.md index e6dbfcb..c2bd0ff 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -37,7 +37,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует ### Активация пользователей - После регистрации пользователь **не активирован** и не может создавать конфиги. -- Он отправляет **запрос на активацию** с комментарием (напр. «я Никита» — чтобы админ понял, кто это). +- Он отправляет **запрос на активацию** с обязательным комментарием — кто он и откуда (напр. «я Никита, + коллега Артёма»), чтобы админ понял, кто это. - Админ одобряет/отклоняет запрос **на сайте или в Telegram**. После одобрения — доступно создание конфигов. ### Аутентификация и восстановление доступа diff --git a/frontend/src/features/activation/ActivationGate.tsx b/frontend/src/features/activation/ActivationGate.tsx index 973f63c..5424eda 100644 --- a/frontend/src/features/activation/ActivationGate.tsx +++ b/frontend/src/features/activation/ActivationGate.tsx @@ -37,10 +37,13 @@ export function ActivationGate({ children }: { children: ReactNode }) { } if (data.isActivated) return <>{children} + const trimmedComment = comment.trim() + const handleSubmit = async () => { + if (!trimmedComment) return setSubmitting(true) try { - await requestActivation(comment.trim() || undefined) + await requestActivation(trimmedComment) await queryClient.invalidateQueries({ queryKey: ['activation-status'] }) } catch (error) { const message = error instanceof HttpError && error.status === 409 ? t('activation.alreadyPending') : t('auth.genericError') @@ -66,12 +69,17 @@ export function ActivationGate({ children }: { children: ReactNode }) {