rl-core-front 0.3.0 → 0.5.0

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/app-error-code.enum.ts +23 -0
  3. package/src/_services/api/schema.d.ts +86 -132
  4. package/src/_services/auth/index.ts +2 -0
  5. package/src/_utils/filter.ts +15 -34
  6. package/src/_utils/password.ts +40 -0
  7. package/src/_utils/permission.ts +14 -0
  8. package/src/_utils/storage.ts +64 -0
  9. package/src/_utils/user.ts +19 -0
  10. package/src/components/app-shell.tsx +85 -11
  11. package/src/components/brand-header.tsx +23 -8
  12. package/src/components/brand-panel.tsx +41 -0
  13. package/src/components/index.ts +1 -0
  14. package/src/components/password-field.tsx +49 -22
  15. package/src/components/ui/alert.tsx +34 -5
  16. package/src/components/ui/badge.tsx +2 -2
  17. package/src/components/ui/button.tsx +5 -0
  18. package/src/components/ui/confirm-dialog.tsx +3 -1
  19. package/src/components/ui/data-table.tsx +13 -4
  20. package/src/components/ui/dialog.tsx +120 -21
  21. package/src/components/ui/filter-sheet.tsx +47 -25
  22. package/src/components/ui/index.ts +3 -1
  23. package/src/components/ui/password-requirements.tsx +105 -0
  24. package/src/components/ui/segmented-control.tsx +75 -0
  25. package/src/components/ui/simple-select.tsx +74 -0
  26. package/src/components/ui/switch.tsx +3 -1
  27. package/src/components/ui/tabs.tsx +4 -8
  28. package/src/components/ui/track.styles.ts +31 -0
  29. package/src/contexts/brand-context.tsx +15 -0
  30. package/src/contexts/color-mode-context.tsx +4 -2
  31. package/src/contexts/i18n-context.tsx +72 -6
  32. package/src/contexts/socket-context.tsx +27 -1
  33. package/src/features/audit/audit-screen.tsx +13 -140
  34. package/src/features/audit/components/audit-diff.tsx +156 -0
  35. package/src/features/audit/components/index.ts +3 -0
  36. package/src/features/audit/components/record-audit-button.tsx +176 -0
  37. package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
  38. package/src/features/audit/hooks/use-audit-trail.ts +9 -2
  39. package/src/features/audit/services/audit.service.ts +43 -1
  40. package/src/features/login/components/backup-codes-dialog.tsx +4 -4
  41. package/src/features/login/components/login-form.tsx +18 -3
  42. package/src/features/login/components/two-factor-setup.tsx +32 -2
  43. package/src/features/login/hooks/use-login-flow.ts +60 -7
  44. package/src/features/login/login-screen.tsx +99 -40
  45. package/src/features/logs/enums/error-type.enum.ts +21 -0
  46. package/src/features/logs/enums/request-outcome.enum.ts +14 -0
  47. package/src/features/logs/hooks/use-request-logs.ts +26 -3
  48. package/src/features/logs/logs-screen.tsx +2 -14
  49. package/src/features/logs/panels/index.ts +0 -1
  50. package/src/features/logs/panels/request-logs-panel.tsx +177 -25
  51. package/src/features/logs/services/logs.service.ts +13 -51
  52. package/src/features/notifications/components/notification-item.tsx +10 -8
  53. package/src/features/notifications/enums/notification-type.enum.ts +8 -0
  54. package/src/features/profile/force-password-change.tsx +18 -3
  55. package/src/features/profile/profile-screen.tsx +10 -2
  56. package/src/features/rbac/panels/groups-panel.tsx +78 -30
  57. package/src/features/rbac/panels/roles-panel.tsx +2 -2
  58. package/src/features/rbac/services/rbac.service.ts +23 -27
  59. package/src/features/recovery/reset-password-screen.tsx +16 -2
  60. package/src/features/users/components/user-form-dialog.tsx +65 -9
  61. package/src/features/users/hooks/use-users.ts +24 -3
  62. package/src/features/users/services/users.service.ts +16 -0
  63. package/src/features/users/users-screen.tsx +17 -12
  64. package/src/hooks/use-column-visibility.ts +13 -12
  65. package/src/hooks/use-countdown.ts +70 -0
  66. package/src/hooks/use-filters.ts +25 -2
  67. package/src/hooks/use-list-query.ts +14 -1
  68. package/src/hooks/use-request.ts +21 -2
  69. package/src/i18n/messages/en.ts +37 -6
  70. package/src/i18n/messages/pt.ts +41 -6
  71. package/src/index.ts +15 -1
  72. package/src/styles/core.css +99 -2
  73. package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
  74. package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
  75. package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
  76. package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
  77. package/tailwind-preset.ts +17 -0
  78. package/src/components/ui/filter-chips.tsx +0 -88
  79. package/src/features/logs/hooks/use-error-logs.ts +0 -65
  80. package/src/features/logs/panels/error-logs-panel.tsx +0 -183
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Espelho do `AppErrorCode` do backend (core/filters/appErrorCode.enum.ts).
3
+ *
4
+ * Chave de protocolo, então continua string (§7): o valor é o que trafega no
5
+ * corpo do erro, e a tradução de cada um mora em `errors.codes.<CÓDIGO>` no
6
+ * dicionário de i18n.
7
+ *
8
+ * Só entra aqui o código que a interface precisa **reconhecer** para mudar de
9
+ * comportamento — mostrar a mensagem não exige nada disto, porque o
10
+ * `useRequest` já traduz pelo código que veio.
11
+ */
12
+ export enum AppErrorCode {
13
+ /** Conta travada por excesso de tentativas. Só um admin reabre. */
14
+ ACCOUNT_LOCKED = "ACCOUNT_LOCKED",
15
+ /** Conta desativada por um administrador. */
16
+ ACCOUNT_INACTIVE = "ACCOUNT_INACTIVE",
17
+ /** E-mail/senha não conferem. */
18
+ INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
19
+ /** Código de 2FA não confere. */
20
+ INVALID_TWO_FACTOR_CODE = "INVALID_TWO_FACTOR_CODE",
21
+ /** A janela entre a senha e o 2FA venceu — refazer o login resolve. */
22
+ PENDING_TOKEN_EXPIRED = "PENDING_TOKEN_EXPIRED",
23
+ }
@@ -528,6 +528,22 @@ export interface paths {
528
528
  patch?: never;
529
529
  trace?: never;
530
530
  };
531
+ "/rbac/groups/managed": {
532
+ parameters: {
533
+ query?: never;
534
+ header?: never;
535
+ path?: never;
536
+ cookie?: never;
537
+ };
538
+ get: operations["RbacController_managedGroups"];
539
+ put?: never;
540
+ post?: never;
541
+ delete?: never;
542
+ options?: never;
543
+ head?: never;
544
+ patch?: never;
545
+ trace?: never;
546
+ };
531
547
  "/rbac/groups/{id}": {
532
548
  parameters: {
533
549
  query?: never;
@@ -608,46 +624,14 @@ export interface paths {
608
624
  patch?: never;
609
625
  trace?: never;
610
626
  };
611
- "/audit/changes": {
612
- parameters: {
613
- query?: never;
614
- header?: never;
615
- path?: never;
616
- cookie?: never;
617
- };
618
- get: operations["AuditController_changes"];
619
- put?: never;
620
- post?: never;
621
- delete?: never;
622
- options?: never;
623
- head?: never;
624
- patch?: never;
625
- trace?: never;
626
- };
627
- "/audit/changes/filter-schema": {
628
- parameters: {
629
- query?: never;
630
- header?: never;
631
- path?: never;
632
- cookie?: never;
633
- };
634
- get: operations["AuditController_changesFilterSchema"];
635
- put?: never;
636
- post?: never;
637
- delete?: never;
638
- options?: never;
639
- head?: never;
640
- patch?: never;
641
- trace?: never;
642
- };
643
- "/audit/errors": {
627
+ "/audit/requests": {
644
628
  parameters: {
645
629
  query?: never;
646
630
  header?: never;
647
631
  path?: never;
648
632
  cookie?: never;
649
633
  };
650
- get: operations["AuditController_errors"];
634
+ get: operations["AuditController_requests"];
651
635
  put?: never;
652
636
  post?: never;
653
637
  delete?: never;
@@ -656,14 +640,14 @@ export interface paths {
656
640
  patch?: never;
657
641
  trace?: never;
658
642
  };
659
- "/audit/errors/filter-schema": {
643
+ "/audit/requests/filter-schema": {
660
644
  parameters: {
661
645
  query?: never;
662
646
  header?: never;
663
647
  path?: never;
664
648
  cookie?: never;
665
649
  };
666
- get: operations["AuditController_errorsFilterSchema"];
650
+ get: operations["AuditController_requestsFilterSchema"];
667
651
  put?: never;
668
652
  post?: never;
669
653
  delete?: never;
@@ -919,6 +903,7 @@ export interface components {
919
903
  /** @default true */
920
904
  isActive: boolean;
921
905
  roleIds?: string[];
906
+ groupIds?: string[];
922
907
  };
923
908
  UpdateUserCommand: {
924
909
  name?: string;
@@ -928,6 +913,7 @@ export interface components {
928
913
  /** @default true */
929
914
  isActive: boolean;
930
915
  roleIds?: string[];
916
+ groupIds?: string[];
931
917
  };
932
918
  ResourceResponse: {
933
919
  /** Format: uuid */
@@ -1081,6 +1067,8 @@ export interface components {
1081
1067
  type: components["schemas"]["GroupType"];
1082
1068
  /** Format: uuid */
1083
1069
  parentId?: string | null;
1070
+ /** Format: uuid */
1071
+ managerId?: string | null;
1084
1072
  isActive: boolean;
1085
1073
  roles?: components["schemas"]["RoleResponse"][];
1086
1074
  };
@@ -1094,6 +1082,8 @@ export interface components {
1094
1082
  type: "company" | "department" | "team";
1095
1083
  /** Format: uuid */
1096
1084
  parentId?: string;
1085
+ /** Format: uuid */
1086
+ managerId?: string | null;
1097
1087
  roleIds?: string[];
1098
1088
  };
1099
1089
  UpdateGroupCommand: {
@@ -1106,6 +1096,8 @@ export interface components {
1106
1096
  type: "company" | "department" | "team";
1107
1097
  /** Format: uuid */
1108
1098
  parentId?: string;
1099
+ /** Format: uuid */
1100
+ managerId?: string | null;
1109
1101
  roleIds?: string[];
1110
1102
  isActive?: boolean;
1111
1103
  };
@@ -1122,6 +1114,11 @@ export interface components {
1122
1114
  permissionIds?: string[];
1123
1115
  isActive?: boolean;
1124
1116
  };
1117
+ /**
1118
+ * @description 0 = sucesso, 1 = erro, 2 = abortada
1119
+ * @enum {number}
1120
+ */
1121
+ RequestOutcome: 0 | 1 | 2;
1125
1122
  AuditUserRefResponse: {
1126
1123
  /** Format: uuid */
1127
1124
  id: string;
@@ -1129,58 +1126,43 @@ export interface components {
1129
1126
  /** @example admin@admin.com */
1130
1127
  email: string;
1131
1128
  };
1129
+ /**
1130
+ * @description 0 = validação, 1 = autenticação, 2 = não encontrado, 3 = limite, 4 = banco, 5 = integração, 6 = interno
1131
+ * @enum {number}
1132
+ */
1133
+ ErrorType: 0 | 1 | 2 | 3 | 4 | 5 | 6;
1132
1134
  RequestLogResponse: {
1133
1135
  /** Format: uuid */
1134
1136
  id: string;
1135
- /** @example GET /api/v1/users */
1136
- action: string;
1137
- /** @example User */
1138
- entity?: string | null;
1139
- entityId?: string | null;
1140
- /** @example GET */
1137
+ /**
1138
+ * @description 0 = sucesso, 1 = erro, 2 = abortada
1139
+ * @example 0
1140
+ */
1141
+ outcome: components["schemas"]["RequestOutcome"];
1142
+ /** @example Login */
1143
+ action?: string | null;
1144
+ /** @example POST */
1141
1145
  method?: string | null;
1146
+ /** @example /api/v1/users/9f3c… */
1142
1147
  path?: string | null;
1148
+ /** @example /users/:id */
1149
+ route?: string | null;
1143
1150
  /** @example 200 */
1144
1151
  statusCode?: number | null;
1145
1152
  /** @example 42 */
1146
1153
  durationMs?: number | null;
1147
- /** @example 10.0.0.1 */
1148
- ipAddress?: string | null;
1149
- /** Format: uuid */
1150
- requestId?: string | null;
1151
- metadata?: Record<string, never> | null;
1152
1154
  userId?: string | null;
1153
1155
  user?: components["schemas"]["AuditUserRefResponse"] | null;
1154
- /** Format: date-time */
1155
- createdAt: string;
1156
- /** Format: date-time */
1157
- updatedAt: string;
1158
- /** Format: date-time */
1159
- deletedAt?: string | null;
1160
- };
1161
- PaginatedRequestLogsResponse: {
1162
- items: components["schemas"]["RequestLogResponse"][];
1163
- meta: components["schemas"]["PaginationMetaResponse"];
1164
- };
1165
- /** @enum {string} */
1166
- ErrorType: "VALIDATION" | "AUTH" | "NOT_FOUND" | "RATE_LIMIT" | "DATABASE" | "INTEGRATION" | "INTERNAL";
1167
- ErrorLogResponse: {
1168
- /** Format: uuid */
1169
- id: string;
1170
- type: components["schemas"]["ErrorType"];
1171
- /** @example 500 */
1172
- statusCode?: number | null;
1173
- message: string;
1174
- stack?: string | null;
1175
- /** @example POST */
1176
- method?: string | null;
1177
- path?: string | null;
1178
- /** @example 10.0.0.1 */
1179
- ipAddress?: string | null;
1156
+ /** @description HMAC do IP — não é reversível ao endereço original */
1157
+ ipHash?: string | null;
1158
+ userAgent?: string | null;
1180
1159
  /** Format: uuid */
1181
1160
  requestId?: string | null;
1182
- userId?: string | null;
1183
- user?: components["schemas"]["AuditUserRefResponse"] | null;
1161
+ /** @description 0 = validação, 1 = autenticação, 2 = não encontrado, 3 = limite, 4 = banco, 5 = integração, 6 = interno */
1162
+ errorType?: components["schemas"]["ErrorType"] | null;
1163
+ errorCode?: string | null;
1164
+ errorMessage?: string | null;
1165
+ errorStack?: string | null;
1184
1166
  /** Format: date-time */
1185
1167
  createdAt: string;
1186
1168
  /** Format: date-time */
@@ -1188,12 +1170,12 @@ export interface components {
1188
1170
  /** Format: date-time */
1189
1171
  deletedAt?: string | null;
1190
1172
  };
1191
- PaginatedErrorLogsResponse: {
1192
- items: components["schemas"]["ErrorLogResponse"][];
1173
+ PaginatedRequestLogsResponse: {
1174
+ items: components["schemas"]["RequestLogResponse"][];
1193
1175
  meta: components["schemas"]["PaginationMetaResponse"];
1194
1176
  };
1195
- /** @enum {string} */
1196
- AuditDataAction: "CREATE" | "UPDATE" | "DELETE";
1177
+ /** @enum {number} */
1178
+ AuditDataAction: 0 | 1 | 2;
1197
1179
  AuditDataChangeResponse: {
1198
1180
  /** Format: uuid */
1199
1181
  id: string;
@@ -1218,8 +1200,8 @@ export interface components {
1218
1200
  items: components["schemas"]["AuditDataChangeResponse"][];
1219
1201
  meta: components["schemas"]["PaginationMetaResponse"];
1220
1202
  };
1221
- /** @enum {string} */
1222
- NotificationType: "INFO" | "SUCCESS" | "WARNING" | "ERROR";
1203
+ /** @enum {number} */
1204
+ NotificationType: 0 | 1 | 2 | 3;
1223
1205
  UserNotificationResponse: {
1224
1206
  /** Format: uuid */
1225
1207
  id: string;
@@ -1245,8 +1227,8 @@ export interface components {
1245
1227
  /** @example 3 */
1246
1228
  unread: number;
1247
1229
  };
1248
- /** @enum {string} */
1249
- ScopeType: "USER" | "ROLE" | "ALL";
1230
+ /** @enum {number} */
1231
+ ScopeType: 0 | 1 | 2;
1250
1232
  NotificationTargetCommand: {
1251
1233
  type: components["schemas"]["ScopeType"];
1252
1234
  /** @description Id do usuário quando type=USER, nome do papel quando type=ROLE. Ignorado em ALL. */
@@ -2231,6 +2213,25 @@ export interface operations {
2231
2213
  };
2232
2214
  };
2233
2215
  };
2216
+ RbacController_managedGroups: {
2217
+ parameters: {
2218
+ query?: never;
2219
+ header?: never;
2220
+ path?: never;
2221
+ cookie?: never;
2222
+ };
2223
+ requestBody?: never;
2224
+ responses: {
2225
+ 200: {
2226
+ headers: {
2227
+ [name: string]: unknown;
2228
+ };
2229
+ content: {
2230
+ "application/json": components["schemas"]["GroupResponse"][];
2231
+ };
2232
+ };
2233
+ };
2234
+ };
2234
2235
  RbacController_group: {
2235
2236
  parameters: {
2236
2237
  query?: never;
@@ -2487,7 +2488,7 @@ export interface operations {
2487
2488
  };
2488
2489
  };
2489
2490
  };
2490
- AuditController_changes: {
2491
+ AuditController_requests: {
2491
2492
  parameters: {
2492
2493
  query?: {
2493
2494
  page?: number;
@@ -2515,54 +2516,7 @@ export interface operations {
2515
2516
  };
2516
2517
  };
2517
2518
  };
2518
- AuditController_changesFilterSchema: {
2519
- parameters: {
2520
- query?: never;
2521
- header?: never;
2522
- path?: never;
2523
- cookie?: never;
2524
- };
2525
- requestBody?: never;
2526
- responses: {
2527
- 200: {
2528
- headers: {
2529
- [name: string]: unknown;
2530
- };
2531
- content: {
2532
- "application/json": components["schemas"]["FilterFieldResponse"][];
2533
- };
2534
- };
2535
- };
2536
- };
2537
- AuditController_errors: {
2538
- parameters: {
2539
- query?: {
2540
- page?: number;
2541
- limit?: number;
2542
- search?: string;
2543
- /** @description Coluna para ordenar (whitelist por endpoint) */
2544
- sortBy?: string;
2545
- sortDir?: "ASC" | "DESC";
2546
- /** @description JSON, ex.: {"name":{"$SUBSTRING":"ana"}} */
2547
- filter?: string;
2548
- };
2549
- header?: never;
2550
- path?: never;
2551
- cookie?: never;
2552
- };
2553
- requestBody?: never;
2554
- responses: {
2555
- 200: {
2556
- headers: {
2557
- [name: string]: unknown;
2558
- };
2559
- content: {
2560
- "application/json": components["schemas"]["PaginatedErrorLogsResponse"];
2561
- };
2562
- };
2563
- };
2564
- };
2565
- AuditController_errorsFilterSchema: {
2519
+ AuditController_requestsFilterSchema: {
2566
2520
  parameters: {
2567
2521
  query?: never;
2568
2522
  header?: never;
@@ -6,6 +6,8 @@ export type LoginStatus = "SETUP_REQUIRED" | "TOTP_REQUIRED";
6
6
  export interface LoginResponse {
7
7
  status: LoginStatus;
8
8
  pendingToken: string;
9
+ /** Segundos até a etapa do código vencer — alimenta o contador da tela. */
10
+ expiresInSeconds: number;
9
11
  }
10
12
 
11
13
  export interface TotpSetup {
@@ -1,5 +1,5 @@
1
1
  import type { components } from "#core/_services/api/schema";
2
- import { formatDay, fromIsoDay, toIsoDay } from "#core/_utils/calendar";
2
+ import { fromIsoDay, toIsoDay } from "#core/_utils/calendar";
3
3
 
4
4
  /**
5
5
  * Tipos derivados do `openapi.json` (§5): o catálogo é servido pelo backend,
@@ -63,8 +63,20 @@ export const hasValue = (draft: FilterDraftValue | undefined): boolean => {
63
63
  );
64
64
  };
65
65
 
66
- export const activeFilterCount = (values: FilterValues): number =>
67
- Object.values(values).filter(hasValue).length;
66
+ /**
67
+ * Quantos filtros o botão "Filtros" anuncia.
68
+ *
69
+ * `ignore` são os campos que a barra da listagem controla: eles filtram de
70
+ * verdade, mas o contador não pode cobrá-los, senão a tela abre marcando 1 num
71
+ * painel onde não há nada marcado.
72
+ */
73
+ export const activeFilterCount = (
74
+ values: FilterValues,
75
+ ignore: string[] = [],
76
+ ): number =>
77
+ Object.entries(values).filter(
78
+ ([field, draft]) => hasValue(draft) && !ignore.includes(field),
79
+ ).length;
68
80
 
69
81
  /**
70
82
  * Ponta de um intervalo de datas em ISO **com o fuso de quem filtrou**.
@@ -280,34 +292,3 @@ const draftFrom = (
280
292
  }
281
293
  }
282
294
  };
283
-
284
- /** Resumo de um filtro ativo, para o chip. */
285
- export const describeValue = (
286
- field: FilterField,
287
- draft: FilterDraftValue,
288
- labels: { from: string; to: string; yes: string; no: string; locale: string },
289
- ): string => {
290
- const show = (value: string): string =>
291
- field.type === "DATE" || field.type === "DATETIME"
292
- ? formatDay(value, labels.locale)
293
- : value;
294
-
295
- if (field.type === "BOOLEAN") {
296
- return draft.value === "true" ? labels.yes : labels.no;
297
- }
298
- if (draft.values?.length) {
299
- const optionLabel = (value: string): string =>
300
- field.options?.find((option) => option.value === value)?.label ?? value;
301
- return draft.values.map(optionLabel).join(", ");
302
- }
303
- if (draft.from && draft.to) {
304
- return `${show(draft.from)} – ${show(draft.to)}`;
305
- }
306
- if (draft.from) {
307
- return `${labels.from} ${show(draft.from)}`;
308
- }
309
- if (draft.to) {
310
- return `${labels.to} ${show(draft.to)}`;
311
- }
312
- return draft.value ?? "";
313
- };
@@ -5,3 +5,43 @@
5
5
  */
6
6
  export const STRONG_PASSWORD_REGEX =
7
7
  /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>]).{8,}$/;
8
+
9
+ /** Tamanho mínimo — o mesmo `{8,}` do regex acima, nomeado. */
10
+ export const PASSWORD_MIN_LENGTH = 8;
11
+
12
+ export interface PasswordRule {
13
+ /** Chave de i18n do texto da regra (`validation.rules.*`). */
14
+ key: string;
15
+ /** Parâmetros da tradução, quando o texto tem número. */
16
+ params?: Record<string, string | number>;
17
+ test: (value: string) => boolean;
18
+ }
19
+
20
+ /**
21
+ * As exigências do regex, uma a uma.
22
+ *
23
+ * O regex sozinho só sabe dizer "não passou", e uma senha recusada sem dizer o
24
+ * que falta obriga a pessoa a adivinhar. Aqui cada condição responde por si, e
25
+ * a tela mostra quais já foram cumpridas enquanto se digita.
26
+ *
27
+ * A lista é derivada do mesmo regex e existe **só para explicar**: quem valida
28
+ * de fato continua sendo `STRONG_PASSWORD_REGEX`, e o backend por último.
29
+ */
30
+ export const PASSWORD_RULES: PasswordRule[] = [
31
+ {
32
+ key: "validation.rules.minLength",
33
+ params: { count: PASSWORD_MIN_LENGTH },
34
+ test: (value) => value.length >= PASSWORD_MIN_LENGTH,
35
+ },
36
+ { key: "validation.rules.lowercase", test: (value) => /[a-z]/.test(value) },
37
+ { key: "validation.rules.uppercase", test: (value) => /[A-Z]/.test(value) },
38
+ { key: "validation.rules.digit", test: (value) => /\d/.test(value) },
39
+ {
40
+ key: "validation.rules.special",
41
+ test: (value) => /[!@#$%^&*(),.?":{}|<>]/.test(value),
42
+ },
43
+ ];
44
+
45
+ /** Quantas regras a senha já cumpre — base do medidor de força. */
46
+ export const passwordScore = (value: string): number =>
47
+ PASSWORD_RULES.filter((rule) => rule.test(value)).length;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Os dois códigos que autorizam a mesma ação em alcances diferentes.
3
+ *
4
+ * `anyOrTeam("users:update")` → `["users:update:any", "users:update:team"]`.
5
+ *
6
+ * Espelha o helper de mesmo nome do backend (`core/auth/permissionScope.util`),
7
+ * como os enums do §7: são dois pacotes publicados separadamente, e o front não
8
+ * importa do outro. O que não pode divergir é o texto dos sufixos — e é
9
+ * justamente por isso que ele fica escrito num lugar só de cada lado.
10
+ */
11
+ export const anyOrTeam = (action: string): [string, string] => [
12
+ `${action}:any`,
13
+ `${action}:team`,
14
+ ];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Acesso ao `localStorage` que nunca derruba a tela.
3
+ *
4
+ * Ler `localStorage` **lança** — não devolve `null` — quando o navegador está
5
+ * com dados de site bloqueados: Chrome em "bloquear todos os cookies", Safari
6
+ * com prevenção de rastreamento em contexto de terceiros, políticas
7
+ * corporativas. Como quem lê são providers da raiz e o shell, um erro ali não
8
+ * degradava um pedaço da interface: derrubava a aplicação inteira, com tela
9
+ * branca no login e nenhuma pista para o usuário.
10
+ *
11
+ * O script anti-flash do `layout.tsx` já se protegia assim; aqui isso deixa de
12
+ * ser cuidado de um lugar só e vira a única porta de acesso.
13
+ *
14
+ * O que está guardado aqui é conveniência — tema, idioma, menu recolhido,
15
+ * colunas escondidas. Não poder ler significa cair no padrão, nunca falhar.
16
+ *
17
+ * Também cobre o servidor: no primeiro render do Next não existe `window`.
18
+ */
19
+ const memory = new Map<string, string>();
20
+
21
+ /**
22
+ * Espelho em memória para quando o navegador recusa o armazenamento.
23
+ *
24
+ * Sem ele, trocar o tema com cookies bloqueados não teria efeito nenhum dentro
25
+ * da própria sessão — a escrita se perdia e a leitura seguinte devolvia o valor
26
+ * antigo. Assim a preferência vale enquanto a aba está aberta; ela só não
27
+ * sobrevive ao recarregamento, que é o máximo possível nesse cenário.
28
+ */
29
+ export const safeStorage = {
30
+ get(key: string): string | null {
31
+ try {
32
+ if (typeof window === "undefined") {
33
+ return null;
34
+ }
35
+ return window.localStorage.getItem(key);
36
+ } catch {
37
+ return memory.get(key) ?? null;
38
+ }
39
+ },
40
+
41
+ set(key: string, value: string): void {
42
+ try {
43
+ if (typeof window === "undefined") {
44
+ return;
45
+ }
46
+ window.localStorage.setItem(key, value);
47
+ } catch {
48
+ // Cota estourada ou armazenamento negado: guarda na sessão e segue.
49
+ memory.set(key, value);
50
+ }
51
+ },
52
+
53
+ remove(key: string): void {
54
+ try {
55
+ memory.delete(key);
56
+ if (typeof window === "undefined") {
57
+ return;
58
+ }
59
+ window.localStorage.removeItem(key);
60
+ } catch {
61
+ // Já saiu do espelho em memória; não há mais nada a fazer.
62
+ }
63
+ },
64
+ };
@@ -0,0 +1,19 @@
1
+ /** Forma mínima que basta para rotular uma pessoa na interface. */
2
+ export interface NamedUser {
3
+ name?: string | null;
4
+ lastName?: string | null;
5
+ }
6
+
7
+ /**
8
+ * Nome completo para exibição — espelha o getter `fullName` da entidade `User`
9
+ * do backend, inclusive no comportamento com sobrenome ausente.
10
+ *
11
+ * Existe para não haver três concatenações diferentes espalhadas pelas telas:
12
+ * quem mostra pessoa mostra o nome inteiro, e "Ana" e "Ana" deixam de ser a
13
+ * mesma linha numa lista de auditoria.
14
+ *
15
+ * Saudação é a exceção e continua usando só o primeiro nome — "Bem-vindo, Ana
16
+ * Paula de Souza" soa como carta de cobrança.
17
+ */
18
+ export const fullName = (user?: NamedUser | null): string =>
19
+ [user?.name, user?.lastName].filter(Boolean).join(" ");