@seedgrid/fe-self-enter 2026.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +14 -0
- package/dist/module.json +7 -0
- package/dist/self-enter.d.ts +46 -0
- package/dist/self-enter.d.ts.map +1 -0
- package/dist/self-enter.js +5 -0
- package/dist/server/create-self-enter-server.d.ts +17 -0
- package/dist/server/create-self-enter-server.d.ts.map +1 -0
- package/dist/server/create-self-enter-server.js +27 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/package.json +43 -0
- package/scaffold/src/app/auth/self-enter/page.tsx +425 -0
- package/scaffold/src/app/self-enter/invitations/page.tsx +336 -0
- package/scaffold/src/app/self-enter/requests/to-approve/page.tsx +301 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @seedgrid/fe-self-enter
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@seedgrid/fe-self-enter)
|
|
4
|
+
[](https://www.npmjs.com/package/@seedgrid/fe-self-enter)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
|
|
7
|
+
Módulo front de **auto-cadastro de usuário** (self-enter) do SeedGrid: pedido público de entrada, convites de administrador e fila de aprovação. Não confundir com o registro de **empresa** (`fe-login-tenancy`/`fe-subdomain-tenancy`) — aqui é o cadastro de **usuário** (nome/e-mail/senha) numa conta já existente. Compõe sobre o motor de sessão do [`@seedgrid/fe-security`](https://www.npmjs.com/package/@seedgrid/fe-security).
|
|
8
|
+
|
|
9
|
+
## Sumário
|
|
10
|
+
|
|
11
|
+
- [Funcionalidades](#funcionalidades)
|
|
12
|
+
- [Telas entregues pelo scaffold](#telas-entregues-pelo-scaffold)
|
|
13
|
+
- [Instalação](#instalação)
|
|
14
|
+
- [Uso rápido](#uso-rápido)
|
|
15
|
+
- [Backend correspondente](#backend-correspondente)
|
|
16
|
+
- [Suporte](#suporte)
|
|
17
|
+
|
|
18
|
+
## Funcionalidades
|
|
19
|
+
|
|
20
|
+
Feature **opcional** com 3 modos no backend: `disabled` / `invite-only` / `public`.
|
|
21
|
+
|
|
22
|
+
- **Pedido público** — usuário pede acesso com nome/e-mail/senha; o pedido passa por confirmação de e-mail e aprovação (`PENDING_EMAIL` → `PENDING_APPROVAL` → `APPROVED`/`REJECTED`/`EXPIRED`).
|
|
23
|
+
- **Convites** — administrador convida por e-mail (`origin: INVITE`); o convidado completa o cadastro pelo token, com reenvio disponível.
|
|
24
|
+
- **Aprovação** — fila de pedidos pendentes com aprovar/rejeitar, registrando quem aprovou/rejeitou e quando.
|
|
25
|
+
|
|
26
|
+
O pacote exporta:
|
|
27
|
+
|
|
28
|
+
- DTOs client-safe: `CreateEntryRequest`, `EntryRequestResponse`, `CreateEntryInvitation`, `EntryInvitationResponse`, `EntryRequestStatus`, `EntryRequestOrigin`.
|
|
29
|
+
- `createSelfEnterServer` (em `@seedgrid/fe-self-enter/server`, SERVER-ONLY) — engine tipado sobre a sessão do fe-security.
|
|
30
|
+
- Os paths REST ficam em `SecurityPaths.selfEnter` (do `@seedgrid/fe-security`), pois a feature vive na extensão de security do backend.
|
|
31
|
+
|
|
32
|
+
## Telas entregues pelo scaffold
|
|
33
|
+
|
|
34
|
+
| Tela | Rota | O que faz |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| Pedido de acesso | `/auth/self-enter` (público) | Formulário de auto-cadastro (nome/e-mail/senha) com confirmação de e-mail |
|
|
37
|
+
| Convites | `/self-enter/invitations` | Administração de convites: criar, listar e reenviar |
|
|
38
|
+
| Aprovações | `/self-enter/requests/to-approve` | Fila de pedidos pendentes com aprovar/rejeitar |
|
|
39
|
+
|
|
40
|
+
Scaffold copiado pelo SeedGrid CLI via `module.json` (`id: "self-enter"`, `requires: ["security"]`) — o app é dono do código.
|
|
41
|
+
|
|
42
|
+
## Instalação
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm add @seedgrid/fe-self-enter @seedgrid/fe-security @seedgrid/fe-core
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Uso rápido
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// server-only — compõe sobre a sessão do fe-security
|
|
52
|
+
import { createSecurityServer } from "@seedgrid/fe-security/server";
|
|
53
|
+
import { createSelfEnterServer } from "@seedgrid/fe-self-enter/server";
|
|
54
|
+
|
|
55
|
+
const session = createSecurityServer({ /* ... */ });
|
|
56
|
+
export const selfEnter = createSelfEnterServer(session);
|
|
57
|
+
|
|
58
|
+
// Admin: convidar e aprovar
|
|
59
|
+
await selfEnter.createInvitation({ name: "Maria", email: "maria@empresa.com" });
|
|
60
|
+
const pendentes = await selfEnter.listToApprove();
|
|
61
|
+
await selfEnter.approve(pendentes[0].id);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Backend correspondente
|
|
65
|
+
|
|
66
|
+
Feature self-enter da extensão Quarkus `seedgrid-quarkus-ext-security` (endpoints `/self-enter/*` autenticados e `/public/self-enter/*` públicos).
|
|
67
|
+
|
|
68
|
+
## Suporte
|
|
69
|
+
|
|
70
|
+
- Issues: https://github.com/SeedGrid/seedgrid-fe-components/issues
|
|
71
|
+
- Repositório: https://github.com/SeedGrid/seedgrid-fe-components
|
|
72
|
+
|
|
73
|
+
## Licença
|
|
74
|
+
|
|
75
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,cAAc,CAAC"}
|
package/dist/index.js
ADDED
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AAWnE,eAAO,MAAM,MAAM,EAAE,yBAIpB,CAAC"}
|
package/dist/module.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Reference-adjacent implementation of the manifest read by the SeedGrid CLI
|
|
2
|
+
// (Rust). See seedgrid-cli/docs/adr/0004-module-manifest-contract.md.
|
|
3
|
+
//
|
|
4
|
+
// No provider/navigation/messages here: the self-enter menu entries and
|
|
5
|
+
// dashboard shortcut already live inside fe-security's own scaffold
|
|
6
|
+
// (`useSecurityAppShellSections.tsx`/`SecurityDashboard.tsx`), gated behind
|
|
7
|
+
// a `{{#if security_self_enter_enabled}}` conditional driven by
|
|
8
|
+
// `config.is_module_enabled("self-enter")`. This module only ships its own
|
|
9
|
+
// 3 standalone screens (public request, invitations, approvals).
|
|
10
|
+
export const module = {
|
|
11
|
+
id: "self-enter",
|
|
12
|
+
requires: ["security"],
|
|
13
|
+
configKey: "self-enter",
|
|
14
|
+
};
|
package/dist/module.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type EntryRequestOrigin = "INVITE" | "PUBLIC";
|
|
2
|
+
export type EntryRequestStatus = "PENDING_EMAIL" | "PENDING_APPROVAL" | "APPROVED" | "REJECTED" | "EXPIRED";
|
|
3
|
+
/** POST /public/self-enter/requests — pedido público de auto-cadastro. */
|
|
4
|
+
export interface CreateEntryRequest {
|
|
5
|
+
name: string;
|
|
6
|
+
email: string;
|
|
7
|
+
password: string;
|
|
8
|
+
}
|
|
9
|
+
/** Pedido de self-enter (EntryRequestDTORes). */
|
|
10
|
+
export interface EntryRequestResponse {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
name: string;
|
|
14
|
+
origin: EntryRequestOrigin;
|
|
15
|
+
status: EntryRequestStatus;
|
|
16
|
+
requestedAt: string;
|
|
17
|
+
expiresAt: string | null;
|
|
18
|
+
approvedAt: string | null;
|
|
19
|
+
approvedBy: string | null;
|
|
20
|
+
rejectedAt: string | null;
|
|
21
|
+
rejectedBy: string | null;
|
|
22
|
+
invitedBy: string | null;
|
|
23
|
+
invitationAcceptedAt: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** POST /self-enter/invitations — cria convite (admin). */
|
|
26
|
+
export interface CreateEntryInvitation {
|
|
27
|
+
email: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
/** Convite (EntryInvitationDTORes). */
|
|
31
|
+
export interface EntryInvitationResponse {
|
|
32
|
+
requestId: string;
|
|
33
|
+
email: string;
|
|
34
|
+
name: string;
|
|
35
|
+
origin: EntryRequestOrigin;
|
|
36
|
+
status: EntryRequestStatus;
|
|
37
|
+
invitationExpiresAt: string | null;
|
|
38
|
+
invitationAcceptedAt: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** POST /public/self-enter/invitations/{token}/complete — completa via convite. */
|
|
41
|
+
export interface CompleteEntryInvitation {
|
|
42
|
+
name: string;
|
|
43
|
+
email: string;
|
|
44
|
+
password: string;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=self-enter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"self-enter.d.ts","sourceRoot":"","sources":["../src/self-enter.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErD,MAAM,MAAM,kBAAkB,GAC1B,eAAe,GACf,kBAAkB,GAClB,UAAU,GACV,UAAU,GACV,SAAS,CAAC;AAEd,0EAA0E;AAC1E,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,2DAA2D;AAC3D,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,uCAAuC;AACvC,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,mFAAmF;AACnF,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// DTOs de self-enter (auto-cadastro de USUÁRIO — name/email/password — na extensão
|
|
2
|
+
// ext-security). Feature OPCIONAL com 3 modos no backend: disabled / invite-only /
|
|
3
|
+
// public. NÃO confundir com o registro de EMPRESA do login-tenancy (esse é BR/CNPJ).
|
|
4
|
+
// Client-safe. Os paths ficam em SecurityPaths.selfEnter (fe-security).
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SecurityServer } from "@seedgrid/fe-security";
|
|
2
|
+
import type { CompleteEntryInvitation, CreateEntryInvitation, CreateEntryRequest, EntryInvitationResponse, EntryRequestResponse } from "../self-enter";
|
|
3
|
+
export interface SelfEnterServer {
|
|
4
|
+
submitRequest(dto: CreateEntryRequest): Promise<EntryRequestResponse>;
|
|
5
|
+
confirmEmail(requestId: string, body?: unknown): Promise<void>;
|
|
6
|
+
resendEmail(requestId: string): Promise<void>;
|
|
7
|
+
getInvitation(token: string): Promise<EntryInvitationResponse>;
|
|
8
|
+
completeInvitation(token: string, dto: CompleteEntryInvitation): Promise<void>;
|
|
9
|
+
listToApprove(): Promise<EntryRequestResponse[]>;
|
|
10
|
+
approve(requestId: string, body?: unknown): Promise<EntryRequestResponse>;
|
|
11
|
+
reject(requestId: string, body?: unknown): Promise<EntryRequestResponse>;
|
|
12
|
+
createInvitation(dto: CreateEntryInvitation): Promise<EntryInvitationResponse>;
|
|
13
|
+
listInvitations(): Promise<EntryInvitationResponse[]>;
|
|
14
|
+
resendInvitation(requestId: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function createSelfEnterServer(session: SecurityServer): SelfEnterServer;
|
|
17
|
+
//# sourceMappingURL=create-self-enter-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-self-enter-server.d.ts","sourceRoot":"","sources":["../../src/server/create-self-enter-server.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,OAAO,KAAK,EACV,uBAAuB,EACvB,qBAAqB,EACrB,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACrB,MAAM,eAAe,CAAC;AAOvB,MAAM,WAAW,eAAe;IAE9B,aAAa,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACtE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAC/D,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/E,aAAa,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAAC;IACjD,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1E,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACzE,gBAAgB,CACd,GAAG,EAAE,qBAAqB,GACzB,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACpC,eAAe,IAAI,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAAC;IACtD,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,cAAc,GAAG,eAAe,CAgC9E"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Helpers server-side de self-enter, sobre o motor de sessão do fe-security. SERVER-ONLY.
|
|
2
|
+
// Reusa SecurityPaths.selfEnter. Endpoints /public/* são não-autenticados; /self-enter/*
|
|
3
|
+
// (aprovar/convidar) exigem sessão de admin.
|
|
4
|
+
//
|
|
5
|
+
// const session = createSecurityServer({ ... });
|
|
6
|
+
// export const selfEnter = createSelfEnterServer(session);
|
|
7
|
+
import { SecurityPaths } from "@seedgrid/fe-security";
|
|
8
|
+
const post = (body) => ({
|
|
9
|
+
method: "POST",
|
|
10
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
11
|
+
});
|
|
12
|
+
export function createSelfEnterServer(session) {
|
|
13
|
+
const SE = SecurityPaths.selfEnter;
|
|
14
|
+
return {
|
|
15
|
+
submitRequest: (dto) => session.apiFetchSecurity(SE.requests, post(dto)),
|
|
16
|
+
confirmEmail: (id, body) => session.apiFetchSecurity(SE.confirmEmail(id), post(body)),
|
|
17
|
+
resendEmail: (id) => session.apiFetchSecurity(SE.resendEmail(id), post()),
|
|
18
|
+
getInvitation: (token) => session.apiFetchSecurity(SE.invitationByToken(token)),
|
|
19
|
+
completeInvitation: (token, dto) => session.apiFetchSecurity(SE.invitationComplete(token), post(dto)),
|
|
20
|
+
listToApprove: () => session.apiFetchSecurity(SE.requestsToApprove),
|
|
21
|
+
approve: (id, body) => session.apiFetchSecurity(SE.approve(id), post(body)),
|
|
22
|
+
reject: (id, body) => session.apiFetchSecurity(SE.reject(id), post(body)),
|
|
23
|
+
createInvitation: (dto) => session.apiFetchSecurity(SE.invitations, post(dto)),
|
|
24
|
+
listInvitations: () => session.apiFetchSecurity(SE.invitations),
|
|
25
|
+
resendInvitation: (id) => session.apiFetchSecurity(SE.invitationResend(id), post()),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,4BAA4B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seedgrid/fe-self-enter",
|
|
3
|
+
"version": "2026.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/SeedGrid/seedgrid-fe-components.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/SeedGrid/seedgrid-fe-components",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/SeedGrid/seedgrid-fe-components/issues"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./server": {
|
|
21
|
+
"types": "./dist/server/index.d.ts",
|
|
22
|
+
"default": "./dist/server/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"scaffold"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@seedgrid/fe-core": "*",
|
|
31
|
+
"@seedgrid/fe-security": "*"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@seedgrid/fe-core": "2026.7.1",
|
|
35
|
+
"@seedgrid/fe-security": "2026.7.2"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.json",
|
|
39
|
+
"postbuild": "node scripts/emit-module-manifest.mjs",
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"test": "pnpm run build && node --test tests/module-manifest.test.mjs"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { FormEvent, InputHTMLAttributes } from "react";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SgButton,
|
|
8
|
+
SgInputEmail,
|
|
9
|
+
SgInputOTP,
|
|
10
|
+
SgInputPassword,
|
|
11
|
+
SgInputText,
|
|
12
|
+
SgPanel,
|
|
13
|
+
SgStack,
|
|
14
|
+
toast,
|
|
15
|
+
} from "@seedgrid/fe-components";
|
|
16
|
+
import { useSearchParams } from "next/navigation";
|
|
17
|
+
import {
|
|
18
|
+
type Control,
|
|
19
|
+
type FieldValues,
|
|
20
|
+
type RegisterOptions,
|
|
21
|
+
useForm,
|
|
22
|
+
} from "react-hook-form";
|
|
23
|
+
|
|
24
|
+
import { useI18n } from "@/i18n";
|
|
25
|
+
import {
|
|
26
|
+
InlineNotice,
|
|
27
|
+
PageFrame,
|
|
28
|
+
classifySelfEnterInvitationError,
|
|
29
|
+
extractApiMessage,
|
|
30
|
+
isSelfEnterInvitationEmailMismatchError,
|
|
31
|
+
securityConfig,
|
|
32
|
+
securityService,
|
|
33
|
+
} from "@/modules/security";
|
|
34
|
+
|
|
35
|
+
type InvitationState = "idle" | "loading" | "ready" | "invalid" | "consumed";
|
|
36
|
+
|
|
37
|
+
type PublicFlowStage = "form" | "confirm" | "success";
|
|
38
|
+
|
|
39
|
+
type ConfirmationFormValues = {
|
|
40
|
+
code: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const CONFIRMATION_CODE_LENGTH = 6;
|
|
44
|
+
|
|
45
|
+
export default function PublicSelfEnterRequestPage() {
|
|
46
|
+
const searchParams = useSearchParams();
|
|
47
|
+
const { t } = useI18n();
|
|
48
|
+
const token = searchParams.get("token")?.trim() ?? "";
|
|
49
|
+
const isInvitationMode = token.length > 0;
|
|
50
|
+
const [name, setName] = useState("");
|
|
51
|
+
const [email, setEmail] = useState("");
|
|
52
|
+
const [password, setPassword] = useState("");
|
|
53
|
+
const [pending, setPending] = useState(false);
|
|
54
|
+
const [publicStage, setPublicStage] = useState<PublicFlowStage>("form");
|
|
55
|
+
const [requestId, setRequestId] = useState<string | number | null>(null);
|
|
56
|
+
const [invitationState, setInvitationState] = useState<InvitationState>(
|
|
57
|
+
isInvitationMode ? "loading" : "idle"
|
|
58
|
+
);
|
|
59
|
+
const [invitationErrorMessage, setInvitationErrorMessage] =
|
|
60
|
+
useState<string | null>(null);
|
|
61
|
+
const [resendingCode, setResendingCode] = useState(false);
|
|
62
|
+
const { control, handleSubmit: handleConfirmationSubmit, reset } =
|
|
63
|
+
useForm<ConfirmationFormValues>({
|
|
64
|
+
defaultValues: {
|
|
65
|
+
code: "",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const confirmationControl = control as unknown as Control<FieldValues>;
|
|
69
|
+
const confirmationRules = {
|
|
70
|
+
required: t("security.self_enter.public.confirm.validation.code_required"),
|
|
71
|
+
} as RegisterOptions<FieldValues>;
|
|
72
|
+
const confirmationInputProps: Omit<
|
|
73
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
74
|
+
"value" | "defaultValue"
|
|
75
|
+
> = {
|
|
76
|
+
autoComplete: "one-time-code",
|
|
77
|
+
inputMode: "numeric",
|
|
78
|
+
};
|
|
79
|
+
const pageTitle = isInvitationMode
|
|
80
|
+
? t("security.self_enter.invitation.title")
|
|
81
|
+
: t("security.self_enter.public.title");
|
|
82
|
+
const pageDescription = isInvitationMode
|
|
83
|
+
? t("security.self_enter.invitation.description")
|
|
84
|
+
: t("security.self_enter.public.description");
|
|
85
|
+
const nameInputProps = {
|
|
86
|
+
value: name,
|
|
87
|
+
onChange: (event: { target: { value: string } }) =>
|
|
88
|
+
setName(event.target.value),
|
|
89
|
+
};
|
|
90
|
+
const emailInputProps = {
|
|
91
|
+
value: email,
|
|
92
|
+
onChange: (event: { target: { value: string } }) =>
|
|
93
|
+
setEmail(event.target.value),
|
|
94
|
+
};
|
|
95
|
+
const passwordInputProps = {
|
|
96
|
+
value: password,
|
|
97
|
+
onChange: (event: { target: { value: string } }) =>
|
|
98
|
+
setPassword(event.target.value),
|
|
99
|
+
autoComplete: "new-password",
|
|
100
|
+
};
|
|
101
|
+
const submitLabel = isInvitationMode
|
|
102
|
+
? t("security.self_enter.invitation.submit")
|
|
103
|
+
: t("security.self_enter.public.submit");
|
|
104
|
+
const inviteInfoMessage = useMemo(() => {
|
|
105
|
+
if (!isInvitationMode || invitationState !== "ready") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return t("security.self_enter.invitation.notice");
|
|
110
|
+
}, [invitationState, isInvitationMode, t]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (publicStage !== "success") {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const timeoutId = window.setTimeout(() => {
|
|
118
|
+
window.location.assign(securityConfig.routes.login);
|
|
119
|
+
}, 1200);
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
window.clearTimeout(timeoutId);
|
|
123
|
+
};
|
|
124
|
+
}, [publicStage]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
setPassword("");
|
|
128
|
+
reset({ code: "" });
|
|
129
|
+
setPublicStage("form");
|
|
130
|
+
setRequestId(null);
|
|
131
|
+
|
|
132
|
+
if (!isInvitationMode) {
|
|
133
|
+
setInvitationState("idle");
|
|
134
|
+
setInvitationErrorMessage(null);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let active = true;
|
|
139
|
+
|
|
140
|
+
setInvitationState("loading");
|
|
141
|
+
setInvitationErrorMessage(null);
|
|
142
|
+
|
|
143
|
+
void (async () => {
|
|
144
|
+
try {
|
|
145
|
+
const response = await securityService.getSelfEnterInvitation(token);
|
|
146
|
+
|
|
147
|
+
if (!active) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setName(response.name?.trim() ?? "");
|
|
152
|
+
setEmail(response.email?.trim() ?? "");
|
|
153
|
+
setInvitationState("ready");
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!active) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const errorKind = classifySelfEnterInvitationError(error);
|
|
160
|
+
const fallbackMessage =
|
|
161
|
+
errorKind === "consumed"
|
|
162
|
+
? t("security.self_enter.invitation.consumed")
|
|
163
|
+
: t("security.self_enter.invitation.invalid");
|
|
164
|
+
|
|
165
|
+
setInvitationState(errorKind === "consumed" ? "consumed" : "invalid");
|
|
166
|
+
setInvitationErrorMessage(extractApiMessage(error) ?? fallbackMessage);
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
active = false;
|
|
172
|
+
};
|
|
173
|
+
}, [isInvitationMode, reset, t, token]);
|
|
174
|
+
|
|
175
|
+
async function handleRequestSubmit(event: FormEvent<HTMLFormElement>) {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
|
|
178
|
+
if (!isInvitationMode && !securityConfig.features.selfEnterEnabled) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setPending(true);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (isInvitationMode) {
|
|
186
|
+
await securityService.completeSelfEnterInvitation(token, {
|
|
187
|
+
name: name.trim(),
|
|
188
|
+
email: email.trim(),
|
|
189
|
+
password: password.trim(),
|
|
190
|
+
});
|
|
191
|
+
toast.success(t("security.self_enter.invitation.success"));
|
|
192
|
+
setPublicStage("success");
|
|
193
|
+
} else {
|
|
194
|
+
const response = await securityService.createSelfEnterRequest({
|
|
195
|
+
name: name.trim(),
|
|
196
|
+
email: email.trim(),
|
|
197
|
+
password: password.trim(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
setRequestId(response.id);
|
|
201
|
+
setPublicStage("confirm");
|
|
202
|
+
toast.success(t("security.self_enter.public.confirm.code_sent"));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setPassword("");
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const invitationError = isInvitationMode
|
|
208
|
+
? isSelfEnterInvitationEmailMismatchError(error)
|
|
209
|
+
? t("security.self_enter.invitation.email_mismatch")
|
|
210
|
+
: t("security.self_enter.invitation.error")
|
|
211
|
+
: t("security.self_enter.public.error");
|
|
212
|
+
|
|
213
|
+
toast.error(extractApiMessage(error) ?? invitationError);
|
|
214
|
+
} finally {
|
|
215
|
+
setPending(false);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleConfirmEmail(values: ConfirmationFormValues) {
|
|
220
|
+
if (!requestId || pending) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const normalizedCode = normalizeConfirmationCode(values.code);
|
|
225
|
+
|
|
226
|
+
if (normalizedCode.length !== CONFIRMATION_CODE_LENGTH) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
setPending(true);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await securityService.confirmSelfEnterRequestEmail(requestId, {
|
|
234
|
+
code: normalizedCode,
|
|
235
|
+
});
|
|
236
|
+
setPublicStage("success");
|
|
237
|
+
toast.success(t("security.self_enter.public.approval_pending"));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
toast.error(
|
|
240
|
+
extractApiMessage(error) ??
|
|
241
|
+
t("security.self_enter.public.confirm.error")
|
|
242
|
+
);
|
|
243
|
+
} finally {
|
|
244
|
+
setPending(false);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function handleResendEmail() {
|
|
249
|
+
if (!requestId || resendingCode) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setResendingCode(true);
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await securityService.resendSelfEnterRequestEmail(requestId);
|
|
257
|
+
reset({ code: "" });
|
|
258
|
+
toast.success(t("security.self_enter.public.confirm.resend_success"));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
toast.error(
|
|
261
|
+
extractApiMessage(error) ??
|
|
262
|
+
t("security.self_enter.public.confirm.resend_error")
|
|
263
|
+
);
|
|
264
|
+
} finally {
|
|
265
|
+
setResendingCode(false);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeConfirmationCode(value: string) {
|
|
270
|
+
return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderInvitationState() {
|
|
274
|
+
if (invitationState === "loading") {
|
|
275
|
+
return (
|
|
276
|
+
<InlineNotice>{t("security.self_enter.invitation.loading")}</InlineNotice>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (invitationState === "invalid") {
|
|
281
|
+
return (
|
|
282
|
+
<InlineNotice tone="danger">
|
|
283
|
+
{invitationErrorMessage ?? t("security.self_enter.invitation.invalid")}
|
|
284
|
+
</InlineNotice>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (invitationState === "consumed") {
|
|
289
|
+
return (
|
|
290
|
+
<InlineNotice tone="warning">
|
|
291
|
+
{invitationErrorMessage ?? t("security.self_enter.invitation.consumed")}
|
|
292
|
+
</InlineNotice>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderSuccessState() {
|
|
300
|
+
const message = isInvitationMode
|
|
301
|
+
? t("security.self_enter.invitation.approval_pending")
|
|
302
|
+
: t("security.self_enter.public.approval_pending");
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<SgPanel borderStyle="none" className="mx-auto w-full max-w-2xl">
|
|
306
|
+
<SgStack gap={16}>
|
|
307
|
+
<InlineNotice tone="success">{message}</InlineNotice>
|
|
308
|
+
</SgStack>
|
|
309
|
+
</SgPanel>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderPublicConfirmation() {
|
|
314
|
+
return (
|
|
315
|
+
<SgPanel borderStyle="none" className="mx-auto w-full max-w-2xl">
|
|
316
|
+
<form onSubmit={handleConfirmationSubmit(handleConfirmEmail)}>
|
|
317
|
+
<SgStack gap={16}>
|
|
318
|
+
<InlineNotice>
|
|
319
|
+
{t("security.self_enter.public.confirm.description", {
|
|
320
|
+
email,
|
|
321
|
+
})}
|
|
322
|
+
</InlineNotice>
|
|
323
|
+
<SgInputOTP
|
|
324
|
+
id="security-self-enter-confirmation-code"
|
|
325
|
+
control={confirmationControl}
|
|
326
|
+
name="code"
|
|
327
|
+
rules={confirmationRules}
|
|
328
|
+
label={t("security.self_enter.public.confirm.code")}
|
|
329
|
+
mask="### ###"
|
|
330
|
+
inputProps={confirmationInputProps}
|
|
331
|
+
groupClassName="justify-start"
|
|
332
|
+
/>
|
|
333
|
+
<div className="flex flex-wrap justify-end gap-3">
|
|
334
|
+
<SgButton
|
|
335
|
+
appearance="outline"
|
|
336
|
+
severity="secondary"
|
|
337
|
+
shape="rounded"
|
|
338
|
+
label={t("security.self_enter.public.confirm.resend")}
|
|
339
|
+
onClick={() => {
|
|
340
|
+
void handleResendEmail();
|
|
341
|
+
}}
|
|
342
|
+
disabled={resendingCode || pending}
|
|
343
|
+
loading={resendingCode}
|
|
344
|
+
type="button"
|
|
345
|
+
/>
|
|
346
|
+
<SgButton
|
|
347
|
+
appearance="solid"
|
|
348
|
+
severity="primary"
|
|
349
|
+
shape="rounded"
|
|
350
|
+
label={t("security.self_enter.public.confirm.submit")}
|
|
351
|
+
loading={pending}
|
|
352
|
+
disabled={pending}
|
|
353
|
+
type="submit"
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
</SgStack>
|
|
357
|
+
</form>
|
|
358
|
+
</SgPanel>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<PageFrame title={pageTitle} description={pageDescription}>
|
|
364
|
+
{isInvitationMode ? renderInvitationState() : null}
|
|
365
|
+
|
|
366
|
+
{!isInvitationMode && !securityConfig.features.selfEnterEnabled ? (
|
|
367
|
+
<InlineNotice tone="warning">
|
|
368
|
+
{t("security.self_enter.public.disabled")}
|
|
369
|
+
</InlineNotice>
|
|
370
|
+
) : publicStage === "success" ? (
|
|
371
|
+
renderSuccessState()
|
|
372
|
+
) : !isInvitationMode && publicStage === "confirm" ? (
|
|
373
|
+
renderPublicConfirmation()
|
|
374
|
+
) : (
|
|
375
|
+
isInvitationMode && invitationState !== "ready" ? null : (
|
|
376
|
+
<form
|
|
377
|
+
className="mx-auto max-w-2xl space-y-4"
|
|
378
|
+
onSubmit={handleRequestSubmit}
|
|
379
|
+
>
|
|
380
|
+
{inviteInfoMessage ? (
|
|
381
|
+
<InlineNotice>{inviteInfoMessage}</InlineNotice>
|
|
382
|
+
) : null}
|
|
383
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
384
|
+
<SgInputEmail
|
|
385
|
+
id="security-self-enter-email"
|
|
386
|
+
label={t("security.self_enter.public.email")}
|
|
387
|
+
required
|
|
388
|
+
readOnly={isInvitationMode}
|
|
389
|
+
inputProps={emailInputProps}
|
|
390
|
+
onChange={(value) => {
|
|
391
|
+
if (!isInvitationMode) {
|
|
392
|
+
setEmail(value);
|
|
393
|
+
}
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
<SgInputText
|
|
397
|
+
id="security-self-enter-name"
|
|
398
|
+
label={t("security.self_enter.public.name")}
|
|
399
|
+
required
|
|
400
|
+
inputProps={nameInputProps}
|
|
401
|
+
/>
|
|
402
|
+
<SgInputPassword
|
|
403
|
+
id="security-self-enter-password"
|
|
404
|
+
label={t("security.self_enter.public.password")}
|
|
405
|
+
required
|
|
406
|
+
inputProps={passwordInputProps}
|
|
407
|
+
/>
|
|
408
|
+
</div>
|
|
409
|
+
<div className="flex justify-end">
|
|
410
|
+
<SgButton
|
|
411
|
+
appearance="solid"
|
|
412
|
+
severity="primary"
|
|
413
|
+
shape="rounded"
|
|
414
|
+
label={submitLabel}
|
|
415
|
+
loading={pending}
|
|
416
|
+
disabled={pending}
|
|
417
|
+
type="submit"
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
</form>
|
|
421
|
+
)
|
|
422
|
+
)}
|
|
423
|
+
</PageFrame>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SgButton,
|
|
7
|
+
SgDatatable,
|
|
8
|
+
SgGroupBox,
|
|
9
|
+
SgInputEmail,
|
|
10
|
+
SgInputText,
|
|
11
|
+
SgPanel,
|
|
12
|
+
SgStack,
|
|
13
|
+
sgWhistle,
|
|
14
|
+
} from "@seedgrid/fe-components";
|
|
15
|
+
import { Mail } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
import { useI18n } from "@/i18n";
|
|
18
|
+
import {
|
|
19
|
+
InlineNotice,
|
|
20
|
+
PageFrame,
|
|
21
|
+
RouteGuard,
|
|
22
|
+
classifySelfEnterInvitationAdminError,
|
|
23
|
+
extractApiMessage,
|
|
24
|
+
securityConfig,
|
|
25
|
+
securityService,
|
|
26
|
+
useDebouncedSearch,
|
|
27
|
+
type EntryRequest,
|
|
28
|
+
type SortState,
|
|
29
|
+
} from "@/modules/security";
|
|
30
|
+
import { formatDateTime, isSearchReady } from "@/modules/security/utils";
|
|
31
|
+
|
|
32
|
+
export default function SelfEnterInvitationsPage() {
|
|
33
|
+
return (
|
|
34
|
+
<RouteGuard permissions={["SELFENTER_INVITE"]}>
|
|
35
|
+
<SelfEnterInvitationsScreen />
|
|
36
|
+
</RouteGuard>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function SelfEnterInvitationsScreen() {
|
|
41
|
+
const { t, locale } = useI18n();
|
|
42
|
+
const [inviteName, setInviteName] = useState("");
|
|
43
|
+
const [inviteEmail, setInviteEmail] = useState("");
|
|
44
|
+
const [invitePending, setInvitePending] = useState(false);
|
|
45
|
+
const [requests, setRequests] = useState<EntryRequest[]>([]);
|
|
46
|
+
const [page, setPage] = useState(0);
|
|
47
|
+
const [rows, setRows] = useState<number>(
|
|
48
|
+
securityConfig.pagination.defaultPageSize
|
|
49
|
+
);
|
|
50
|
+
const [totalRecords, setTotalRecords] = useState(0);
|
|
51
|
+
const [loading, setLoading] = useState(true);
|
|
52
|
+
const [searchValue, setSearchValue] = useState("");
|
|
53
|
+
const [sort, setSort] = useState<SortState>({
|
|
54
|
+
sortField: "requestedAt",
|
|
55
|
+
sortOrder: -1,
|
|
56
|
+
});
|
|
57
|
+
const [resendingId, setResendingId] = useState<string | null>(null);
|
|
58
|
+
const debouncedSearch = useDebouncedSearch(searchValue);
|
|
59
|
+
const effectiveSearch = isSearchReady(debouncedSearch) ? debouncedSearch : "";
|
|
60
|
+
const inviteNameInputProps = {
|
|
61
|
+
value: inviteName,
|
|
62
|
+
onChange: (event: { target: { value: string } }) => {
|
|
63
|
+
setInviteName(event.target.value);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const inviteEmailInputProps = {
|
|
67
|
+
value: inviteEmail,
|
|
68
|
+
onChange: (event: { target: { value: string } }) => {
|
|
69
|
+
setInviteEmail(event.target.value);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const searchInputProps = {
|
|
73
|
+
value: searchValue,
|
|
74
|
+
onChange: (event: { target: { value: string } }) => {
|
|
75
|
+
setSearchValue(event.target.value);
|
|
76
|
+
setPage(0);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
async function loadInvitations() {
|
|
81
|
+
const response = await securityService.listSelfEnterInvitationsPage({
|
|
82
|
+
page,
|
|
83
|
+
size: rows,
|
|
84
|
+
search: effectiveSearch,
|
|
85
|
+
sort,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
setRequests(response.items);
|
|
89
|
+
setTotalRecords(response.total);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let active = true;
|
|
94
|
+
setLoading(true);
|
|
95
|
+
|
|
96
|
+
void (async () => {
|
|
97
|
+
try {
|
|
98
|
+
const response = await securityService.listSelfEnterInvitationsPage({
|
|
99
|
+
page,
|
|
100
|
+
size: rows,
|
|
101
|
+
search: effectiveSearch,
|
|
102
|
+
sort,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!active) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setRequests(response.items);
|
|
110
|
+
setTotalRecords(response.total);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!active) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sgWhistle.error({
|
|
117
|
+
message:
|
|
118
|
+
extractApiMessage(error) ??
|
|
119
|
+
t("security.self_enter.invitation_admin.error"),
|
|
120
|
+
});
|
|
121
|
+
} finally {
|
|
122
|
+
if (active) {
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
active = false;
|
|
130
|
+
};
|
|
131
|
+
}, [effectiveSearch, page, rows, sort, t]);
|
|
132
|
+
|
|
133
|
+
const columns = useMemo(
|
|
134
|
+
() => [
|
|
135
|
+
{
|
|
136
|
+
field: "email",
|
|
137
|
+
header: t("security.self_enter.invitation_admin.email"),
|
|
138
|
+
sortable: true,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
field: "name",
|
|
142
|
+
header: t("security.self_enter.invitation_admin.name"),
|
|
143
|
+
sortable: true,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
field: "invitedBy",
|
|
147
|
+
header: t("security.self_enter.invitation_admin.column.invited_by"),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
field: "expiresAt",
|
|
151
|
+
header: t("security.self_enter.invitation_admin.column.expires_at"),
|
|
152
|
+
sortable: true,
|
|
153
|
+
body: (item: EntryRequest) => formatDateTime(item.expiresAt, locale),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
header: t("security.common.actions"),
|
|
157
|
+
body: (item: EntryRequest) => (
|
|
158
|
+
<div className="flex flex-wrap gap-2" data-sg-stop-row-select="true">
|
|
159
|
+
<SgButton
|
|
160
|
+
size="sm"
|
|
161
|
+
appearance="solid"
|
|
162
|
+
severity="info"
|
|
163
|
+
shape="rounded"
|
|
164
|
+
leftIcon={<Mail size={16} />}
|
|
165
|
+
label={t("security.self_enter.invitation_admin.resend")}
|
|
166
|
+
aria-label={t("security.self_enter.invitation_admin.resend")}
|
|
167
|
+
title={t("security.self_enter.invitation_admin.resend")}
|
|
168
|
+
loading={resendingId === item.id}
|
|
169
|
+
disabled={resendingId === item.id}
|
|
170
|
+
onClick={() => {
|
|
171
|
+
void handleResend(item.id);
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
[locale, resendingId, t]
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
async function handleInviteSubmit() {
|
|
182
|
+
const normalizedEmail = inviteEmail.trim();
|
|
183
|
+
const normalizedName = inviteName.trim();
|
|
184
|
+
|
|
185
|
+
if (!normalizedEmail || invitePending) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setInvitePending(true);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await securityService.createSelfEnterInvitation({
|
|
193
|
+
email: normalizedEmail,
|
|
194
|
+
name: normalizedName || undefined,
|
|
195
|
+
});
|
|
196
|
+
sgWhistle.success({
|
|
197
|
+
message: t("security.self_enter.invitation_admin.success"),
|
|
198
|
+
});
|
|
199
|
+
setInviteName("");
|
|
200
|
+
setInviteEmail("");
|
|
201
|
+
await loadInvitations();
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const inviteError =
|
|
204
|
+
classifySelfEnterInvitationAdminError(error) === "duplicate_email"
|
|
205
|
+
? t("security.self_enter.invitation_admin.duplicate_email")
|
|
206
|
+
: t("security.self_enter.invitation_admin.error");
|
|
207
|
+
|
|
208
|
+
sgWhistle.error({
|
|
209
|
+
message: inviteError,
|
|
210
|
+
});
|
|
211
|
+
} finally {
|
|
212
|
+
setInvitePending(false);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function handleResend(id: string | number) {
|
|
217
|
+
const normalizedId = `${id}`.trim();
|
|
218
|
+
|
|
219
|
+
if (!normalizedId || resendingId) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setResendingId(normalizedId);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await securityService.resendSelfEnterInvitation(normalizedId);
|
|
227
|
+
sgWhistle.success({
|
|
228
|
+
message: t("security.self_enter.invitation_admin.resend_success"),
|
|
229
|
+
});
|
|
230
|
+
await loadInvitations();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
sgWhistle.error({
|
|
233
|
+
message:
|
|
234
|
+
extractApiMessage(error) ??
|
|
235
|
+
t("security.self_enter.invitation_admin.resend_error"),
|
|
236
|
+
});
|
|
237
|
+
} finally {
|
|
238
|
+
setResendingId(null);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<PageFrame
|
|
244
|
+
title={t("security.self_enter.invitation_admin.nav_title")}
|
|
245
|
+
description={t("security.self_enter.invitation_admin.page_description")}
|
|
246
|
+
>
|
|
247
|
+
<div className="space-y-4">
|
|
248
|
+
<SgPanel borderStyle="none" className="mx-auto w-full">
|
|
249
|
+
<SgStack gap={16}>
|
|
250
|
+
<SgStack gap={4}>
|
|
251
|
+
<h2 className="text-base font-semibold">
|
|
252
|
+
{t("security.self_enter.invitation_admin.form_title")}
|
|
253
|
+
</h2>
|
|
254
|
+
<p className="text-sm text-slate-500">
|
|
255
|
+
{t("security.self_enter.invitation_admin.description")}
|
|
256
|
+
</p>
|
|
257
|
+
</SgStack>
|
|
258
|
+
|
|
259
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
260
|
+
<SgInputEmail
|
|
261
|
+
id="security-self-enter-invite-email"
|
|
262
|
+
label={t("security.self_enter.invitation_admin.email")}
|
|
263
|
+
required
|
|
264
|
+
inputProps={inviteEmailInputProps}
|
|
265
|
+
/>
|
|
266
|
+
<SgInputText
|
|
267
|
+
id="security-self-enter-invite-name"
|
|
268
|
+
label={t("security.self_enter.invitation_admin.name")}
|
|
269
|
+
inputProps={inviteNameInputProps}
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="flex justify-end">
|
|
274
|
+
<SgButton
|
|
275
|
+
appearance="solid"
|
|
276
|
+
severity="primary"
|
|
277
|
+
shape="rounded"
|
|
278
|
+
label={t("security.self_enter.invitation_admin.submit")}
|
|
279
|
+
loading={invitePending}
|
|
280
|
+
disabled={invitePending}
|
|
281
|
+
onClick={() => {
|
|
282
|
+
void handleInviteSubmit();
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
</SgStack>
|
|
287
|
+
</SgPanel>
|
|
288
|
+
|
|
289
|
+
<SgGroupBox title={t("security.self_enter.invitation_admin.list_title")}>
|
|
290
|
+
<SgStack gap={16}>
|
|
291
|
+
<SgInputText
|
|
292
|
+
id="security-self-enter-invitation-search"
|
|
293
|
+
label={t("security.common.search")}
|
|
294
|
+
inputProps={searchInputProps}
|
|
295
|
+
placeholder={t("security.self_enter.approval.search_placeholder")}
|
|
296
|
+
/>
|
|
297
|
+
|
|
298
|
+
{!isSearchReady(searchValue) && searchValue.trim().length > 0 ? (
|
|
299
|
+
<InlineNotice>{t("security.common.search_hint")}</InlineNotice>
|
|
300
|
+
) : null}
|
|
301
|
+
|
|
302
|
+
<SgDatatable<EntryRequest>
|
|
303
|
+
title=" "
|
|
304
|
+
value={requests}
|
|
305
|
+
columns={columns}
|
|
306
|
+
dataKey="id"
|
|
307
|
+
lazy
|
|
308
|
+
paginator
|
|
309
|
+
rows={rows}
|
|
310
|
+
first={page * rows}
|
|
311
|
+
totalRecords={totalRecords}
|
|
312
|
+
sortField={sort.sortField}
|
|
313
|
+
sortOrder={sort.sortOrder}
|
|
314
|
+
loading={loading}
|
|
315
|
+
emptyMessage={t("security.self_enter.invitation_admin.list_empty")}
|
|
316
|
+
onPage={(event) => {
|
|
317
|
+
setRows(event.rows ?? securityConfig.pagination.defaultPageSize);
|
|
318
|
+
setPage(event.page ?? 0);
|
|
319
|
+
}}
|
|
320
|
+
onSort={(event) => {
|
|
321
|
+
setPage(0);
|
|
322
|
+
setSort({
|
|
323
|
+
sortField: event.sortField ?? "createdAt",
|
|
324
|
+
sortOrder:
|
|
325
|
+
event.sortOrder === 1 || event.sortOrder === -1
|
|
326
|
+
? event.sortOrder
|
|
327
|
+
: -1,
|
|
328
|
+
});
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
</SgStack>
|
|
332
|
+
</SgGroupBox>
|
|
333
|
+
</div>
|
|
334
|
+
</PageFrame>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SgButton,
|
|
7
|
+
SgConfirmationDialog,
|
|
8
|
+
SgDatatable,
|
|
9
|
+
SgInputText,
|
|
10
|
+
sgWhistle,
|
|
11
|
+
} from "@seedgrid/fe-components";
|
|
12
|
+
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
|
13
|
+
|
|
14
|
+
import { useI18n } from "@/i18n";
|
|
15
|
+
import {
|
|
16
|
+
InlineNotice,
|
|
17
|
+
PageFrame,
|
|
18
|
+
RouteGuard,
|
|
19
|
+
extractApiMessage,
|
|
20
|
+
securityConfig,
|
|
21
|
+
securityService,
|
|
22
|
+
useDebouncedSearch,
|
|
23
|
+
usePermission,
|
|
24
|
+
type EntryRequest,
|
|
25
|
+
type SortState,
|
|
26
|
+
} from "@/modules/security";
|
|
27
|
+
import { formatDateTime, isSearchReady } from "@/modules/security/utils";
|
|
28
|
+
|
|
29
|
+
export default function SelfEnterApprovalPage() {
|
|
30
|
+
return (
|
|
31
|
+
<RouteGuard
|
|
32
|
+
permissions={["SELFENTER_APPROVE", "SELFENTER_REJECT"]}
|
|
33
|
+
permissionMode="any"
|
|
34
|
+
>
|
|
35
|
+
<SelfEnterApprovalScreen />
|
|
36
|
+
</RouteGuard>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function SelfEnterApprovalScreen() {
|
|
41
|
+
const { t, locale } = useI18n();
|
|
42
|
+
const canApprove = usePermission("SELFENTER_APPROVE");
|
|
43
|
+
const canReject = usePermission("SELFENTER_REJECT");
|
|
44
|
+
const [requests, setRequests] = useState<EntryRequest[]>([]);
|
|
45
|
+
const [page, setPage] = useState(0);
|
|
46
|
+
const [rows, setRows] = useState<number>(
|
|
47
|
+
securityConfig.pagination.defaultPageSize
|
|
48
|
+
);
|
|
49
|
+
const [totalRecords, setTotalRecords] = useState(0);
|
|
50
|
+
const [loading, setLoading] = useState(true);
|
|
51
|
+
const [searchValue, setSearchValue] = useState("");
|
|
52
|
+
const [sort, setSort] = useState<SortState>({
|
|
53
|
+
sortField: "requestedAt",
|
|
54
|
+
sortOrder: -1,
|
|
55
|
+
});
|
|
56
|
+
const [confirmAction, setConfirmAction] = useState<{
|
|
57
|
+
type: "approve" | "reject";
|
|
58
|
+
item: EntryRequest;
|
|
59
|
+
} | null>(null);
|
|
60
|
+
const debouncedSearch = useDebouncedSearch(searchValue);
|
|
61
|
+
const effectiveSearch = isSearchReady(debouncedSearch) ? debouncedSearch : "";
|
|
62
|
+
const approveButtonColors = {
|
|
63
|
+
bg: "rgba(34, 197, 94, 0.10)",
|
|
64
|
+
border: "rgba(34, 197, 94, 0.12)",
|
|
65
|
+
hoverBg: "rgba(34, 197, 94, 0.16)",
|
|
66
|
+
activeBg: "rgba(34, 197, 94, 0.22)",
|
|
67
|
+
};
|
|
68
|
+
const rejectButtonColors = {
|
|
69
|
+
bg: "rgba(239, 68, 68, 0.10)",
|
|
70
|
+
border: "rgba(239, 68, 68, 0.12)",
|
|
71
|
+
hoverBg: "rgba(239, 68, 68, 0.16)",
|
|
72
|
+
activeBg: "rgba(239, 68, 68, 0.22)",
|
|
73
|
+
};
|
|
74
|
+
const searchInputProps = {
|
|
75
|
+
value: searchValue,
|
|
76
|
+
onChange: (event: { target: { value: string } }) => {
|
|
77
|
+
setSearchValue(event.target.value);
|
|
78
|
+
setPage(0);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
async function loadRequests() {
|
|
83
|
+
const response = await securityService.listSelfEnterRequestsPage({
|
|
84
|
+
page,
|
|
85
|
+
size: rows,
|
|
86
|
+
search: effectiveSearch,
|
|
87
|
+
sort,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
setRequests(response.items);
|
|
91
|
+
setTotalRecords(response.total);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let active = true;
|
|
96
|
+
setLoading(true);
|
|
97
|
+
|
|
98
|
+
void (async () => {
|
|
99
|
+
try {
|
|
100
|
+
const response = await securityService.listSelfEnterRequestsPage({
|
|
101
|
+
page,
|
|
102
|
+
size: rows,
|
|
103
|
+
search: effectiveSearch,
|
|
104
|
+
sort,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!active) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setRequests(response.items);
|
|
112
|
+
setTotalRecords(response.total);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (!active) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
sgWhistle.error({
|
|
119
|
+
message:
|
|
120
|
+
extractApiMessage(error) ??
|
|
121
|
+
t("security.self_enter.approval.load_error"),
|
|
122
|
+
});
|
|
123
|
+
} finally {
|
|
124
|
+
if (active) {
|
|
125
|
+
setLoading(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
active = false;
|
|
132
|
+
};
|
|
133
|
+
}, [effectiveSearch, page, rows, sort, t]);
|
|
134
|
+
|
|
135
|
+
const columns = useMemo(
|
|
136
|
+
() => [
|
|
137
|
+
{
|
|
138
|
+
field: "name",
|
|
139
|
+
header: t("security.self_enter.approval.column.name"),
|
|
140
|
+
sortable: true,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
field: "email",
|
|
144
|
+
header: t("security.self_enter.approval.column.email"),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
field: "requestedAt",
|
|
148
|
+
header: t("security.self_enter.approval.column.requested_at"),
|
|
149
|
+
sortable: true,
|
|
150
|
+
body: (item: EntryRequest) =>
|
|
151
|
+
formatDateTime(item.requestedAt ?? item.createdAt, locale),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
header: t("security.common.actions"),
|
|
155
|
+
body: (item: EntryRequest) => (
|
|
156
|
+
<div className="flex flex-wrap gap-2" data-sg-stop-row-select="true">
|
|
157
|
+
{canApprove ? (
|
|
158
|
+
<SgButton
|
|
159
|
+
size="sm"
|
|
160
|
+
appearance="ghost"
|
|
161
|
+
severity="success"
|
|
162
|
+
shape="circle"
|
|
163
|
+
iconOnly
|
|
164
|
+
leftIcon={<ThumbsUp size={16} />}
|
|
165
|
+
customColors={approveButtonColors}
|
|
166
|
+
aria-label={t("security.self_enter.approval.approve")}
|
|
167
|
+
title={t("security.self_enter.approval.approve")}
|
|
168
|
+
onClick={() => {
|
|
169
|
+
setConfirmAction({
|
|
170
|
+
type: "approve",
|
|
171
|
+
item,
|
|
172
|
+
});
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
{canReject ? (
|
|
177
|
+
<SgButton
|
|
178
|
+
size="sm"
|
|
179
|
+
appearance="ghost"
|
|
180
|
+
severity="danger"
|
|
181
|
+
shape="circle"
|
|
182
|
+
iconOnly
|
|
183
|
+
leftIcon={<ThumbsDown size={16} />}
|
|
184
|
+
customColors={rejectButtonColors}
|
|
185
|
+
aria-label={t("security.self_enter.approval.reject")}
|
|
186
|
+
title={t("security.self_enter.approval.reject")}
|
|
187
|
+
onClick={() => {
|
|
188
|
+
setConfirmAction({
|
|
189
|
+
type: "reject",
|
|
190
|
+
item,
|
|
191
|
+
});
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
) : null}
|
|
195
|
+
</div>
|
|
196
|
+
),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
[canApprove, canReject, locale, t]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
async function handleConfirmAction() {
|
|
203
|
+
if (!confirmAction) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
if (confirmAction.type === "approve") {
|
|
209
|
+
await securityService.approveSelfEnterRequest(confirmAction.item.id);
|
|
210
|
+
sgWhistle.success({
|
|
211
|
+
message: t("security.self_enter.approval.approve_success"),
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
await securityService.rejectSelfEnterRequest(confirmAction.item.id);
|
|
215
|
+
sgWhistle.success({
|
|
216
|
+
message: t("security.self_enter.approval.reject_success"),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
setConfirmAction(null);
|
|
221
|
+
await loadRequests();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
sgWhistle.error({
|
|
224
|
+
message:
|
|
225
|
+
extractApiMessage(error) ??
|
|
226
|
+
t("security.self_enter.approval.action_error"),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<PageFrame
|
|
233
|
+
title={t("security.self_enter.approval.title")}
|
|
234
|
+
description={t("security.self_enter.approval.description")}
|
|
235
|
+
>
|
|
236
|
+
<div className="space-y-4">
|
|
237
|
+
<SgInputText
|
|
238
|
+
id="security-self-enter-approval-search"
|
|
239
|
+
label={t("security.common.search")}
|
|
240
|
+
inputProps={searchInputProps}
|
|
241
|
+
placeholder={t("security.self_enter.approval.search_placeholder")}
|
|
242
|
+
/>
|
|
243
|
+
|
|
244
|
+
{!isSearchReady(searchValue) && searchValue.trim().length > 0 ? (
|
|
245
|
+
<InlineNotice>{t("security.common.search_hint")}</InlineNotice>
|
|
246
|
+
) : null}
|
|
247
|
+
|
|
248
|
+
<SgDatatable<EntryRequest>
|
|
249
|
+
title=" "
|
|
250
|
+
value={requests}
|
|
251
|
+
columns={columns}
|
|
252
|
+
dataKey="id"
|
|
253
|
+
lazy
|
|
254
|
+
paginator
|
|
255
|
+
rows={rows}
|
|
256
|
+
first={page * rows}
|
|
257
|
+
totalRecords={totalRecords}
|
|
258
|
+
sortField={sort.sortField}
|
|
259
|
+
sortOrder={sort.sortOrder}
|
|
260
|
+
loading={loading}
|
|
261
|
+
emptyMessage={t("security.self_enter.approval.empty")}
|
|
262
|
+
onPage={(event) => {
|
|
263
|
+
setRows(event.rows ?? securityConfig.pagination.defaultPageSize);
|
|
264
|
+
setPage(event.page ?? 0);
|
|
265
|
+
}}
|
|
266
|
+
onSort={(event) => {
|
|
267
|
+
setPage(0);
|
|
268
|
+
setSort({
|
|
269
|
+
sortField: event.sortField ?? "createdAt",
|
|
270
|
+
sortOrder:
|
|
271
|
+
event.sortOrder === 1 || event.sortOrder === -1
|
|
272
|
+
? event.sortOrder
|
|
273
|
+
: -1,
|
|
274
|
+
});
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<SgConfirmationDialog
|
|
280
|
+
open={Boolean(confirmAction)}
|
|
281
|
+
severity={confirmAction?.type === "approve" ? "warning" : "danger"}
|
|
282
|
+
title={
|
|
283
|
+
confirmAction?.type === "approve"
|
|
284
|
+
? t("security.self_enter.approval.approve_title")
|
|
285
|
+
: t("security.self_enter.approval.reject_title")
|
|
286
|
+
}
|
|
287
|
+
message={
|
|
288
|
+
confirmAction?.type === "approve"
|
|
289
|
+
? t("security.self_enter.approval.approve_message")
|
|
290
|
+
: t("security.self_enter.approval.reject_message")
|
|
291
|
+
}
|
|
292
|
+
onCancel={() => {
|
|
293
|
+
setConfirmAction(null);
|
|
294
|
+
}}
|
|
295
|
+
onConfirm={handleConfirmAction}
|
|
296
|
+
/>
|
|
297
|
+
</PageFrame>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|