@seedgrid/fe-security 2026.7.0 → 2026.7.2

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 (125) hide show
  1. package/README.md +113 -0
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +5 -0
  5. package/dist/client/permission-provider.d.ts +18 -0
  6. package/dist/client/permission-provider.d.ts.map +1 -0
  7. package/dist/client/permission-provider.js +57 -0
  8. package/dist/client/pick-list-field.d.ts +15 -0
  9. package/dist/client/pick-list-field.d.ts.map +1 -0
  10. package/dist/client/pick-list-field.js +30 -0
  11. package/dist/client/status-badge.d.ts +6 -0
  12. package/dist/client/status-badge.d.ts.map +1 -0
  13. package/dist/client/status-badge.js +6 -0
  14. package/dist/config.d.ts +34 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +4 -0
  17. package/dist/dtos.d.ts +88 -0
  18. package/dist/dtos.d.ts.map +1 -0
  19. package/dist/dtos.js +3 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +10 -0
  23. package/dist/menu.d.ts +19 -0
  24. package/dist/menu.d.ts.map +1 -0
  25. package/dist/menu.js +41 -0
  26. package/dist/messages.d.ts +1858 -0
  27. package/dist/messages.d.ts.map +1 -0
  28. package/dist/messages.js +990 -0
  29. package/dist/module.d.ts +3 -0
  30. package/dist/module.d.ts.map +1 -0
  31. package/dist/module.js +37 -0
  32. package/dist/module.json +21 -0
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.d.ts.map +1 -1
  35. package/dist/paths.js +3 -0
  36. package/dist/permissions.d.ts +14 -0
  37. package/dist/permissions.d.ts.map +1 -0
  38. package/dist/permissions.js +39 -0
  39. package/dist/server/action.d.ts +3 -0
  40. package/dist/server/action.d.ts.map +1 -0
  41. package/dist/server/action.js +21 -0
  42. package/dist/server/create-security-server.d.ts +4 -0
  43. package/dist/server/create-security-server.d.ts.map +1 -0
  44. package/dist/server/create-security-server.js +166 -0
  45. package/dist/server/errors.d.ts +13 -0
  46. package/dist/server/errors.d.ts.map +1 -0
  47. package/dist/server/errors.js +26 -0
  48. package/dist/server/index.d.ts +8 -0
  49. package/dist/server/index.d.ts.map +1 -0
  50. package/dist/server/index.js +8 -0
  51. package/dist/server/problem-response.d.ts +3 -0
  52. package/dist/server/problem-response.d.ts.map +1 -0
  53. package/dist/server/problem-response.js +28 -0
  54. package/dist/server/route.d.ts +3 -0
  55. package/dist/server/route.d.ts.map +1 -0
  56. package/dist/server/route.js +22 -0
  57. package/dist/server/to-problem.d.ts +3 -0
  58. package/dist/server/to-problem.d.ts.map +1 -0
  59. package/dist/server/to-problem.js +22 -0
  60. package/dist/server-contract.d.ts +23 -0
  61. package/dist/server-contract.d.ts.map +1 -0
  62. package/dist/server-contract.js +4 -0
  63. package/dist/session-contract.d.ts +36 -0
  64. package/dist/session-contract.d.ts.map +1 -0
  65. package/dist/session-contract.js +8 -0
  66. package/dist/session-result.d.ts +22 -0
  67. package/dist/session-result.d.ts.map +1 -0
  68. package/dist/session-result.js +14 -0
  69. package/package.json +38 -3
  70. package/scaffold/README.md +97 -0
  71. package/scaffold/src/app/api/security/session/2fa/resend/route.ts +8 -0
  72. package/scaffold/src/app/api/security/session/2fa/verify/route.ts +8 -0
  73. package/scaffold/src/app/api/security/session/login/route.ts +7 -0
  74. package/scaffold/src/app/api/security/session/logout/route.ts +5 -0
  75. package/scaffold/src/app/api/security/session/refresh/route.ts +7 -0
  76. package/scaffold/src/app/api/security/users/[publicId]/avatar/route.ts +15 -0
  77. package/scaffold/src/app/auth/2fa/verify/page.tsx +286 -0
  78. package/scaffold/src/app/auth/forgot-password/confirm/page.tsx +96 -0
  79. package/scaffold/src/app/auth/forgot-password/page.tsx +165 -0
  80. package/scaffold/src/app/auth/forgot-password/success/page.tsx +31 -0
  81. package/scaffold/src/app/auth/login/page.tsx +386 -0
  82. package/scaffold/src/app/auth/welcome/page.tsx +90 -0
  83. package/scaffold/src/app/login/page.tsx +7 -0
  84. package/scaffold/src/app/page.tsx +12 -0
  85. package/scaffold/src/app/security/2fa/page.tsx +370 -0
  86. package/scaffold/src/app/security/companies/(create)/new/page.tsx +12 -0
  87. package/scaffold/src/app/security/companies/(edit)/[publicId]/page.tsx +18 -0
  88. package/scaffold/src/app/security/companies/(list)/page.tsx +298 -0
  89. package/scaffold/src/app/security/companies/editors/CompanyEditor.tsx +754 -0
  90. package/scaffold/src/app/security/profile/page.tsx +492 -0
  91. package/scaffold/src/app/security/roles/(create)/new/page.tsx +11 -0
  92. package/scaffold/src/app/security/roles/(edit)/[publicId]/page.tsx +17 -0
  93. package/scaffold/src/app/security/roles/(list)/page.tsx +269 -0
  94. package/scaffold/src/app/security/roles/editors/RoleEditor.tsx +328 -0
  95. package/scaffold/src/app/security/users/(create)/new/page.tsx +10 -0
  96. package/scaffold/src/app/security/users/(edit)/[publicId]/page.tsx +16 -0
  97. package/scaffold/src/app/security/users/(list)/page.tsx +289 -0
  98. package/scaffold/src/app/security/users/editors/UserEditor.tsx +478 -0
  99. package/scaffold/src/app/users/change-password/page.tsx +172 -0
  100. package/scaffold/src/modules/security/AuthProvider.tsx +405 -0
  101. package/scaffold/src/modules/security/components/AppShell.tsx.hbs +889 -0
  102. package/scaffold/src/modules/security/components/CompanyManagementGuard.tsx +59 -0
  103. package/scaffold/src/modules/security/components/PageFrame.tsx +105 -0
  104. package/scaffold/src/modules/security/components/RouteGuard.tsx +114 -0
  105. package/scaffold/src/modules/security/components/SecurityDashboard.tsx.hbs +315 -0
  106. package/scaffold/src/modules/security/config.ts +55 -0
  107. package/scaffold/src/modules/security/hooks/useAuth.ts +7 -0
  108. package/scaffold/src/modules/security/hooks/useDebouncedSearch.ts +13 -0
  109. package/scaffold/src/modules/security/hooks/usePermission.ts +35 -0
  110. package/scaffold/src/modules/security/host-access.ts +59 -0
  111. package/scaffold/src/modules/security/index.ts +44 -0
  112. package/scaffold/src/modules/security/navigation.ts +41 -0
  113. package/scaffold/src/modules/security/server/avatar-routes.ts +86 -0
  114. package/scaffold/src/modules/security/server/public-api-base-url.ts +26 -0
  115. package/scaffold/src/modules/security/server/session-routes.ts.hbs +322 -0
  116. package/scaffold/src/modules/security/server/session-server.ts.hbs +30 -0
  117. package/scaffold/src/modules/security/services/auth-service.ts +332 -0
  118. package/scaffold/src/modules/security/services/security-api.test.ts +178 -0
  119. package/scaffold/src/modules/security/services/security-api.ts +281 -0
  120. package/scaffold/src/modules/security/services/security-service.test.ts +71 -0
  121. package/scaffold/src/modules/security/services/security-service.ts +727 -0
  122. package/scaffold/src/modules/security/services/session-storage.ts +80 -0
  123. package/scaffold/src/modules/security/types.ts +295 -0
  124. package/scaffold/src/modules/security/useSecurityAppShellSections.tsx.hbs +180 -0
  125. package/scaffold/src/modules/security/utils.ts +194 -0
@@ -0,0 +1,332 @@
1
+ // seedgrid:managed
2
+
3
+ import { ApiClientError } from "@/modules/core";
4
+
5
+ import type {
6
+ AuthTokens,
7
+ ForgotPasswordPayload,
8
+ LoginCredentials,
9
+ PendingTwoFactorSession,
10
+ PermissionEnvelope,
11
+ } from "../types";
12
+ import { securityApi } from "./security-api";
13
+
14
+ const SESSION_ROUTE_BASE = "/api/security/session";
15
+
16
+ export type LoginOutcome =
17
+ | {
18
+ kind: "authenticated";
19
+ tokens: AuthTokens;
20
+ }
21
+ | {
22
+ kind: "two_factor_required";
23
+ pending: PendingTwoFactorSession;
24
+ };
25
+
26
+ export const authService = {
27
+ async signIn(credentials: LoginCredentials): Promise<LoginOutcome> {
28
+ const response = await callSessionRoute<unknown>(`${SESSION_ROUTE_BASE}/login`, {
29
+ method: "POST",
30
+ body: credentials,
31
+ });
32
+
33
+ const accessToken = pickString(response, "accessToken", "token");
34
+ const tempToken = pickString(response, "tempToken", "twoFactorToken");
35
+ const status = pickString(response, "status");
36
+
37
+ if (!accessToken && tempToken && (status?.includes("2F") ?? true)) {
38
+ return {
39
+ kind: "two_factor_required",
40
+ pending: {
41
+ login: credentials.login,
42
+ tempToken,
43
+ method: pickString(response, "method"),
44
+ },
45
+ };
46
+ }
47
+
48
+ return {
49
+ kind: "authenticated",
50
+ tokens: parseAuthTokens(response),
51
+ };
52
+ },
53
+
54
+ async verifyTwoFactor(
55
+ pendingSession: PendingTwoFactorSession,
56
+ code: string
57
+ ) {
58
+ const normalizedCode = code.trim();
59
+
60
+ const response = await callSessionRoute<unknown>(
61
+ `${SESSION_ROUTE_BASE}/2fa/verify`,
62
+ {
63
+ method: "POST",
64
+ body: {
65
+ tempToken:
66
+ pendingSession.tempToken || pendingSession.twoFactorToken || "",
67
+ code: normalizedCode,
68
+ twoFactorCode: normalizedCode,
69
+ backupCode: normalizedCode,
70
+ },
71
+ }
72
+ );
73
+
74
+ return parseAuthTokens(response);
75
+ },
76
+
77
+ async resendTwoFactor(pendingSession: PendingTwoFactorSession) {
78
+ return callSessionRoute<unknown>(`${SESSION_ROUTE_BASE}/2fa/resend`, {
79
+ method: "POST",
80
+ body: {
81
+ tempToken:
82
+ pendingSession.tempToken || pendingSession.twoFactorToken || "",
83
+ },
84
+ });
85
+ },
86
+
87
+ async refresh() {
88
+ const response = await callSessionRoute<unknown>(`${SESSION_ROUTE_BASE}/refresh`, {
89
+ method: "POST",
90
+ });
91
+
92
+ return parseAuthTokens(response);
93
+ },
94
+
95
+ async logout() {
96
+ await callSessionRoute<unknown>(`${SESSION_ROUTE_BASE}/logout`, {
97
+ method: "POST",
98
+ });
99
+ },
100
+
101
+ async forgotPassword(payload: ForgotPasswordPayload) {
102
+ return securityApi.public.post<unknown>("/public/auth/forgot-password", {
103
+ body: payload,
104
+ retry: false,
105
+ });
106
+ },
107
+
108
+ async confirmPasswordReset(token: string) {
109
+ const encodedToken = encodeURIComponent(token.trim());
110
+
111
+ return securityApi.public.get<unknown>(
112
+ `/public/auth/reset-password?token=${encodedToken}`,
113
+ {
114
+ retry: false,
115
+ }
116
+ );
117
+ },
118
+
119
+ async confirmEmail(token: string) {
120
+ const encodedToken = encodeURIComponent(token.trim());
121
+
122
+ return securityApi.public.get<unknown>(
123
+ `/public/auth/confirm-email?token=${encodedToken}`,
124
+ {
125
+ retry: false,
126
+ }
127
+ );
128
+ },
129
+
130
+ async loadPermissions(accessToken?: string): Promise<PermissionEnvelope> {
131
+ const response = await securityApi.get<unknown>("/me/permissions", {
132
+ requireAuth: !accessToken,
133
+ retry: false,
134
+ headers: accessToken
135
+ ? {
136
+ Authorization: `Bearer ${accessToken}`,
137
+ }
138
+ : undefined,
139
+ });
140
+
141
+ return {
142
+ permissions: readStringArray(response, "permissions"),
143
+ roles: readStringArray(response, "roles"),
144
+ login:
145
+ pickString(response, "login", "username", "email", "userName") ??
146
+ undefined,
147
+ publicId: pickString(response, "publicId") ?? undefined,
148
+ name: pickString(response, "name", "fullName") ?? undefined,
149
+ };
150
+ },
151
+ };
152
+
153
+ function parseAuthTokens(response: unknown): AuthTokens {
154
+ const accessToken = pickString(response, "accessToken", "token");
155
+
156
+ if (!accessToken) {
157
+ throw new Error("Security authentication response is missing access token.");
158
+ }
159
+
160
+ const expiresIn = pickNumber(response, "expiresIn");
161
+ const accessTokenClaims = parseJwtClaims(accessToken);
162
+
163
+ return {
164
+ accessToken,
165
+ refreshToken: pickString(response, "refreshToken"),
166
+ tokenType: pickString(response, "tokenType") ?? "Bearer",
167
+ expiresIn,
168
+ expiresAt:
169
+ typeof expiresIn === "number" ? Date.now() + expiresIn * 1000 : null,
170
+ allowedCompanies: readNumberArray(accessTokenClaims, "allowedCompanies"),
171
+ publicId:
172
+ pickString(response, "publicId") ??
173
+ pickString(
174
+ accessTokenClaims,
175
+ "publicId",
176
+ "userPublicId",
177
+ "uid",
178
+ "sub"
179
+ ) ??
180
+ undefined,
181
+ name:
182
+ pickString(response, "name", "fullName") ??
183
+ pickString(accessTokenClaims, "name", "fullName", "given_name") ??
184
+ undefined,
185
+ changePasswordRequired: pickBoolean(response, "changePasswordRequired") ?? undefined,
186
+ };
187
+ }
188
+
189
+ function parseJwtClaims(token: string) {
190
+ const parts = token.split(".");
191
+
192
+ if (parts.length < 2) {
193
+ return null;
194
+ }
195
+
196
+ try {
197
+ const normalizedPayload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
198
+ const paddedPayload = normalizedPayload.padEnd(
199
+ Math.ceil(normalizedPayload.length / 4) * 4,
200
+ "="
201
+ );
202
+ const decodedPayload = atob(paddedPayload);
203
+
204
+ return JSON.parse(decodedPayload) as Record<string, unknown>;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ async function callSessionRoute<T>(
211
+ path: string,
212
+ init: {
213
+ method?: string;
214
+ body?: unknown;
215
+ } = {}
216
+ ): Promise<T> {
217
+ const headers = new Headers();
218
+
219
+ if (init.body != null) {
220
+ headers.set("Content-Type", "application/json");
221
+ }
222
+
223
+ const response = await fetch(path, {
224
+ method: init.method ?? "GET",
225
+ headers,
226
+ body: init.body == null ? undefined : JSON.stringify(init.body),
227
+ cache: "no-store",
228
+ credentials: "same-origin",
229
+ });
230
+
231
+ if (response.ok) {
232
+ if (response.status === 204) {
233
+ return null as T;
234
+ }
235
+
236
+ return (await response.json()) as T;
237
+ }
238
+
239
+ throw new ApiClientError(
240
+ `API request failed with status ${response.status}`,
241
+ response.status,
242
+ await readResponseBody(response)
243
+ );
244
+ }
245
+
246
+ async function readResponseBody(response: Response) {
247
+ const contentType = response.headers.get("Content-Type") ?? "";
248
+ console.log("[readResponseBody] contentType:", contentType);
249
+
250
+ if (isJsonContentType(contentType)) {
251
+ const json = await response.json();
252
+ console.log("[readResponseBody] response json:", JSON.stringify(json, null, 2));
253
+ return json;
254
+ }
255
+
256
+ const text = await response.text();
257
+ console.log("[readResponseBody] response text:", text);
258
+ return text;
259
+ }
260
+
261
+ function isJsonContentType(contentType: string) {
262
+ const normalized = contentType.trim().toLowerCase();
263
+
264
+ return normalized.includes("/json") || normalized.includes("+json");
265
+ }
266
+
267
+ function pickBoolean(value: unknown, ...keys: string[]): boolean | null {
268
+ const candidate = keys.length > 0 ? readValue(value, keys) : value;
269
+
270
+ return typeof candidate === "boolean" ? candidate : null;
271
+ }
272
+
273
+ function pickString(value: unknown, ...keys: string[]): string | null {
274
+ const candidate = keys.length > 0 ? readValue(value, keys) : value;
275
+
276
+ return typeof candidate === "string" && candidate.trim().length > 0
277
+ ? candidate.trim()
278
+ : null;
279
+ }
280
+
281
+ function pickNumber(value: unknown, ...keys: string[]) {
282
+ const candidate = keys.length > 0 ? readValue(value, keys) : value;
283
+
284
+ return typeof candidate === "number" && Number.isFinite(candidate)
285
+ ? candidate
286
+ : null;
287
+ }
288
+
289
+ function readStringArray(value: unknown, key: string) {
290
+ if (!value || typeof value !== "object") {
291
+ return [] as string[];
292
+ }
293
+
294
+ const candidate = (value as Record<string, unknown>)[key];
295
+
296
+ return Array.isArray(candidate)
297
+ ? candidate.filter((entry): entry is string => typeof entry === "string")
298
+ : [];
299
+ }
300
+
301
+ function readNumberArray(value: unknown, key: string) {
302
+ if (!value || typeof value !== "object") {
303
+ return [] as number[];
304
+ }
305
+
306
+ const candidate = (value as Record<string, unknown>)[key];
307
+
308
+ return Array.isArray(candidate)
309
+ ? candidate.filter(
310
+ (entry): entry is number =>
311
+ typeof entry === "number" && Number.isFinite(entry)
312
+ )
313
+ : [];
314
+ }
315
+
316
+ function readValue(value: unknown, keys: string[]) {
317
+ if (!value || typeof value !== "object") {
318
+ return null;
319
+ }
320
+
321
+ const record = value as Record<string, unknown>;
322
+
323
+ for (const key of keys) {
324
+ const candidate = record[key];
325
+
326
+ if (candidate !== undefined && candidate !== null) {
327
+ return candidate;
328
+ }
329
+ }
330
+
331
+ return null;
332
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ApiClientError } from "@/modules/core";
4
+
5
+ import { extractApiMessage } from "./security-api";
6
+
7
+ describe("extractApiMessage", () => {
8
+ it("returns the first violation message from backend validation errors", () => {
9
+ const error = new ApiClientError("API request failed with status 400", 400, {
10
+ type: "https://seedgrid.com.br/dados-invalidos",
11
+ title: "Dados invalidos",
12
+ status: 400,
13
+ detail: "Revise os campos informados.",
14
+ devMessage: "",
15
+ instance: "/public/auth/login",
16
+ violations: [
17
+ {
18
+ field: "login.loginDTO.password",
19
+ message: "Senha e obrigatoria",
20
+ },
21
+ ],
22
+ });
23
+
24
+ expect(extractApiMessage(error)).toBe("Senha e obrigatoria");
25
+ });
26
+
27
+ it("prioritizes violation messages over generic rfc7807 detail", () => {
28
+ const error = new ApiClientError("API request failed with status 400", 400, {
29
+ type: "https://seedgrid.com.br/dados-invalidos",
30
+ title: "Dados invalidos",
31
+ status: 400,
32
+ detail: "Revise os campos informados.",
33
+ devMessage:
34
+ "create.dto.name: Nome da role deve ter entre 5 e 30 caracteres alfanuméricos maiúsculos e _",
35
+ instance: "/roles",
36
+ violations: [
37
+ {
38
+ field: "create.dto.name",
39
+ message:
40
+ "Nome da role deve ter entre 5 e 30 caracteres alfanuméricos maiúsculos e _",
41
+ },
42
+ ],
43
+ });
44
+
45
+ expect(extractApiMessage(error)).toBe(
46
+ "Nome da role deve ter entre 5 e 30 caracteres alfanuméricos maiúsculos e _"
47
+ );
48
+ });
49
+
50
+ it("returns the rfc7807 detail when the response has no violations", () => {
51
+ const error = new ApiClientError("API request failed with status 401", 401, {
52
+ type: "https://seedgrid.com.br/autenticacao-negada",
53
+ title: "Autenticacao negada",
54
+ status: 401,
55
+ detail: "Usuario ou senha invalido",
56
+ devMessage: "",
57
+ instance: "/public/auth/login",
58
+ });
59
+
60
+ expect(extractApiMessage(error)).toBe("Usuario ou senha invalido");
61
+ });
62
+
63
+ it("parses stringified problem json responses before extracting the message", () => {
64
+ const error = new ApiClientError(
65
+ "API request failed with status 400",
66
+ 400,
67
+ JSON.stringify({
68
+ type: "https://seedgrid.com.br/dados-invalidos",
69
+ title: "Dados invalidos",
70
+ status: 400,
71
+ detail: "Revise os campos informados.",
72
+ devMessage: "",
73
+ instance: "/public/auth/login",
74
+ violations: [
75
+ {
76
+ field: "login.loginDTO.login",
77
+ message: "Login e obrigatorio",
78
+ },
79
+ {
80
+ field: "login.loginDTO.password",
81
+ message: "Senha e obrigatoria",
82
+ },
83
+ ],
84
+ })
85
+ );
86
+
87
+ expect(extractApiMessage(error)).toBe("Login e obrigatorio");
88
+ });
89
+
90
+ it("reads violation messages from api-client-like errors even without instanceof", () => {
91
+ const error = {
92
+ message: "API request failed with status 400",
93
+ status: 400,
94
+ responseBody: {
95
+ type: "https://seedgrid.com.br/dados-invalidos",
96
+ title: "Dados invalidos",
97
+ status: 400,
98
+ detail: "Revise os campos informados.",
99
+ devMessage:
100
+ "create.dto.name: Nome da role deve conter apenas letras maiusculas e _",
101
+ instance: "/roles",
102
+ violations: [
103
+ {
104
+ field: "create.dto.name",
105
+ message:
106
+ "Nome da role deve conter apenas letras maiusculas e _",
107
+ },
108
+ ],
109
+ },
110
+ };
111
+
112
+ expect(extractApiMessage(error)).toBe(
113
+ "Nome da role deve conter apenas letras maiusculas e _"
114
+ );
115
+ });
116
+
117
+ it("reads violation messages from wrapped api errors", () => {
118
+ const error = {
119
+ message: "API request failed with status 400",
120
+ cause: {
121
+ responseBody: JSON.stringify({
122
+ type: "https://seedgrid.com.br/dados-invalidos",
123
+ title: "Dados invalidos",
124
+ status: 400,
125
+ detail: "Revise os campos informados.",
126
+ instance: "/roles",
127
+ violations: [
128
+ {
129
+ field: "create.dto.name",
130
+ message: "Nome da role deve conter apenas letras maiusculas e _",
131
+ },
132
+ ],
133
+ }),
134
+ },
135
+ };
136
+
137
+ expect(extractApiMessage(error)).toBe(
138
+ "Nome da role deve conter apenas letras maiusculas e _"
139
+ );
140
+ });
141
+
142
+ it("falls back to the api client message when the response has no readable details", () => {
143
+ const error = new ApiClientError("API request failed with status 400", 400, {
144
+ status: 400,
145
+ });
146
+
147
+ expect(extractApiMessage(error)).toBe("API request failed with status 400");
148
+ });
149
+
150
+ it("maps fetch failures to a friendly server connection message", () => {
151
+ expect(extractApiMessage(new Error("fetch failed"))).toBe(
152
+ "Erro ao conectar ao servidor"
153
+ );
154
+ });
155
+
156
+ it("reproduces the role creation error scenario from issue", () => {
157
+ const error = new ApiClientError("API request failed with status 400", 400, {
158
+ type: "https://seedgrid.com.br/dados-invalidos",
159
+ title: "Dados invalidos",
160
+ status: 400,
161
+ detail: "Revise os campos informados.",
162
+ devMessage:
163
+ "create.dto.name: Nome da role deve ter entre 5 e 30 caracteres alfanuméricos maiúsculos e _",
164
+ instance: "http://crm.seedgrid.com.br:8085/roles",
165
+ violations: [
166
+ {
167
+ field: "create.dto.name",
168
+ message:
169
+ "Nome da role deve ter entre 5 e 30 caracteres alfanuméricos maiúsculos e _",
170
+ },
171
+ ],
172
+ });
173
+
174
+ expect(extractApiMessage(error)).toBe(
175
+ "Nome da role deve ter entre 5 e 30 caracteres alfanuméricos maiúsculos e _"
176
+ );
177
+ });
178
+ });