@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.
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,281 @@
1
+ import {
2
+ ApiClientError,
3
+ createApiClient,
4
+ extractApiErrorMessage,
5
+ type ApiRequestOptions,
6
+ } from "@/modules/core";
7
+ import { getMessages, translate } from "@/i18n";
8
+
9
+ import { securityConfig } from "../config";
10
+
11
+ type AuthRuntime = {
12
+ getAccessToken: () => Promise<string | null>;
13
+ refreshAccessToken: () => Promise<string | null>;
14
+ onAuthFailure: () => void | Promise<void>;
15
+ };
16
+
17
+ const runtime: AuthRuntime = {
18
+ getAccessToken: async () => null,
19
+ refreshAccessToken: async () => null,
20
+ onAuthFailure: async () => {},
21
+ };
22
+
23
+ let pendingRefresh: Promise<string | null> | null = null;
24
+
25
+ const privateClient = createApiClient({
26
+ baseUrl: securityConfig.apiBaseUrl,
27
+ getAccessToken: () => runtime.getAccessToken(),
28
+ refreshAccessToken: () => {
29
+ if (!pendingRefresh) {
30
+ pendingRefresh = runtime.refreshAccessToken().finally(() => {
31
+ pendingRefresh = null;
32
+ });
33
+ }
34
+ return pendingRefresh;
35
+ },
36
+ onAuthFailure: () => runtime.onAuthFailure(),
37
+ });
38
+
39
+ const publicClient = createApiClient({
40
+ baseUrl: securityConfig.apiBaseUrl,
41
+ });
42
+
43
+ export function configureSecurityApi(nextRuntime: Partial<AuthRuntime>) {
44
+ if (typeof nextRuntime.getAccessToken === "function") {
45
+ runtime.getAccessToken = nextRuntime.getAccessToken;
46
+ }
47
+
48
+ if (typeof nextRuntime.refreshAccessToken === "function") {
49
+ runtime.refreshAccessToken = nextRuntime.refreshAccessToken;
50
+ }
51
+
52
+ if (typeof nextRuntime.onAuthFailure === "function") {
53
+ runtime.onAuthFailure = nextRuntime.onAuthFailure;
54
+ }
55
+ }
56
+
57
+ export const securityApi = {
58
+ get: <T = unknown>(path: string, options?: ApiRequestOptions) =>
59
+ privateClient.get<T>(path, options),
60
+ post: <T = unknown>(path: string, options?: ApiRequestOptions) =>
61
+ privateClient.post<T>(path, options),
62
+ put: <T = unknown>(path: string, options?: ApiRequestOptions) =>
63
+ privateClient.put<T>(path, options),
64
+ patch: <T = unknown>(path: string, options?: ApiRequestOptions) =>
65
+ privateClient.patch<T>(path, options),
66
+ delete: <T = unknown>(path: string, options?: ApiRequestOptions) =>
67
+ privateClient.delete<T>(path, options),
68
+ public: {
69
+ get: <T = unknown>(path: string, options?: ApiRequestOptions) =>
70
+ publicClient.get<T>(path, {
71
+ ...options,
72
+ requireAuth: false,
73
+ }),
74
+ post: <T = unknown>(path: string, options?: ApiRequestOptions) =>
75
+ publicClient.post<T>(path, {
76
+ ...options,
77
+ requireAuth: false,
78
+ }),
79
+ put: <T = unknown>(path: string, options?: ApiRequestOptions) =>
80
+ publicClient.put<T>(path, {
81
+ ...options,
82
+ requireAuth: false,
83
+ }),
84
+ patch: <T = unknown>(path: string, options?: ApiRequestOptions) =>
85
+ publicClient.patch<T>(path, {
86
+ ...options,
87
+ requireAuth: false,
88
+ }),
89
+ delete: <T = unknown>(path: string, options?: ApiRequestOptions) =>
90
+ publicClient.delete<T>(path, {
91
+ ...options,
92
+ requireAuth: false,
93
+ }),
94
+ },
95
+ };
96
+
97
+ export function isApiClientError(error: unknown): error is ApiClientError {
98
+ return error instanceof ApiClientError;
99
+ }
100
+
101
+ export function extractApiMessage(error: unknown) {
102
+ if (isApiClientError(error)) {
103
+ const message = readApiMessage(error.responseBody) ?? extractApiErrorMessage(error);
104
+ return normalizeReadableErrorMessage(message);
105
+ }
106
+
107
+ // Erros que atravessam a fronteira server->client (server actions/RSC) sao
108
+ // serializados e PERDEM o prototype de ApiClientError, entao `instanceof`
109
+ // falha aqui. Mas o formato { responseBody } / { cause: { responseBody } }
110
+ // sobrevive — le dele o problem+json (violations/detail/...) antes de cair no
111
+ // fallback generico, senao o usuario veria "API request failed with status
112
+ // 400" em vez da mensagem de validacao real.
113
+ const duckTypedMessage = readDuckTypedApiMessage(error);
114
+ if (duckTypedMessage) {
115
+ return normalizeReadableErrorMessage(duckTypedMessage);
116
+ }
117
+
118
+ return normalizeReadableErrorMessage(extractApiErrorMessage(error));
119
+ }
120
+
121
+ // Le a mensagem do corpo de um erro "parecido com ApiClientError" (sem depender
122
+ // de instanceof): tenta `error.responseBody` e, em seguida, `error.cause.responseBody`
123
+ // (o body pode vir ja como objeto ou como string JSON — readApiMessage trata os dois).
124
+ function readDuckTypedApiMessage(error: unknown): string | null {
125
+ if (!error || typeof error !== "object") {
126
+ return null;
127
+ }
128
+
129
+ const record = error as Record<string, unknown>;
130
+ const fromBody = readApiMessage(record.responseBody);
131
+ if (fromBody) {
132
+ return fromBody;
133
+ }
134
+
135
+ if (record.cause && typeof record.cause === "object") {
136
+ const cause = record.cause as Record<string, unknown>;
137
+ const fromCause = readApiMessage(cause.responseBody);
138
+ if (fromCause) {
139
+ return fromCause;
140
+ }
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ function readApiMessage(body: unknown): string | null {
147
+ const normalizedBody = normalizeApiBody(body);
148
+
149
+ if (!normalizedBody || typeof normalizedBody !== "object") {
150
+ return pickString(normalizedBody);
151
+ }
152
+
153
+ const record = normalizedBody as Record<string, unknown>;
154
+
155
+ if (Array.isArray(record.violations)) {
156
+ for (const entry of record.violations) {
157
+ const violationMessage = readRecordMessage(entry);
158
+
159
+ if (violationMessage) {
160
+ return violationMessage;
161
+ }
162
+ }
163
+ }
164
+
165
+ if (Array.isArray(record.errors)) {
166
+ for (const entry of record.errors) {
167
+ const nestedMessage = readRecordMessage(entry);
168
+
169
+ if (nestedMessage) {
170
+ return nestedMessage;
171
+ }
172
+ }
173
+ }
174
+
175
+ return (
176
+ pickString(record.userMessage) ??
177
+ pickString(record.detail) ??
178
+ pickString(record.message) ??
179
+ pickString(record.error) ??
180
+ pickString(record.title)
181
+ );
182
+ }
183
+
184
+ function normalizeApiBody(body: unknown) {
185
+ if (typeof body !== "string") {
186
+ return body;
187
+ }
188
+
189
+ const normalized = body.trim();
190
+
191
+ if (!normalized) {
192
+ return body;
193
+ }
194
+
195
+ try {
196
+ return JSON.parse(normalized) as unknown;
197
+ } catch {
198
+ return body;
199
+ }
200
+ }
201
+
202
+ function readRecordMessage(value: unknown) {
203
+ if (!value || typeof value !== "object") {
204
+ return null;
205
+ }
206
+
207
+ const record = value as Record<string, unknown>;
208
+
209
+ return (
210
+ pickString(record.userMessage) ??
211
+ pickString(record.message) ??
212
+ pickString(record.error) ??
213
+ pickString(record.description)
214
+ );
215
+ }
216
+
217
+ function normalizeReadableErrorMessage(message: string | null) {
218
+ const normalizedMessage = pickString(message);
219
+
220
+ if (!normalizedMessage) {
221
+ return null;
222
+ }
223
+
224
+ if (isServerConnectionError(normalizedMessage)) {
225
+ return translate(
226
+ getMessages(resolveRuntimeLocale()),
227
+ "security.common.server_connection_error"
228
+ );
229
+ }
230
+
231
+ if (isHtmlContent(normalizedMessage)) {
232
+ return null;
233
+ }
234
+
235
+ return normalizedMessage;
236
+ }
237
+
238
+ function isHtmlContent(message: string) {
239
+ const trimmed = message.trimStart();
240
+
241
+ return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
242
+ }
243
+
244
+ function isServerConnectionError(message: string) {
245
+ const normalized = message.trim().toLowerCase();
246
+
247
+ return (
248
+ normalized === "fetch failed" ||
249
+ normalized === "failed to fetch" ||
250
+ normalized.includes("networkerror") ||
251
+ normalized.includes("network request failed") ||
252
+ normalized.includes("load failed")
253
+ );
254
+ }
255
+
256
+ function resolveRuntimeLocale() {
257
+ if (typeof document !== "undefined") {
258
+ const documentLocale = document.documentElement.lang?.trim();
259
+
260
+ if (documentLocale) {
261
+ return documentLocale;
262
+ }
263
+ }
264
+
265
+ if (typeof navigator !== "undefined") {
266
+ const browserLocale =
267
+ navigator.language?.trim() || navigator.languages?.[0]?.trim();
268
+
269
+ if (browserLocale) {
270
+ return browserLocale;
271
+ }
272
+ }
273
+
274
+ return undefined;
275
+ }
276
+
277
+ function pickString(value: unknown) {
278
+ return typeof value === "string" && value.trim().length > 0
279
+ ? value.trim()
280
+ : null;
281
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ApiClientError } from "@/modules/core";
4
+
5
+ import {
6
+ classifySelfEnterInvitationAdminError,
7
+ classifySelfEnterInvitationError,
8
+ isSelfEnterInvitationEmailMismatchError,
9
+ } from "./security-service";
10
+
11
+ describe("classifySelfEnterInvitationError", () => {
12
+ it("classifies status 409 responses as consumed invitations", () => {
13
+ const error = new ApiClientError("API request failed with status 409", 409, {
14
+ detail: "Invitation already used.",
15
+ });
16
+
17
+ expect(classifySelfEnterInvitationError(error)).toBe("consumed");
18
+ });
19
+
20
+ it("classifies expired invitation messages as invalid", () => {
21
+ const error = new ApiClientError("API request failed with status 400", 400, {
22
+ detail: "Convite expirado.",
23
+ });
24
+
25
+ expect(classifySelfEnterInvitationError(error)).toBe("invalid");
26
+ });
27
+
28
+ it("returns unknown when the backend error is not recognized", () => {
29
+ const error = new ApiClientError("API request failed with status 400", 400, {
30
+ detail: "Unexpected validation failure.",
31
+ });
32
+
33
+ expect(classifySelfEnterInvitationError(error)).toBe("unknown");
34
+ });
35
+ });
36
+
37
+ describe("isSelfEnterInvitationEmailMismatchError", () => {
38
+ it("detects friendly email mismatch cases", () => {
39
+ const error = new ApiClientError("API request failed with status 400", 400, {
40
+ detail: "O email nao corresponde ao convite.",
41
+ });
42
+
43
+ expect(isSelfEnterInvitationEmailMismatchError(error)).toBe(true);
44
+ });
45
+
46
+ it("ignores unrelated backend errors", () => {
47
+ const error = new ApiClientError("API request failed with status 400", 400, {
48
+ detail: "Senha obrigatoria.",
49
+ });
50
+
51
+ expect(isSelfEnterInvitationEmailMismatchError(error)).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("classifySelfEnterInvitationAdminError", () => {
56
+ it("classifies conflict responses as duplicate email problems", () => {
57
+ const error = new ApiClientError("API request failed with status 409", 409, {
58
+ detail: "An active entry request already exists for email: duplicate@test.com",
59
+ });
60
+
61
+ expect(classifySelfEnterInvitationAdminError(error)).toBe("duplicate_email");
62
+ });
63
+
64
+ it("returns unknown when the create-invitation error is not recognized", () => {
65
+ const error = new ApiClientError("API request failed with status 422", 422, {
66
+ detail: "Invitation cannot be resent.",
67
+ });
68
+
69
+ expect(classifySelfEnterInvitationAdminError(error)).toBe("unknown");
70
+ });
71
+ });