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() 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( var requestResponse = await userClient.PostJsonAsync(
"/api/activation/request", "/api/activation/request",
new { comment = (string?)null } new { comment = "Erin, a friend of the admin" }
); );
var request = await requestResponse.ReadAsAsync<ActivationRequestResponse>(); var request = await requestResponse.ReadAsAsync<ActivationRequestResponse>();
@@ -85,14 +85,29 @@ public class ActivationFlowTests(PnvPanelWebApplicationFactory factory)
var first = await userClient.PostJsonAsync( var first = await userClient.PostJsonAsync(
"/api/activation/request", "/api/activation/request",
new { comment = (string?)null } new { comment = "Frank, a colleague of the admin" }
); );
Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var second = await userClient.PostJsonAsync( var second = await userClient.PostJsonAsync(
"/api/activation/request", "/api/activation/request",
new { comment = (string?)null } new { comment = "Frank, a colleague of the admin" }
); );
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); 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 }` | | 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 (пользователь) ## Billing (пользователь)
+1 -1
View File
@@ -706,7 +706,7 @@ Application-хендлере поверх результата `IIdentityService
| ------------ | ----------------------- | ---------------------------------------------------------- | | ------------ | ----------------------- | ---------------------------------------------------------- |
| `Id` | `Guid` | PK | | `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) | | `UserId` | `Guid` | FK → AppUser (заявитель) |
| `Comment` | `string?` | Комментарий заявителя, напр. «я Никита» — чтобы админ понял, кто это | | `Comment` | `string?` | Комментарий заявителя (кто и откуда), напр. «я Никита, коллега Артёма» — обязателен при создании заявки через API (`NotEmpty`, ≤500); nullable в схеме ради исторических записей |
| `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` | | `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` |
| `DecidedBy` | `Guid?` | Админ, принявший решение | | `DecidedBy` | `Guid?` | Админ, принявший решение |
| `DecidedAt` | `DateTimeOffset?` | | | `DecidedAt` | `DateTimeOffset?` | |
+2 -1
View File
@@ -37,7 +37,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
### Активация пользователей ### Активация пользователей
- После регистрации пользователь **не активирован** и не может создавать конфиги. - После регистрации пользователь **не активирован** и не может создавать конфиги.
- Он отправляет **запрос на активацию** с комментарием (напр. «я Никита» — чтобы админ понял, кто это). - Он отправляет **запрос на активацию** с обязательным комментарием — кто он и откуда (напр. «я Никита,
коллега Артёма»), чтобы админ понял, кто это.
- Админ одобряет/отклоняет запрос **на сайте или в Telegram**. После одобрения — доступно создание конфигов. - Админ одобряет/отклоняет запрос **на сайте или в Telegram**. После одобрения — доступно создание конфигов.
### Аутентификация и восстановление доступа ### Аутентификация и восстановление доступа
@@ -37,10 +37,13 @@ export function ActivationGate({ children }: { children: ReactNode }) {
} }
if (data.isActivated) return <>{children}</> if (data.isActivated) return <>{children}</>
const trimmedComment = comment.trim()
const handleSubmit = async () => { const handleSubmit = async () => {
if (!trimmedComment) return
setSubmitting(true) setSubmitting(true)
try { try {
await requestActivation(comment.trim() || undefined) await requestActivation(trimmedComment)
await queryClient.invalidateQueries({ queryKey: ['activation-status'] }) await queryClient.invalidateQueries({ queryKey: ['activation-status'] })
} catch (error) { } catch (error) {
const message = error instanceof HttpError && error.status === 409 ? t('activation.alreadyPending') : t('auth.genericError') 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> <Label htmlFor="comment">{t('activation.commentLabel')}</Label>
<textarea <textarea
id="comment" 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} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
/> />
<p className="text-xs text-muted-foreground">{t('activation.commentHint')}</p>
</div> </div>
<Button onClick={handleSubmit} disabled={submitting}> <Button onClick={handleSubmit} disabled={submitting || !trimmedComment}>
{t('activation.submit')} {t('activation.submit')}
</Button> </Button>
</> </>
+2 -2
View File
@@ -5,6 +5,6 @@ export function getActivationStatus() {
return apiRequest<ActivationStatusDto>('/activation/status') return apiRequest<ActivationStatusDto>('/activation/status')
} }
export function requestActivation(comment: string | undefined) { export function requestActivation(comment: string) {
return apiRequest<ActivationRequestDto>('/activation/request', { method: 'POST', body: { comment: comment ?? null } }) return apiRequest<ActivationRequestDto>('/activation/request', { method: 'POST', body: { comment } })
} }
+8 -4
View File
@@ -60,8 +60,10 @@ const resources = {
activation: { activation: {
title: 'Аккаунт не активирован', title: 'Аккаунт не активирован',
description: description:
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.', 'Чтобы создавать конфиги, дождитесь активации администратором. Обязательно напишите в комментарии, кто вы и откуда — иначе администратор не сможет вас опознать.',
commentLabel: 'Комментарий (необязательно)', commentLabel: 'Комментарий (обязательно)',
commentPlaceholder: 'Например: Никита, коллега Артёма с работы',
commentHint: 'Укажите, кто вы и откуда: имя и как связаны с администратором.',
submit: 'Запросить активацию', submit: 'Запросить активацию',
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.', pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
alreadyPending: 'У вас уже есть необработанная заявка на активацию.', alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
@@ -652,8 +654,10 @@ const resources = {
activation: { activation: {
title: 'Account not activated', title: 'Account not activated',
description: description:
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.', '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 (optional)', 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', submit: 'Request activation',
pending: 'Activation request sent, waiting for administrator review.', pending: 'Activation request sent, waiting for administrator review.',
alreadyPending: 'You already have a pending activation request.', alreadyPending: 'You already have a pending activation request.',