@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 ADDED
@@ -0,0 +1,75 @@
1
+ # @seedgrid/fe-self-enter
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40seedgrid%2Ffe-self-enter)](https://www.npmjs.com/package/@seedgrid/fe-self-enter)
4
+ [![npm downloads](https://img.shields.io/npm/dm/%40seedgrid%2Ffe-self-enter)](https://www.npmjs.com/package/@seedgrid/fe-self-enter)
5
+ [![TypeScript](https://img.shields.io/badge/types-TypeScript-blue)](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
@@ -0,0 +1,2 @@
1
+ export * from "./self-enter";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -0,0 +1,3 @@
1
+ // Barrel client-safe (@seedgrid/fe-self-enter). Tipos. Server helper em /server.
2
+ // Paths reusam SecurityPaths.selfEnter (fe-security). Telas = SCAFFOLD (app owns).
3
+ export * from "./self-enter";
@@ -0,0 +1,3 @@
1
+ import type { SeedGridCliModuleManifest } from "@seedgrid/fe-core";
2
+ export declare const module: SeedGridCliModuleManifest;
3
+ //# sourceMappingURL=module.d.ts.map
@@ -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
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "self-enter",
3
+ "requires": [
4
+ "security"
5
+ ],
6
+ "configKey": "self-enter"
7
+ }
@@ -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,2 @@
1
+ export { createSelfEnterServer, type SelfEnterServer, } from "./create-self-enter-server";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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"}
@@ -0,0 +1,2 @@
1
+ // Barrel SERVER (@seedgrid/fe-self-enter/server). Roda em action/route.
2
+ export { createSelfEnterServer, } from "./create-self-enter-server";
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
+