@seedgrid/fe-security 2026.7.0 → 2026.7.1

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.
Files changed (124) hide show
  1. package/dist/client/index.d.ts +4 -0
  2. package/dist/client/index.d.ts.map +1 -0
  3. package/dist/client/index.js +5 -0
  4. package/dist/client/permission-provider.d.ts +18 -0
  5. package/dist/client/permission-provider.d.ts.map +1 -0
  6. package/dist/client/permission-provider.js +57 -0
  7. package/dist/client/pick-list-field.d.ts +15 -0
  8. package/dist/client/pick-list-field.d.ts.map +1 -0
  9. package/dist/client/pick-list-field.js +30 -0
  10. package/dist/client/status-badge.d.ts +6 -0
  11. package/dist/client/status-badge.d.ts.map +1 -0
  12. package/dist/client/status-badge.js +6 -0
  13. package/dist/config.d.ts +34 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +4 -0
  16. package/dist/dtos.d.ts +88 -0
  17. package/dist/dtos.d.ts.map +1 -0
  18. package/dist/dtos.js +3 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +10 -0
  22. package/dist/menu.d.ts +19 -0
  23. package/dist/menu.d.ts.map +1 -0
  24. package/dist/menu.js +41 -0
  25. package/dist/messages.d.ts +1858 -0
  26. package/dist/messages.d.ts.map +1 -0
  27. package/dist/messages.js +990 -0
  28. package/dist/module.d.ts +3 -0
  29. package/dist/module.d.ts.map +1 -0
  30. package/dist/module.js +37 -0
  31. package/dist/module.json +21 -0
  32. package/dist/paths.d.ts +1 -0
  33. package/dist/paths.d.ts.map +1 -1
  34. package/dist/paths.js +3 -0
  35. package/dist/permissions.d.ts +14 -0
  36. package/dist/permissions.d.ts.map +1 -0
  37. package/dist/permissions.js +39 -0
  38. package/dist/server/action.d.ts +3 -0
  39. package/dist/server/action.d.ts.map +1 -0
  40. package/dist/server/action.js +21 -0
  41. package/dist/server/create-security-server.d.ts +4 -0
  42. package/dist/server/create-security-server.d.ts.map +1 -0
  43. package/dist/server/create-security-server.js +166 -0
  44. package/dist/server/errors.d.ts +13 -0
  45. package/dist/server/errors.d.ts.map +1 -0
  46. package/dist/server/errors.js +26 -0
  47. package/dist/server/index.d.ts +8 -0
  48. package/dist/server/index.d.ts.map +1 -0
  49. package/dist/server/index.js +8 -0
  50. package/dist/server/problem-response.d.ts +3 -0
  51. package/dist/server/problem-response.d.ts.map +1 -0
  52. package/dist/server/problem-response.js +28 -0
  53. package/dist/server/route.d.ts +3 -0
  54. package/dist/server/route.d.ts.map +1 -0
  55. package/dist/server/route.js +22 -0
  56. package/dist/server/to-problem.d.ts +3 -0
  57. package/dist/server/to-problem.d.ts.map +1 -0
  58. package/dist/server/to-problem.js +22 -0
  59. package/dist/server-contract.d.ts +23 -0
  60. package/dist/server-contract.d.ts.map +1 -0
  61. package/dist/server-contract.js +4 -0
  62. package/dist/session-contract.d.ts +36 -0
  63. package/dist/session-contract.d.ts.map +1 -0
  64. package/dist/session-contract.js +8 -0
  65. package/dist/session-result.d.ts +22 -0
  66. package/dist/session-result.d.ts.map +1 -0
  67. package/dist/session-result.js +14 -0
  68. package/package.json +38 -3
  69. package/scaffold/README.md +97 -0
  70. package/scaffold/src/app/api/security/session/2fa/resend/route.ts +8 -0
  71. package/scaffold/src/app/api/security/session/2fa/verify/route.ts +8 -0
  72. package/scaffold/src/app/api/security/session/login/route.ts +7 -0
  73. package/scaffold/src/app/api/security/session/logout/route.ts +5 -0
  74. package/scaffold/src/app/api/security/session/refresh/route.ts +7 -0
  75. package/scaffold/src/app/api/security/users/[publicId]/avatar/route.ts +15 -0
  76. package/scaffold/src/app/auth/2fa/verify/page.tsx +286 -0
  77. package/scaffold/src/app/auth/forgot-password/confirm/page.tsx +96 -0
  78. package/scaffold/src/app/auth/forgot-password/page.tsx +165 -0
  79. package/scaffold/src/app/auth/forgot-password/success/page.tsx +31 -0
  80. package/scaffold/src/app/auth/login/page.tsx +386 -0
  81. package/scaffold/src/app/auth/welcome/page.tsx +90 -0
  82. package/scaffold/src/app/login/page.tsx +7 -0
  83. package/scaffold/src/app/page.tsx +12 -0
  84. package/scaffold/src/app/security/2fa/page.tsx +370 -0
  85. package/scaffold/src/app/security/companies/(create)/new/page.tsx +12 -0
  86. package/scaffold/src/app/security/companies/(edit)/[publicId]/page.tsx +18 -0
  87. package/scaffold/src/app/security/companies/(list)/page.tsx +298 -0
  88. package/scaffold/src/app/security/companies/editors/CompanyEditor.tsx +754 -0
  89. package/scaffold/src/app/security/profile/page.tsx +492 -0
  90. package/scaffold/src/app/security/roles/(create)/new/page.tsx +11 -0
  91. package/scaffold/src/app/security/roles/(edit)/[publicId]/page.tsx +17 -0
  92. package/scaffold/src/app/security/roles/(list)/page.tsx +269 -0
  93. package/scaffold/src/app/security/roles/editors/RoleEditor.tsx +328 -0
  94. package/scaffold/src/app/security/users/(create)/new/page.tsx +10 -0
  95. package/scaffold/src/app/security/users/(edit)/[publicId]/page.tsx +16 -0
  96. package/scaffold/src/app/security/users/(list)/page.tsx +289 -0
  97. package/scaffold/src/app/security/users/editors/UserEditor.tsx +478 -0
  98. package/scaffold/src/app/users/change-password/page.tsx +172 -0
  99. package/scaffold/src/modules/security/AuthProvider.tsx +405 -0
  100. package/scaffold/src/modules/security/components/AppShell.tsx.hbs +889 -0
  101. package/scaffold/src/modules/security/components/CompanyManagementGuard.tsx +59 -0
  102. package/scaffold/src/modules/security/components/PageFrame.tsx +105 -0
  103. package/scaffold/src/modules/security/components/RouteGuard.tsx +114 -0
  104. package/scaffold/src/modules/security/components/SecurityDashboard.tsx.hbs +315 -0
  105. package/scaffold/src/modules/security/config.ts +55 -0
  106. package/scaffold/src/modules/security/hooks/useAuth.ts +7 -0
  107. package/scaffold/src/modules/security/hooks/useDebouncedSearch.ts +13 -0
  108. package/scaffold/src/modules/security/hooks/usePermission.ts +35 -0
  109. package/scaffold/src/modules/security/host-access.ts +59 -0
  110. package/scaffold/src/modules/security/index.ts +44 -0
  111. package/scaffold/src/modules/security/navigation.ts +41 -0
  112. package/scaffold/src/modules/security/server/avatar-routes.ts +86 -0
  113. package/scaffold/src/modules/security/server/public-api-base-url.ts +26 -0
  114. package/scaffold/src/modules/security/server/session-routes.ts.hbs +322 -0
  115. package/scaffold/src/modules/security/server/session-server.ts.hbs +30 -0
  116. package/scaffold/src/modules/security/services/auth-service.ts +332 -0
  117. package/scaffold/src/modules/security/services/security-api.test.ts +178 -0
  118. package/scaffold/src/modules/security/services/security-api.ts +281 -0
  119. package/scaffold/src/modules/security/services/security-service.test.ts +71 -0
  120. package/scaffold/src/modules/security/services/security-service.ts +727 -0
  121. package/scaffold/src/modules/security/services/session-storage.ts +80 -0
  122. package/scaffold/src/modules/security/types.ts +295 -0
  123. package/scaffold/src/modules/security/useSecurityAppShellSections.tsx.hbs +180 -0
  124. package/scaffold/src/modules/security/utils.ts +194 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-result.d.ts","sourceRoot":"","sources":["../src/session-result.ts"],"names":[],"mappings":"AAIA,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oDAAoD;IACpD,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,YAAY,CAAC,CAAC,IACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,CAAC,CAAA;CAAE,GACrB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,aAAa,CAAA;CAAE,CAAC;AAE1C,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CASjE"}
@@ -0,0 +1,14 @@
1
+ // Resultado de Server Action — client-safe. A action NÃO lança (o Next redige a
2
+ // mensagem de erro em produção): ela RETORNA este result, que serializa com segurança
3
+ // e carrega o problem+json do backend intacto até a UI.
4
+ /** Lê a mensagem exibível de um ProblemDetail (detail → 1ª violação → title → status). */
5
+ export function readProblemMessage(problem) {
6
+ if (problem.detail) {
7
+ return problem.detail;
8
+ }
9
+ const firstViolation = problem.violations?.find((v) => v.message)?.message;
10
+ if (firstViolation) {
11
+ return firstViolation;
12
+ }
13
+ return problem.title ?? `Erro ${problem.status}`;
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedgrid/fe-security",
3
- "version": "2026.7.0",
3
+ "version": "2026.7.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,14 +16,49 @@
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
18
  "default": "./dist/index.js"
19
+ },
20
+ "./server": {
21
+ "types": "./dist/server/index.d.ts",
22
+ "default": "./dist/server/index.js"
23
+ },
24
+ "./client": {
25
+ "types": "./dist/client/index.d.ts",
26
+ "default": "./dist/client/index.js"
27
+ },
28
+ "./paths": {
29
+ "types": "./dist/paths.d.ts",
30
+ "default": "./dist/paths.js"
19
31
  }
20
32
  },
21
33
  "files": [
22
- "dist"
34
+ "dist",
35
+ "scaffold"
23
36
  ],
37
+ "peerDependencies": {
38
+ "@seedgrid/fe-components": "*",
39
+ "@seedgrid/fe-core": "*",
40
+ "next": ">=14",
41
+ "react": "^18.2.0 || ^19.0.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "next": {
45
+ "optional": true
46
+ },
47
+ "react": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "next": "15.0.7",
53
+ "react": "19.0.0",
54
+ "@types/react": "^19.0.0",
55
+ "@seedgrid/fe-components": "2026.7.1",
56
+ "@seedgrid/fe-core": "2026.7.1"
57
+ },
24
58
  "scripts": {
25
59
  "build": "tsc -p tsconfig.json",
60
+ "postbuild": "node scripts/emit-module-manifest.mjs",
26
61
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
- "test": "pnpm run build && node --test tests/paths.test.mjs"
62
+ "test": "pnpm run build && node --test tests/paths.test.mjs tests/module-manifest.test.mjs tests/permissions.test.mjs"
28
63
  }
29
64
  }
@@ -0,0 +1,97 @@
1
+ # `fe-security` scaffold
2
+
3
+ Conteúdo copiado para dentro do app gerado pelo CLI (git do app, editável à
4
+ vontade — ver `seedgrid-cli/docs/adr/0004-module-manifest-contract.md`).
5
+ Extraído de `seedgrid-cli/templates/modules/security/`.
6
+
7
+ ## Critério lib vs scaffold usado nesta extração
8
+
9
+ Regra objetiva aplicada arquivo a arquivo: **qualquer import de um alias
10
+ `@/...`** (`@/config/*`, `@/i18n`, `@/theme`, `@/modules/core`,
11
+ `@/components/layout/*`) **só resolve dentro do app gerado** — um pacote npm
12
+ publicado não tem acesso a esses aliases. Então:
13
+
14
+ - **Sem nenhum import `@/...`** → candidato a lib (`src/` do pacote).
15
+ - **Com import `@/...` usado só como valor default trivial** (ex.:
16
+ `delayMs = securityConfig.search.debounceMs`) → decoupar (virar parâmetro
17
+ com default literal) e promover.
18
+ - **Com import `@/...` usado em fluxo de controle/rota concreta** (redirects,
19
+ comparação de pathname, `securityConfig.apiBaseUrl` pra chamar a própria
20
+ rota-proxy do app) → fica scaffold. Não vale a pena forçar injeção de config
21
+ pra tudo nesta passada — fica como follow-up se algum dia fizer falta.
22
+
23
+ `@/modules/core` é caso à parte: é só um shim local que reexporta
24
+ `@seedgrid/fe-core` (não é acoplamento de verdade). Ao promover um arquivo pra
25
+ lib, trocar `@/modules/core` por `@seedgrid/fe-core` direto.
26
+
27
+ ## O que foi promovido pra `@seedgrid/fe-security` (lib)
28
+
29
+ - `permissions.ts` (`normalizePermission`, `hasPermission`, `hasAnyPermission`,
30
+ `hasAllPermissions`, `buildCrudPermissions`, `createPermissionSet`) — puro,
31
+ zero import `@/...`.
32
+ - `messages.ts` (`securityMessages`) — puro, 5 locales, zero import `@/...`.
33
+ - `components/StatusBadge.tsx`, `components/PickListField.tsx` — puros
34
+ (`PickListOption` também promovido, foi pra `dtos.ts` do pacote).
35
+
36
+ ## O que ficou scaffold (e por quê)
37
+
38
+ - `AuthProvider.tsx`, `components/RouteGuard.tsx`, `navigation.ts` — fluxo de
39
+ redirect hardcoda rotas concretas (`securityConfig.routes.login` etc.).
40
+ - `config.ts` — `securityConfig.routes`/`pagination` são a própria estrutura
41
+ de rotas do app; muda se o app mover uma tela de lugar.
42
+ - `services/security-api.ts`, `services/security-service.ts`,
43
+ `services/auth-service.ts` — a camada de fetch client-side chama a própria
44
+ rota-proxy do app (`securityConfig.apiBaseUrl`) e (no caso de
45
+ `security-api.ts`) usa `@/i18n` pra 1 mensagem de fallback. Dava pra
46
+ decopiar com injeção de config, mas não foi feito nesta passada — ver
47
+ "Follow-ups" abaixo.
48
+ - `components/PageFrame.tsx` (+`InlineNotice`) — usa `@/theme`
49
+ (`useSgTheme`), que é implementação própria do base-app, sem equivalente
50
+ npm hoje.
51
+ - `components/AppShell.tsx`, `components/SecurityDashboard.tsx`,
52
+ `useSecurityAppShellSections.tsx` — acopladas a `@/i18n` +
53
+ `@/components/layout/*` e têm menu/rotas concretas. Estes 3 mantiveram
54
+ `.hbs` (têm `{{...}}` de verdade — condicionais por config do projeto,
55
+ ex. `security_api_keys_enabled`); os demais viraram `.ts`/`.tsx` puro (não
56
+ tinham templating real, só texto que por acaso teria a mesma extensão).
57
+ - `hooks/useAuth.ts`, `hooks/usePermission.ts`,
58
+ `components/CompanyManagementGuard.tsx`, `host-access.ts`, `types.ts`,
59
+ `utils.ts` — dependem, direta ou indiretamente, de algo acima que ficou
60
+ scaffold (ex.: `useAuth` depende do `AuthProvider` scaffold).
61
+
62
+ `index.ts` (barrel local do scaffold) mistura os dois: reexporta os itens
63
+ promovidos direto de `@seedgrid/fe-security`/`@seedgrid/fe-security/client`, e
64
+ mantém os demais como arquivo local — igual a como um app gerado hoje já
65
+ usa esse barrel, só com a origem de alguns exports trocada.
66
+
67
+ ## Motor de sessão unificado (o "dois motores" foi resolvido em parte)
68
+
69
+ Havia dois motores de **fetch autenticado** paralelos: o `server/server-api.ts`
70
+ (`createServerApiClient`, scaffold) e o `createSecurityServer` da lib
71
+ (`src/server/`, usado por todos os módulos compostos — billing/tenancy/backup).
72
+ O único consumidor do antigo era o proxy de avatar.
73
+
74
+ **Resolvido**: `server/server-api.ts` foi REMOVIDO. O avatar agora usa
75
+ `server/session-server.ts` (`getSecuritySessionServer()` → `createSecurityServer`
76
+ com a config de cookie do app) e `fetchWithSession()` — o MESMO motor do resto
77
+ do app. Um motor de fetch autenticado só.
78
+
79
+ **Deixado de propósito**: `server/session-routes.ts` (rotas públicas de
80
+ login/2fa/refresh/logout em `app/api/security/session/**`) NÃO foi migrado.
81
+ Ele não é um motor duplicado — é o **estabelecimento** de sessão (fluxos
82
+ públicos que criam os cookies), enquanto o `createSecurityServer` é o consumo
83
+ autenticado depois. Migrá-lo mexeria em semântica sutil de cookie (o antigo
84
+ seta `maxAge` do refresh = 30d configurável; o `applySessionCookies` do motor
85
+ novo grava cookie de sessão sem `maxAge`) e no dedup de refresh concorrente
86
+ (`Map`), no caminho MAIS crítico (login) e SEM testes de runtime pra pegar
87
+ regressão. Fica como follow-up com essa ressalva explícita.
88
+
89
+ ## Follow-ups (fora do escopo desta extração)
90
+
91
+ - Promover `security-api.ts`/`security-service.ts`/`auth-service.ts` pra lib
92
+ exigiria injeção de config (ex.: aceitar um `createApiClient` já configurado
93
+ em vez de montar um com `securityConfig.apiBaseUrl` fixo). Ganho real (essa
94
+ é a camada de maior volume de código), mas é redesenho deliberado, não
95
+ mecânico — não fazer de afogadilho.
96
+ - `AuthProvider`/`RouteGuard` também dava pra promover injetando as rotas via
97
+ props/config em vez de importar `securityConfig` direto. Mesma ressalva.
@@ -0,0 +1,8 @@
1
+ import { NextRequest } from "next/server";
2
+
3
+ import { createResendTwoFactorRouteResponse } from "@/modules/security/server/session-routes";
4
+
5
+ export async function POST(request: NextRequest) {
6
+ return createResendTwoFactorRouteResponse(request);
7
+ }
8
+
@@ -0,0 +1,8 @@
1
+ import { NextRequest } from "next/server";
2
+
3
+ import { createVerifyTwoFactorRouteResponse } from "@/modules/security/server/session-routes";
4
+
5
+ export async function POST(request: NextRequest) {
6
+ return createVerifyTwoFactorRouteResponse(request);
7
+ }
8
+
@@ -0,0 +1,7 @@
1
+ import { NextRequest } from "next/server";
2
+
3
+ import { createLoginRouteResponse } from "@/modules/security/server/session-routes";
4
+
5
+ export async function POST(request: NextRequest) {
6
+ return createLoginRouteResponse(request);
7
+ }
@@ -0,0 +1,5 @@
1
+ import { createLogoutRouteResponse } from "@/modules/security/server/session-routes";
2
+
3
+ export async function POST() {
4
+ return createLogoutRouteResponse();
5
+ }
@@ -0,0 +1,7 @@
1
+ import { NextRequest } from "next/server";
2
+
3
+ import { createRefreshRouteResponse } from "@/modules/security/server/session-routes";
4
+
5
+ export async function POST(request: NextRequest) {
6
+ return createRefreshRouteResponse(request);
7
+ }
@@ -0,0 +1,15 @@
1
+ import { NextRequest } from "next/server";
2
+
3
+ import { createUserAvatarRouteResponse } from "@/modules/security/server/avatar-routes";
4
+
5
+ export async function GET(
6
+ request: NextRequest,
7
+ context: { params: Promise<{ publicId: string }> }
8
+ ) {
9
+ const params = await context.params;
10
+
11
+ return createUserAvatarRouteResponse(
12
+ params.publicId,
13
+ request.nextUrl.searchParams.get("v")
14
+ );
15
+ }
@@ -0,0 +1,286 @@
1
+ "use client";
2
+
3
+ import type { InputHTMLAttributes } from "react";
4
+ import { useRef, useState } from "react";
5
+
6
+ import {
7
+ SgButton,
8
+ SgCard,
9
+ SgInputOTP,
10
+ SgPanel,
11
+ SgStack,
12
+ SgWhistleHost,
13
+ sgWhistle,
14
+ } from "@seedgrid/fe-components";
15
+ import { useRouter } from "next/navigation";
16
+ import {
17
+ type Control,
18
+ type FieldErrors,
19
+ type FieldValues,
20
+ type RegisterOptions,
21
+ useForm,
22
+ } from "react-hook-form";
23
+
24
+ import { useI18n } from "@/i18n";
25
+ import { useSgTheme } from "@/theme";
26
+ import {
27
+ InlineNotice,
28
+ extractApiMessage,
29
+ resolvePostAuthPath,
30
+ securityConfig,
31
+ useAuth,
32
+ } from "@/modules/security";
33
+ import { authService } from "@/modules/security/services/auth-service";
34
+ import { readStoredSession } from "@/modules/security/services/session-storage";
35
+
36
+ type TwoFactorVerifyFormValues = {
37
+ code: string;
38
+ };
39
+
40
+ const TWO_FACTOR_CODE_LENGTH = 6;
41
+
42
+ export default function TwoFactorVerifyPage() {
43
+ const router = useRouter();
44
+ const { t } = useI18n();
45
+ const translate = t as unknown as (
46
+ key: string,
47
+ values?: Record<string, unknown>
48
+ ) => string;
49
+ const { currentMode } = useSgTheme();
50
+ const isDarkMode = currentMode === "dark";
51
+ const auth = useAuth();
52
+ const [pending, setPending] = useState(false);
53
+ const [resending, setResending] = useState(false);
54
+ const lastSubmittedCodeRef = useRef<string | null>(null);
55
+ const pendingMethod = auth.pendingTwoFactor?.method?.trim().toUpperCase() ?? "";
56
+ const canResendEmail = pendingMethod === "EMAIL";
57
+ const { control, handleSubmit, reset, setValue } =
58
+ useForm<TwoFactorVerifyFormValues>({
59
+ defaultValues: {
60
+ code: "",
61
+ },
62
+ });
63
+ const codeRules = {
64
+ required: translate("security.auth.two_factor.validation.code_required"),
65
+ };
66
+ const otpControl = control as unknown as Control<FieldValues>;
67
+ const otpRules = codeRules as RegisterOptions<FieldValues>;
68
+ const otpInputProps: Omit<
69
+ InputHTMLAttributes<HTMLInputElement>,
70
+ "value" | "defaultValue"
71
+ > = {
72
+ autoComplete: "one-time-code",
73
+ inputMode: "text",
74
+ };
75
+
76
+ function normalizeCode(value: string) {
77
+ return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
78
+ }
79
+
80
+ const methodHint =
81
+ pendingMethod === "EMAIL"
82
+ ? translate("security.auth.two_factor.hint_email")
83
+ : pendingMethod === "TOTP"
84
+ ? translate("security.auth.two_factor.hint_totp")
85
+ : pendingMethod === "WHATSAPP"
86
+ ? translate("security.auth.two_factor.hint_whatsapp")
87
+ : translate("security.auth.two_factor.hint_generic");
88
+
89
+ async function handleVerify(values: TwoFactorVerifyFormValues) {
90
+ const normalizedCode = normalizeCode(values.code);
91
+
92
+ if (normalizedCode.length !== TWO_FACTOR_CODE_LENGTH || pending) {
93
+ return;
94
+ }
95
+
96
+ setPending(true);
97
+
98
+ try {
99
+ lastSubmittedCodeRef.current = normalizedCode;
100
+ await auth.verifyTwoFactor(normalizedCode);
101
+ sgWhistle.success({
102
+ message: translate("security.auth.two_factor.success"),
103
+ });
104
+ reset();
105
+ const nextPermissions = readStoredSession()?.permissions ?? [];
106
+ router.push(await resolvePostAuthPath(nextPermissions));
107
+ } catch (error) {
108
+ lastSubmittedCodeRef.current = null;
109
+ sgWhistle.error({
110
+ message:
111
+ extractApiMessage(error) ??
112
+ translate("security.auth.two_factor.error"),
113
+ });
114
+ } finally {
115
+ setPending(false);
116
+ }
117
+ }
118
+
119
+ async function handleAutoSubmit(rawValue: string) {
120
+ const normalizedCode = normalizeCode(rawValue);
121
+
122
+ if (
123
+ normalizedCode.length !== TWO_FACTOR_CODE_LENGTH ||
124
+ pending ||
125
+ lastSubmittedCodeRef.current === normalizedCode
126
+ ) {
127
+ return;
128
+ }
129
+
130
+ setValue("code", normalizedCode, {
131
+ shouldDirty: true,
132
+ shouldTouch: true,
133
+ shouldValidate: true,
134
+ });
135
+
136
+ await handleSubmit(handleVerify, handleInvalidSubmit)();
137
+ }
138
+
139
+ function handleInvalidSubmit(errors: FieldErrors<TwoFactorVerifyFormValues>) {
140
+ const firstMessage = errors.code?.message;
141
+
142
+ if (firstMessage) {
143
+ sgWhistle.error({ message: firstMessage });
144
+ }
145
+ }
146
+
147
+ async function handleResendEmail() {
148
+ const pendingSession = auth.pendingTwoFactor;
149
+
150
+ if (!pendingSession || resending) {
151
+ return;
152
+ }
153
+
154
+ setResending(true);
155
+
156
+ try {
157
+ await authService.resendTwoFactor(pendingSession);
158
+ sgWhistle.success({
159
+ message: translate("security.auth.two_factor.resend_success"),
160
+ });
161
+ reset({ code: "" });
162
+ lastSubmittedCodeRef.current = null;
163
+ } catch (error) {
164
+ sgWhistle.error({
165
+ message:
166
+ extractApiMessage(error) ??
167
+ translate("security.auth.two_factor.resend_error"),
168
+ });
169
+ } finally {
170
+ setResending(false);
171
+ }
172
+ }
173
+
174
+ return (
175
+ <div
176
+ className={`flex min-h-screen items-center justify-center px-4 py-8 sm:px-6 ${
177
+ isDarkMode
178
+ ? "bg-slate-950 text-slate-100"
179
+ : "bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_28%),linear-gradient(180deg,#f8fafc_0%,#eef4fb_100%)] text-slate-900"
180
+ }`}
181
+ >
182
+ <SgCard
183
+ cardStyle="elevated"
184
+ size="lg"
185
+ className={`w-full max-w-md rounded-[2rem] border shadow-2xl ${
186
+ isDarkMode
187
+ ? "border-slate-800 bg-slate-900/95 shadow-black/30"
188
+ : "border-white/70 bg-white/95 shadow-slate-900/10"
189
+ }`}
190
+ >
191
+ <form
192
+ noValidate
193
+ onSubmit={handleSubmit(handleVerify, handleInvalidSubmit)}
194
+ >
195
+ <SgStack gap={24}>
196
+ <SgWhistleHost className="w-full" max={1} newestOnTop />
197
+
198
+ <SgStack gap={8} className="text-center">
199
+ <h1
200
+ className={`text-2xl font-semibold ${
201
+ isDarkMode ? "text-slate-50" : "text-slate-950"
202
+ }`}
203
+ >
204
+ {translate("security.auth.two_factor.title")}
205
+ </h1>
206
+ <p
207
+ className={`text-sm leading-6 ${
208
+ isDarkMode ? "text-slate-400" : "text-slate-600"
209
+ }`}
210
+ >
211
+ {translate("security.auth.two_factor.description")}
212
+ </p>
213
+ <p
214
+ className={`text-xs leading-5 ${
215
+ isDarkMode ? "text-slate-500" : "text-slate-500"
216
+ }`}
217
+ >
218
+ {methodHint}
219
+ </p>
220
+ </SgStack>
221
+
222
+ {!auth.pendingTwoFactor ? (
223
+ <InlineNotice tone="warning">
224
+ {translate("security.auth.two_factor.missing")}
225
+ </InlineNotice>
226
+ ) : null}
227
+
228
+ <SgPanel
229
+ borderStyle="none"
230
+ className={`rounded-[1.5rem] px-3 py-4 ${
231
+ isDarkMode ? "bg-slate-950/70" : "bg-slate-50/70"
232
+ }`}
233
+ >
234
+ <SgInputOTP
235
+ id="security-two-factor-code"
236
+ control={otpControl}
237
+ name="code"
238
+ rules={otpRules}
239
+ label={translate("security.auth.two_factor.code")}
240
+ mask="### ###"
241
+ inputProps={otpInputProps}
242
+ onRawChange={(rawValue) => {
243
+ if (normalizeCode(rawValue).length < TWO_FACTOR_CODE_LENGTH) {
244
+ lastSubmittedCodeRef.current = null;
245
+ }
246
+ }}
247
+ onComplete={(value) => {
248
+ void handleAutoSubmit(value);
249
+ }}
250
+ groupClassName="justify-center"
251
+ />
252
+ </SgPanel>
253
+
254
+ <SgStack direction="row" justify="between" align="center" gap={12}>
255
+ <SgButton
256
+ appearance="outline"
257
+ severity="secondary"
258
+ shape="rounded"
259
+ label={translate("security.common.back")}
260
+ onClick={() => {
261
+ window.location.assign(securityConfig.routes.login);
262
+ }}
263
+ />
264
+
265
+ {canResendEmail ? (
266
+ <SgButton
267
+ appearance="ghost"
268
+ severity="primary"
269
+ shape="rounded"
270
+ label={translate("security.auth.two_factor.resend")}
271
+ loading={resending}
272
+ disabled={resending || pending || !auth.pendingTwoFactor}
273
+ onClick={() => {
274
+ void handleResendEmail();
275
+ }}
276
+ />
277
+ ) : (
278
+ <div />
279
+ )}
280
+ </SgStack>
281
+ </SgStack>
282
+ </form>
283
+ </SgCard>
284
+ </div>
285
+ );
286
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ import { SgButton, SgPanel, SgStack } from "@seedgrid/fe-components";
6
+ import { useRouter, useSearchParams } from "next/navigation";
7
+
8
+ import { useI18n } from "@/i18n";
9
+ import {
10
+ InlineNotice,
11
+ PageFrame,
12
+ authService,
13
+ extractApiMessage,
14
+ securityConfig,
15
+ } from "@/modules/security";
16
+
17
+ export default function ForgotPasswordConfirmPage() {
18
+ const router = useRouter();
19
+ const searchParams = useSearchParams();
20
+ const { t } = useI18n();
21
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
22
+
23
+ useEffect(() => {
24
+ const token = searchParams.get("token")?.trim();
25
+
26
+ if (!token) {
27
+ setErrorMessage(t("security.forgot_password.confirm.invalid_token"));
28
+ return;
29
+ }
30
+
31
+ let active = true;
32
+
33
+ void (async () => {
34
+ try {
35
+ await authService.confirmPasswordReset(token);
36
+
37
+ if (!active) {
38
+ return;
39
+ }
40
+
41
+ router.replace(securityConfig.routes.forgotPasswordSuccess);
42
+ } catch (error) {
43
+ if (!active) {
44
+ return;
45
+ }
46
+
47
+ setErrorMessage(
48
+ extractApiMessage(error) ?? t("security.forgot_password.confirm.error")
49
+ );
50
+ }
51
+ })();
52
+
53
+ return () => {
54
+ active = false;
55
+ };
56
+ }, [router, searchParams, t]);
57
+
58
+ return (
59
+ <PageFrame
60
+ title={t("security.forgot_password.confirm.title")}
61
+ description={t("security.forgot_password.confirm.description")}
62
+ >
63
+ {errorMessage ? (
64
+ <SgPanel borderStyle="none" className="mx-auto w-full max-w-xl">
65
+ <SgStack gap={16}>
66
+ <InlineNotice tone="danger">{errorMessage}</InlineNotice>
67
+ <SgStack direction="row" wrap gap={12}>
68
+ <SgButton
69
+ appearance="solid"
70
+ severity="primary"
71
+ shape="rounded"
72
+ label={t("security.forgot_password.confirm.request_new")}
73
+ onClick={() => {
74
+ window.location.assign(securityConfig.routes.forgotPassword);
75
+ }}
76
+ />
77
+ <SgButton
78
+ appearance="outline"
79
+ severity="secondary"
80
+ shape="rounded"
81
+ label={t("security.forgot_password.success_page.enter")}
82
+ onClick={() => {
83
+ window.location.assign(securityConfig.routes.login);
84
+ }}
85
+ />
86
+ </SgStack>
87
+ </SgStack>
88
+ </SgPanel>
89
+ ) : (
90
+ <SgPanel borderStyle="none" className="mx-auto w-full max-w-xl">
91
+ <InlineNotice>{t("security.forgot_password.confirm.loading")}</InlineNotice>
92
+ </SgPanel>
93
+ )}
94
+ </PageFrame>
95
+ );
96
+ }