@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,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
import { buildCrudPermissions } from "@seedgrid/fe-security";
|
|
6
|
+
import { useAuth } from "./useAuth";
|
|
7
|
+
|
|
8
|
+
export function usePermission(permission: string) {
|
|
9
|
+
const auth = useAuth();
|
|
10
|
+
|
|
11
|
+
return auth.hasPermission(permission);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useCrudPermission(resource: string) {
|
|
15
|
+
const auth = useAuth();
|
|
16
|
+
|
|
17
|
+
return useMemo(() => {
|
|
18
|
+
const permissions = buildCrudPermissions(resource);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...permissions,
|
|
22
|
+
canRead: auth.hasPermission(permissions.read),
|
|
23
|
+
canCreate: auth.hasPermission(permissions.create),
|
|
24
|
+
canUpdate: auth.hasPermission(permissions.update),
|
|
25
|
+
canDelete: auth.hasPermission(permissions.delete),
|
|
26
|
+
isReadOnly:
|
|
27
|
+
auth.hasPermission(permissions.read) &&
|
|
28
|
+
!auth.hasAnyPermission([
|
|
29
|
+
permissions.create,
|
|
30
|
+
permissions.update,
|
|
31
|
+
permissions.delete,
|
|
32
|
+
]),
|
|
33
|
+
};
|
|
34
|
+
}, [auth, resource]);
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { appEnvironment } from "@/config/environment";
|
|
6
|
+
|
|
7
|
+
export type SecurityHostKind = "local" | "master" | "tenant" | "unknown";
|
|
8
|
+
|
|
9
|
+
export function resolveSecurityHostKind(hostname: string): SecurityHostKind {
|
|
10
|
+
const normalizedHostname = hostname.trim().toLowerCase();
|
|
11
|
+
const masterHost = appEnvironment.hosts.masterAppHost.trim().toLowerCase();
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
!normalizedHostname ||
|
|
15
|
+
normalizedHostname === "localhost" ||
|
|
16
|
+
normalizedHostname === "127.0.0.1" ||
|
|
17
|
+
normalizedHostname.endsWith(".local")
|
|
18
|
+
) {
|
|
19
|
+
return "local";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (normalizedHostname === masterHost) {
|
|
23
|
+
return "master";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (masterHost && normalizedHostname.endsWith(`.${masterHost}`)) {
|
|
27
|
+
return "tenant";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isCompanyManagementEnabledForHostKind(
|
|
34
|
+
hostKind: SecurityHostKind
|
|
35
|
+
) {
|
|
36
|
+
return hostKind !== "master";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useSecurityHostKind() {
|
|
40
|
+
const [hostKind, setHostKind] = useState<SecurityHostKind | null>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (typeof window === "undefined") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setHostKind(resolveSecurityHostKind(window.location.hostname));
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return hostKind;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useCompanyManagementEnabledForCurrentHost() {
|
|
54
|
+
const hostKind = useSecurityHostKind();
|
|
55
|
+
|
|
56
|
+
return hostKind == null
|
|
57
|
+
? null
|
|
58
|
+
: isCompanyManagementEnabledForHostKind(hostKind);
|
|
59
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
export { AuthProvider, useAuthContext } from "./AuthProvider";
|
|
4
|
+
export { useAuth } from "./hooks/useAuth";
|
|
5
|
+
export { useCrudPermission, usePermission } from "./hooks/usePermission";
|
|
6
|
+
export { useDebouncedSearch } from "./hooks/useDebouncedSearch";
|
|
7
|
+
export { AppShell } from "./components/AppShell";
|
|
8
|
+
export { CompanyManagementGuard } from "./components/CompanyManagementGuard";
|
|
9
|
+
export { PageFrame, InlineNotice } from "./components/PageFrame";
|
|
10
|
+
export { RouteGuard } from "./components/RouteGuard";
|
|
11
|
+
export { securityConfig } from "./config";
|
|
12
|
+
export {
|
|
13
|
+
isCompanyManagementEnabledForHostKind,
|
|
14
|
+
resolveSecurityHostKind,
|
|
15
|
+
useCompanyManagementEnabledForCurrentHost,
|
|
16
|
+
useSecurityHostKind,
|
|
17
|
+
} from "./host-access";
|
|
18
|
+
export { resolvePostAuthPath } from "./navigation";
|
|
19
|
+
export { useSecurityAppShellSections } from "./useSecurityAppShellSections";
|
|
20
|
+
export { authService } from "./services/auth-service";
|
|
21
|
+
export {
|
|
22
|
+
classifySelfEnterInvitationAdminError,
|
|
23
|
+
classifySelfEnterInvitationError,
|
|
24
|
+
isSelfEnterInvitationEmailMismatchError,
|
|
25
|
+
securityService,
|
|
26
|
+
} from "./services/security-service";
|
|
27
|
+
export {
|
|
28
|
+
extractApiMessage,
|
|
29
|
+
isApiClientError,
|
|
30
|
+
} from "./services/security-api";
|
|
31
|
+
export type * from "./types";
|
|
32
|
+
|
|
33
|
+
// Promovidos pra lib versionada (@seedgrid/fe-security) — ver ADR 0004/0005 e
|
|
34
|
+
// scaffold/README.md pro criterio lib vs scaffold usado nesta extracao.
|
|
35
|
+
export { PickListField, StatusBadge } from "@seedgrid/fe-security/client";
|
|
36
|
+
export {
|
|
37
|
+
buildCrudPermissions,
|
|
38
|
+
createPermissionSet,
|
|
39
|
+
hasAllPermissions,
|
|
40
|
+
hasAnyPermission,
|
|
41
|
+
hasPermission,
|
|
42
|
+
normalizePermission,
|
|
43
|
+
securityMessages,
|
|
44
|
+
} from "@seedgrid/fe-security";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { securityConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
// seedgrid:post-auth-resolvers-imports:start
|
|
4
|
+
// seedgrid:post-auth-resolvers-imports:end
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve pra onde mandar o usuário logo após um login bem-sucedido. Devolve
|
|
8
|
+
* a rota, ou `null` pra deixar o próximo resolver (ou o dashboard) decidir.
|
|
9
|
+
* Pode ser síncrono ou assíncrono (ex.: consultar as empresas do usuário).
|
|
10
|
+
*/
|
|
11
|
+
export type PostAuthResolver = (
|
|
12
|
+
permissions: Iterable<string>,
|
|
13
|
+
) => Promise<string | null> | string | null;
|
|
14
|
+
|
|
15
|
+
// Módulos registram seus resolvers aqui via o campo `postAuthResolver` do
|
|
16
|
+
// module.ts (o CLI insere no marcador abaixo). Ex.: login-tenancy manda pro
|
|
17
|
+
// select-company quando o usuário tem mais de uma empresa.
|
|
18
|
+
const postAuthResolvers: PostAuthResolver[] = [
|
|
19
|
+
// seedgrid:post-auth-resolvers-register:start
|
|
20
|
+
// seedgrid:post-auth-resolvers-register:end
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export async function resolvePostAuthPath(
|
|
24
|
+
permissions: Iterable<string>,
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const permissionList = Array.from(permissions);
|
|
27
|
+
|
|
28
|
+
for (const resolver of postAuthResolvers) {
|
|
29
|
+
try {
|
|
30
|
+
const path = await resolver(permissionList);
|
|
31
|
+
if (path) {
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Um resolver que falha não pode travar o login — segue pro próximo
|
|
36
|
+
// (e, no fim, pro dashboard).
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return securityConfig.routes.dashboard;
|
|
41
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { getSecuritySessionServer } from "./session-server";
|
|
4
|
+
|
|
5
|
+
// Proxy do avatar do usuário: repassa o binário do report-api com a sessão do
|
|
6
|
+
// usuário (cookie→Bearer, refresh em 401). Usa o MESMO motor de sessão
|
|
7
|
+
// (createSecurityServer, via getSecuritySessionServer) que o resto do app —
|
|
8
|
+
// `fetchWithSession` devolve a Response crua, ideal pra repassar bytes.
|
|
9
|
+
export async function createUserAvatarRouteResponse(
|
|
10
|
+
publicId: string,
|
|
11
|
+
version: string | null
|
|
12
|
+
) {
|
|
13
|
+
const normalizedPublicId = publicId.trim();
|
|
14
|
+
|
|
15
|
+
if (!normalizedPublicId) {
|
|
16
|
+
return NextResponse.json(
|
|
17
|
+
{
|
|
18
|
+
message: "User publicId is required.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
status: 400,
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const query = version ? `?v=${encodeURIComponent(version)}` : "";
|
|
27
|
+
const path = `/users/${encodeURIComponent(normalizedPublicId)}/avatar${query}`;
|
|
28
|
+
|
|
29
|
+
let upstreamResponse: Response;
|
|
30
|
+
try {
|
|
31
|
+
const session = await getSecuritySessionServer();
|
|
32
|
+
upstreamResponse = await session.fetchWithSession(path, { cache: "no-store" });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return NextResponse.json(
|
|
35
|
+
{
|
|
36
|
+
message:
|
|
37
|
+
error instanceof Error ? error.message : "Unexpected avatar proxy error.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
status: 500,
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!upstreamResponse.ok) {
|
|
46
|
+
const text = await upstreamResponse.text();
|
|
47
|
+
const trimmed = text.trim();
|
|
48
|
+
|
|
49
|
+
if (!trimmed) {
|
|
50
|
+
return new NextResponse(null, { status: upstreamResponse.status });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const contentType =
|
|
54
|
+
upstreamResponse.headers.get("content-type") ?? "text/plain; charset=utf-8";
|
|
55
|
+
|
|
56
|
+
return new NextResponse(trimmed, {
|
|
57
|
+
status: upstreamResponse.status,
|
|
58
|
+
headers: { "content-type": contentType },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headers = new Headers();
|
|
63
|
+
copyResponseHeader(upstreamResponse, headers, "content-type");
|
|
64
|
+
copyResponseHeader(upstreamResponse, headers, "content-disposition");
|
|
65
|
+
copyResponseHeader(upstreamResponse, headers, "content-length");
|
|
66
|
+
copyResponseHeader(upstreamResponse, headers, "cache-control");
|
|
67
|
+
copyResponseHeader(upstreamResponse, headers, "etag");
|
|
68
|
+
copyResponseHeader(upstreamResponse, headers, "last-modified");
|
|
69
|
+
|
|
70
|
+
return new NextResponse(await upstreamResponse.arrayBuffer(), {
|
|
71
|
+
status: upstreamResponse.status,
|
|
72
|
+
headers,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function copyResponseHeader(
|
|
77
|
+
response: Response,
|
|
78
|
+
headers: Headers,
|
|
79
|
+
headerName: string
|
|
80
|
+
) {
|
|
81
|
+
const value = response.headers.get(headerName);
|
|
82
|
+
|
|
83
|
+
if (value) {
|
|
84
|
+
headers.set(headerName, value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
|
|
5
|
+
import { resolveApiBaseUrlForHostname } from "@/config/environment";
|
|
6
|
+
|
|
7
|
+
export async function resolvePublicApiBaseUrl() {
|
|
8
|
+
const requestHeaders = await headers();
|
|
9
|
+
const hostname = resolveRequestHostname(requestHeaders);
|
|
10
|
+
|
|
11
|
+
if (!hostname) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"Could not resolve the public API base URL. Access the app through a configured domain hostname and provide the corresponding host header."
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return resolveApiBaseUrlForHostname(hostname);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveRequestHostname(requestHeaders: Headers) {
|
|
21
|
+
const forwardedHost = requestHeaders.get("x-forwarded-host")?.trim();
|
|
22
|
+
const hostHeader = requestHeaders.get("host")?.trim();
|
|
23
|
+
const candidate = forwardedHost || hostHeader || "";
|
|
24
|
+
|
|
25
|
+
return candidate.replace(/:\d+$/, "");
|
|
26
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { resolveApiBaseUrlForHostname } from "@/config/environment";
|
|
4
|
+
import { ApiClientError, createApiClient } from "@seedgrid/fe-core";
|
|
5
|
+
|
|
6
|
+
const REFRESH_COOKIE_NAME =
|
|
7
|
+
process.env.SEEDGRID_SECURITY_REFRESH_COOKIE_NAME?.trim() ||
|
|
8
|
+
"sg_{{project_slug}}_refresh_token";
|
|
9
|
+
const ACCESS_COOKIE_NAME =
|
|
10
|
+
process.env.SEEDGRID_SECURITY_ACCESS_COOKIE_NAME?.trim() ||
|
|
11
|
+
"sg_{{project_slug}}_access_token";
|
|
12
|
+
const REFRESH_COOKIE_MAX_AGE_SECONDS = resolveRefreshCookieMaxAge();
|
|
13
|
+
|
|
14
|
+
export async function createLoginRouteResponse(request: NextRequest) {
|
|
15
|
+
const body = await request.json();
|
|
16
|
+
|
|
17
|
+
return forwardPublicAuthRequest(request, "/public/auth/login", {
|
|
18
|
+
body,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function createVerifyTwoFactorRouteResponse(request: NextRequest) {
|
|
23
|
+
const body = await request.json();
|
|
24
|
+
|
|
25
|
+
return forwardPublicAuthRequest(request, "/public/2f/verify", {
|
|
26
|
+
body,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function createResendTwoFactorRouteResponse(request: NextRequest) {
|
|
31
|
+
const body = await request.json();
|
|
32
|
+
|
|
33
|
+
return forwardPublicAuthRequest(request, "/public/2f/resend", {
|
|
34
|
+
body,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const pendingRefreshResponses = new Map<string, Promise<NextResponse>>();
|
|
39
|
+
|
|
40
|
+
export async function createRefreshRouteResponse(request: NextRequest) {
|
|
41
|
+
const refreshToken = request.cookies.get(REFRESH_COOKIE_NAME)?.value?.trim();
|
|
42
|
+
|
|
43
|
+
if (!refreshToken) {
|
|
44
|
+
return createUnauthorizedRouteResponse();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const existing = pendingRefreshResponses.get(refreshToken);
|
|
48
|
+
|
|
49
|
+
if (existing) {
|
|
50
|
+
return existing;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const refreshPromise = (async () => {
|
|
54
|
+
try {
|
|
55
|
+
const authClient = createAuthClient(request);
|
|
56
|
+
const payload = await authClient.post<unknown>("/public/auth/refresh", {
|
|
57
|
+
body: {
|
|
58
|
+
refreshToken,
|
|
59
|
+
},
|
|
60
|
+
requireAuth: false,
|
|
61
|
+
retry: false,
|
|
62
|
+
cache: "no-store",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return createAuthSuccessResponse(payload);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return createAuthErrorResponse(error, true);
|
|
68
|
+
}
|
|
69
|
+
})().finally(() => {
|
|
70
|
+
pendingRefreshResponses.delete(refreshToken);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pendingRefreshResponses.set(refreshToken, refreshPromise);
|
|
74
|
+
|
|
75
|
+
return refreshPromise;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createLogoutRouteResponse() {
|
|
79
|
+
const response = NextResponse.json(
|
|
80
|
+
{
|
|
81
|
+
ok: true,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
status: 200,
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
clearRefreshTokenCookie(response);
|
|
89
|
+
clearAccessTokenCookie(response);
|
|
90
|
+
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function forwardPublicAuthRequest(
|
|
95
|
+
request: NextRequest,
|
|
96
|
+
path: string,
|
|
97
|
+
options: {
|
|
98
|
+
body?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
) {
|
|
101
|
+
try {
|
|
102
|
+
const authClient = createAuthClient(request);
|
|
103
|
+
const payload = await authClient.post<unknown>(path, {
|
|
104
|
+
body: options.body,
|
|
105
|
+
requireAuth: false,
|
|
106
|
+
retry: false,
|
|
107
|
+
cache: "no-store",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return createAuthSuccessResponse(payload);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return createAuthErrorResponse(error, false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createAuthClient(request: NextRequest) {
|
|
117
|
+
return createApiClient({
|
|
118
|
+
baseUrl: resolveApiBaseUrlForHostname(resolveRequestHostname(request)),
|
|
119
|
+
defaultCache: "no-store",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveRequestHostname(request: NextRequest) {
|
|
124
|
+
const forwardedHost = request.headers.get("x-forwarded-host")?.trim();
|
|
125
|
+
const hostHeader = request.headers.get("host")?.trim();
|
|
126
|
+
const candidate = forwardedHost || hostHeader || request.nextUrl.hostname;
|
|
127
|
+
|
|
128
|
+
return candidate.replace(/:\d+$/, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createAuthSuccessResponse(payload: unknown) {
|
|
132
|
+
const response = NextResponse.json(stripRefreshToken(payload), {
|
|
133
|
+
status: 200,
|
|
134
|
+
});
|
|
135
|
+
const refreshToken = pickString(payload, "refreshToken");
|
|
136
|
+
const accessToken = pickString(payload, "accessToken") ?? pickString(payload, "token");
|
|
137
|
+
const expiresIn = pickNumber(payload, "expiresIn");
|
|
138
|
+
|
|
139
|
+
if (refreshToken) {
|
|
140
|
+
setRefreshTokenCookie(response, refreshToken);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (accessToken) {
|
|
144
|
+
setAccessTokenCookie(response, accessToken, expiresIn ?? undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createUnauthorizedRouteResponse() {
|
|
151
|
+
const response = NextResponse.json(
|
|
152
|
+
{
|
|
153
|
+
message: "Refresh token is missing or expired.",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
status: 401,
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
clearRefreshTokenCookie(response);
|
|
161
|
+
clearAccessTokenCookie(response);
|
|
162
|
+
|
|
163
|
+
return response;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function createAuthErrorResponse(error: unknown, clearCookieOnUnauthorized: boolean) {
|
|
167
|
+
if (error instanceof ApiClientError) {
|
|
168
|
+
const response = NextResponse.json(
|
|
169
|
+
normalizeErrorBody(error.responseBody, error.message),
|
|
170
|
+
{
|
|
171
|
+
status: error.status,
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (clearCookieOnUnauthorized && error.status === 401) {
|
|
176
|
+
clearRefreshTokenCookie(response);
|
|
177
|
+
clearAccessTokenCookie(response);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return response;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = NextResponse.json(
|
|
184
|
+
{
|
|
185
|
+
message: error instanceof Error ? error.message : "Unexpected auth error.",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
status: 500,
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (clearCookieOnUnauthorized) {
|
|
193
|
+
clearRefreshTokenCookie(response);
|
|
194
|
+
clearAccessTokenCookie(response);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return response;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function setRefreshTokenCookie(response: NextResponse, refreshToken: string) {
|
|
201
|
+
response.cookies.set({
|
|
202
|
+
name: REFRESH_COOKIE_NAME,
|
|
203
|
+
value: refreshToken,
|
|
204
|
+
httpOnly: true,
|
|
205
|
+
secure: process.env.NODE_ENV === "production",
|
|
206
|
+
sameSite: "lax",
|
|
207
|
+
path: "/",
|
|
208
|
+
maxAge: REFRESH_COOKIE_MAX_AGE_SECONDS,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function setAccessTokenCookie(
|
|
213
|
+
response: NextResponse,
|
|
214
|
+
accessToken: string,
|
|
215
|
+
expiresIn?: number
|
|
216
|
+
) {
|
|
217
|
+
response.cookies.set({
|
|
218
|
+
name: ACCESS_COOKIE_NAME,
|
|
219
|
+
value: accessToken,
|
|
220
|
+
httpOnly: true,
|
|
221
|
+
secure: process.env.NODE_ENV === "production",
|
|
222
|
+
sameSite: "lax",
|
|
223
|
+
path: "/",
|
|
224
|
+
maxAge: expiresIn ?? 900,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function clearRefreshTokenCookie(response: NextResponse) {
|
|
229
|
+
response.cookies.set({
|
|
230
|
+
name: REFRESH_COOKIE_NAME,
|
|
231
|
+
value: "",
|
|
232
|
+
httpOnly: true,
|
|
233
|
+
secure: process.env.NODE_ENV === "production",
|
|
234
|
+
sameSite: "lax",
|
|
235
|
+
path: "/",
|
|
236
|
+
maxAge: 0,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function clearAccessTokenCookie(response: NextResponse) {
|
|
241
|
+
response.cookies.set({
|
|
242
|
+
name: ACCESS_COOKIE_NAME,
|
|
243
|
+
value: "",
|
|
244
|
+
httpOnly: true,
|
|
245
|
+
secure: process.env.NODE_ENV === "production",
|
|
246
|
+
sameSite: "lax",
|
|
247
|
+
path: "/",
|
|
248
|
+
maxAge: 0,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function stripRefreshToken(payload: unknown) {
|
|
253
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
254
|
+
return payload;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const record = payload as Record<string, unknown>;
|
|
258
|
+
const { refreshToken: _refreshToken, ...rest } = record;
|
|
259
|
+
|
|
260
|
+
return rest;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeErrorBody(body: unknown, fallbackMessage: string) {
|
|
264
|
+
if (!body) {
|
|
265
|
+
return {
|
|
266
|
+
message: fallbackMessage,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (typeof body === "string") {
|
|
271
|
+
return {
|
|
272
|
+
message: body,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (typeof body === "object") {
|
|
277
|
+
return body;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
message: fallbackMessage,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pickString(value: unknown, key: string) {
|
|
286
|
+
if (!value || typeof value !== "object") {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const candidate = (value as Record<string, unknown>)[key];
|
|
291
|
+
|
|
292
|
+
return typeof candidate === "string" && candidate.trim().length > 0
|
|
293
|
+
? candidate.trim()
|
|
294
|
+
: null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function pickNumber(value: unknown, key: string) {
|
|
298
|
+
if (!value || typeof value !== "object") {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const candidate = (value as Record<string, unknown>)[key];
|
|
303
|
+
|
|
304
|
+
return typeof candidate === "number" && Number.isFinite(candidate)
|
|
305
|
+
? candidate
|
|
306
|
+
: null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resolveRefreshCookieMaxAge() {
|
|
310
|
+
const configuredValue =
|
|
311
|
+
process.env.SEEDGRID_SECURITY_REFRESH_COOKIE_MAX_AGE_SECONDS?.trim() ?? "";
|
|
312
|
+
|
|
313
|
+
if (!configuredValue) {
|
|
314
|
+
return 60 * 60 * 24 * 30;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const parsedValue = Number.parseInt(configuredValue, 10);
|
|
318
|
+
|
|
319
|
+
return Number.isFinite(parsedValue) && parsedValue > 0
|
|
320
|
+
? parsedValue
|
|
321
|
+
: 60 * 60 * 24 * 30;
|
|
322
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
// Instância do motor de sessão do fe-security (createSecurityServer) com a
|
|
4
|
+
// config de cookie do app. É o MESMO motor que os módulos compostos
|
|
5
|
+
// (billing/tenancy/backup) usam via os seus `server.ts` — o próprio security
|
|
6
|
+
// passa a usá-lo também (ex.: proxy de avatar), em vez do antigo
|
|
7
|
+
// `createServerApiClient` (removido). Um motor só de fetch autenticado.
|
|
8
|
+
|
|
9
|
+
import { createSecurityServer } from "@seedgrid/fe-security/server";
|
|
10
|
+
|
|
11
|
+
import { resolvePublicApiBaseUrl } from "./public-api-base-url";
|
|
12
|
+
|
|
13
|
+
const ACCESS_COOKIE_NAME =
|
|
14
|
+
process.env.SEEDGRID_SECURITY_ACCESS_COOKIE_NAME?.trim() ||
|
|
15
|
+
"sg_{{project_slug}}_access_token";
|
|
16
|
+
const REFRESH_COOKIE_NAME =
|
|
17
|
+
process.env.SEEDGRID_SECURITY_REFRESH_COOKIE_NAME?.trim() ||
|
|
18
|
+
"sg_{{project_slug}}_refresh_token";
|
|
19
|
+
const PASSWORD_CHANGE_REQUIRED_COOKIE_NAME = "sg_{{project_slug}}_password_change_required";
|
|
20
|
+
|
|
21
|
+
export async function getSecuritySessionServer() {
|
|
22
|
+
return createSecurityServer({
|
|
23
|
+
apiBaseUrl: await resolvePublicApiBaseUrl(),
|
|
24
|
+
cookies: {
|
|
25
|
+
token: ACCESS_COOKIE_NAME,
|
|
26
|
+
refresh: REFRESH_COOKIE_NAME,
|
|
27
|
+
passwordChangeRequired: PASSWORD_CHANGE_REQUIRED_COOKIE_NAME,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|