Enhance activation request validation and documentation
CI / Backend (build + test) (push) Failing after 1m37s
CI / Frontend (lint + typecheck + build) (push) Successful in 44s

- Updated the `RequestActivationCommandValidator` to require a non-empty comment, ensuring that users provide necessary identification information.
- Modified integration tests to validate the new requirement for a comment, including a test for handling empty comments.
- Updated API documentation to reflect that the comment is now mandatory and clarified its purpose.
- Enhanced frontend components to enforce comment requirements and provide user guidance on the comment's importance.
This commit is contained in:
Leonid Pershin
2026-07-30 03:23:03 +03:00
parent a8358b930d
commit cc7e2a7f8f
8 changed files with 45 additions and 16 deletions
@@ -6,6 +6,7 @@ public sealed class RequestActivationCommandValidator : AbstractValidator<Reques
{
public RequestActivationCommandValidator()
{
RuleFor(x => x.Comment).MaximumLength(500);
// Комментарий обязателен: по нему админ понимает, кто заявитель и откуда.
RuleFor(x => x.Comment).NotEmpty().MaximumLength(500);
}
}
@@ -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<ActivationRequestResponse>();
@@ -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);
}
}
+1 -1
View File
@@ -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 (пользователь)
+1 -1
View File
@@ -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?` | |
+2 -1
View File
@@ -37,7 +37,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
### Активация пользователей
- После регистрации пользователь **не активирован** и не может создавать конфиги.
- Он отправляет **запрос на активацию** с комментарием (напр. «я Никита» — чтобы админ понял, кто это).
- Он отправляет **запрос на активацию** с обязательным комментарием — кто он и откуда (напр. «я Никита,
коллега Артёма»), чтобы админ понял, кто это.
- Админ одобряет/отклоняет запрос **на сайте или в Telegram**. После одобрения — доступно создание конфигов.
### Аутентификация и восстановление доступа
@@ -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 }) {
<Label htmlFor="comment">{t('activation.commentLabel')}</Label>
<textarea
id="comment"
className="min-h-24 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
required
aria-required="true"
maxLength={500}
placeholder={t('activation.commentPlaceholder')}
className="min-h-24 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('activation.commentHint')}</p>
</div>
<Button onClick={handleSubmit} disabled={submitting}>
<Button onClick={handleSubmit} disabled={submitting || !trimmedComment}>
{t('activation.submit')}
</Button>
</>
+2 -2
View File
@@ -5,6 +5,6 @@ export function getActivationStatus() {
return apiRequest<ActivationStatusDto>('/activation/status')
}
export function requestActivation(comment: string | undefined) {
return apiRequest<ActivationRequestDto>('/activation/request', { method: 'POST', body: { comment: comment ?? null } })
export function requestActivation(comment: string) {
return apiRequest<ActivationRequestDto>('/activation/request', { method: 'POST', body: { comment } })
}
+8 -4
View File
@@ -60,8 +60,10 @@ const resources = {
activation: {
title: 'Аккаунт не активирован',
description:
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
commentLabel: 'Комментарий (необязательно)',
'Чтобы создавать конфиги, дождитесь активации администратором. Обязательно напишите в комментарии, кто вы и откуда — иначе администратор не сможет вас опознать.',
commentLabel: 'Комментарий (обязательно)',
commentPlaceholder: 'Например: Никита, коллега Артёма с работы',
commentHint: 'Укажите, кто вы и откуда: имя и как связаны с администратором.',
submit: 'Запросить активацию',
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
@@ -652,8 +654,10 @@ const resources = {
activation: {
title: 'Account not activated',
description:
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
commentLabel: 'Comment (optional)',
'Wait for an administrator to activate your account before creating configs. You must state in the comment who you are and where you are from — otherwise the administrator cannot identify you.',
commentLabel: 'Comment (required)',
commentPlaceholder: 'For example: Nikita, Artems coworker',
commentHint: 'State who you are and where you are from: your name and how you know the administrator.',
submit: 'Request activation',
pending: 'Activation request sent, waiting for administrator review.',
alreadyPending: 'You already have a pending activation request.',