@seedgrid/fe-security 2026.6.2 → 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.
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/permission-provider.d.ts +18 -0
- package/dist/client/permission-provider.d.ts.map +1 -0
- package/dist/client/permission-provider.js +57 -0
- package/dist/client/pick-list-field.d.ts +15 -0
- package/dist/client/pick-list-field.d.ts.map +1 -0
- package/dist/client/pick-list-field.js +30 -0
- package/dist/client/status-badge.d.ts +6 -0
- package/dist/client/status-badge.d.ts.map +1 -0
- package/dist/client/status-badge.js +6 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/dtos.d.ts +88 -0
- package/dist/dtos.d.ts.map +1 -0
- package/dist/dtos.js +3 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/menu.d.ts +19 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +41 -0
- package/dist/messages.d.ts +1858 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +990 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +37 -0
- package/dist/module.json +21 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +3 -0
- package/dist/permissions.d.ts +14 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +39 -0
- package/dist/server/action.d.ts +3 -0
- package/dist/server/action.d.ts.map +1 -0
- package/dist/server/action.js +21 -0
- package/dist/server/create-security-server.d.ts +4 -0
- package/dist/server/create-security-server.d.ts.map +1 -0
- package/dist/server/create-security-server.js +166 -0
- package/dist/server/errors.d.ts +13 -0
- package/dist/server/errors.d.ts.map +1 -0
- package/dist/server/errors.js +26 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +8 -0
- package/dist/server/problem-response.d.ts +3 -0
- package/dist/server/problem-response.d.ts.map +1 -0
- package/dist/server/problem-response.js +28 -0
- package/dist/server/route.d.ts +3 -0
- package/dist/server/route.d.ts.map +1 -0
- package/dist/server/route.js +22 -0
- package/dist/server/to-problem.d.ts +3 -0
- package/dist/server/to-problem.d.ts.map +1 -0
- package/dist/server/to-problem.js +22 -0
- package/dist/server-contract.d.ts +23 -0
- package/dist/server-contract.d.ts.map +1 -0
- package/dist/server-contract.js +4 -0
- package/dist/session-contract.d.ts +36 -0
- package/dist/session-contract.d.ts.map +1 -0
- package/dist/session-contract.js +8 -0
- package/dist/session-result.d.ts +22 -0
- package/dist/session-result.d.ts.map +1 -0
- package/dist/session-result.js +14 -0
- package/package.json +38 -3
- package/scaffold/README.md +97 -0
- package/scaffold/src/app/api/security/session/2fa/resend/route.ts +8 -0
- package/scaffold/src/app/api/security/session/2fa/verify/route.ts +8 -0
- package/scaffold/src/app/api/security/session/login/route.ts +7 -0
- package/scaffold/src/app/api/security/session/logout/route.ts +5 -0
- package/scaffold/src/app/api/security/session/refresh/route.ts +7 -0
- package/scaffold/src/app/api/security/users/[publicId]/avatar/route.ts +15 -0
- package/scaffold/src/app/auth/2fa/verify/page.tsx +286 -0
- package/scaffold/src/app/auth/forgot-password/confirm/page.tsx +96 -0
- package/scaffold/src/app/auth/forgot-password/page.tsx +165 -0
- package/scaffold/src/app/auth/forgot-password/success/page.tsx +31 -0
- package/scaffold/src/app/auth/login/page.tsx +386 -0
- package/scaffold/src/app/auth/welcome/page.tsx +90 -0
- package/scaffold/src/app/login/page.tsx +7 -0
- package/scaffold/src/app/page.tsx +12 -0
- package/scaffold/src/app/security/2fa/page.tsx +370 -0
- package/scaffold/src/app/security/companies/(create)/new/page.tsx +12 -0
- package/scaffold/src/app/security/companies/(edit)/[publicId]/page.tsx +18 -0
- package/scaffold/src/app/security/companies/(list)/page.tsx +298 -0
- package/scaffold/src/app/security/companies/editors/CompanyEditor.tsx +754 -0
- package/scaffold/src/app/security/profile/page.tsx +492 -0
- package/scaffold/src/app/security/roles/(create)/new/page.tsx +11 -0
- package/scaffold/src/app/security/roles/(edit)/[publicId]/page.tsx +17 -0
- package/scaffold/src/app/security/roles/(list)/page.tsx +269 -0
- package/scaffold/src/app/security/roles/editors/RoleEditor.tsx +328 -0
- package/scaffold/src/app/security/users/(create)/new/page.tsx +10 -0
- package/scaffold/src/app/security/users/(edit)/[publicId]/page.tsx +16 -0
- package/scaffold/src/app/security/users/(list)/page.tsx +289 -0
- package/scaffold/src/app/security/users/editors/UserEditor.tsx +478 -0
- package/scaffold/src/app/users/change-password/page.tsx +172 -0
- package/scaffold/src/modules/security/AuthProvider.tsx +405 -0
- package/scaffold/src/modules/security/components/AppShell.tsx.hbs +889 -0
- package/scaffold/src/modules/security/components/CompanyManagementGuard.tsx +59 -0
- package/scaffold/src/modules/security/components/PageFrame.tsx +105 -0
- package/scaffold/src/modules/security/components/RouteGuard.tsx +114 -0
- package/scaffold/src/modules/security/components/SecurityDashboard.tsx.hbs +315 -0
- package/scaffold/src/modules/security/config.ts +55 -0
- package/scaffold/src/modules/security/hooks/useAuth.ts +7 -0
- package/scaffold/src/modules/security/hooks/useDebouncedSearch.ts +13 -0
- package/scaffold/src/modules/security/hooks/usePermission.ts +35 -0
- package/scaffold/src/modules/security/host-access.ts +59 -0
- package/scaffold/src/modules/security/index.ts +44 -0
- package/scaffold/src/modules/security/navigation.ts +41 -0
- package/scaffold/src/modules/security/server/avatar-routes.ts +86 -0
- package/scaffold/src/modules/security/server/public-api-base-url.ts +26 -0
- package/scaffold/src/modules/security/server/session-routes.ts.hbs +322 -0
- package/scaffold/src/modules/security/server/session-server.ts.hbs +30 -0
- package/scaffold/src/modules/security/services/auth-service.ts +332 -0
- package/scaffold/src/modules/security/services/security-api.test.ts +178 -0
- package/scaffold/src/modules/security/services/security-api.ts +281 -0
- package/scaffold/src/modules/security/services/security-service.test.ts +71 -0
- package/scaffold/src/modules/security/services/security-service.ts +727 -0
- package/scaffold/src/modules/security/services/session-storage.ts +80 -0
- package/scaffold/src/modules/security/types.ts +295 -0
- package/scaffold/src/modules/security/useSecurityAppShellSections.tsx.hbs +180 -0
- 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.
|
|
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,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
|
+
}
|