rl-core-front 0.4.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 (50) 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 +43 -0
  4. package/src/_services/auth/index.ts +2 -0
  5. package/src/_utils/password.ts +40 -0
  6. package/src/_utils/permission.ts +14 -0
  7. package/src/_utils/storage.ts +64 -0
  8. package/src/_utils/user.ts +19 -0
  9. package/src/components/app-shell.tsx +37 -13
  10. package/src/components/password-field.tsx +49 -22
  11. package/src/components/ui/alert.tsx +34 -5
  12. package/src/components/ui/badge.tsx +2 -2
  13. package/src/components/ui/index.ts +2 -0
  14. package/src/components/ui/password-requirements.tsx +105 -0
  15. package/src/components/ui/segmented-control.tsx +7 -6
  16. package/src/components/ui/simple-select.tsx +74 -0
  17. package/src/components/ui/switch.tsx +3 -1
  18. package/src/components/ui/tabs.tsx +4 -8
  19. package/src/components/ui/track.styles.ts +31 -0
  20. package/src/contexts/color-mode-context.tsx +4 -2
  21. package/src/contexts/i18n-context.tsx +3 -2
  22. package/src/contexts/socket-context.tsx +27 -1
  23. package/src/features/audit/components/audit-diff.tsx +1 -1
  24. package/src/features/login/components/backup-codes-dialog.tsx +4 -4
  25. package/src/features/login/components/login-form.tsx +18 -3
  26. package/src/features/login/components/two-factor-setup.tsx +32 -2
  27. package/src/features/login/hooks/use-login-flow.ts +60 -7
  28. package/src/features/login/login-screen.tsx +17 -4
  29. package/src/features/profile/force-password-change.tsx +18 -3
  30. package/src/features/profile/profile-screen.tsx +8 -1
  31. package/src/features/rbac/panels/groups-panel.tsx +58 -23
  32. package/src/features/rbac/panels/roles-panel.tsx +2 -2
  33. package/src/features/rbac/services/rbac.service.ts +23 -27
  34. package/src/features/recovery/reset-password-screen.tsx +16 -2
  35. package/src/features/users/components/user-form-dialog.tsx +49 -2
  36. package/src/features/users/hooks/use-users.ts +15 -1
  37. package/src/features/users/services/users.service.ts +16 -0
  38. package/src/features/users/users-screen.tsx +17 -12
  39. package/src/hooks/use-column-visibility.ts +13 -12
  40. package/src/hooks/use-countdown.ts +70 -0
  41. package/src/hooks/use-request.ts +21 -2
  42. package/src/i18n/messages/en.ts +20 -1
  43. package/src/i18n/messages/pt.ts +23 -1
  44. package/src/index.ts +3 -0
  45. package/src/styles/core.css +99 -2
  46. package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
  47. package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
  48. package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
  49. package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
  50. package/tailwind-preset.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.4.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;
@@ -887,6 +903,7 @@ export interface components {
887
903
  /** @default true */
888
904
  isActive: boolean;
889
905
  roleIds?: string[];
906
+ groupIds?: string[];
890
907
  };
891
908
  UpdateUserCommand: {
892
909
  name?: string;
@@ -896,6 +913,7 @@ export interface components {
896
913
  /** @default true */
897
914
  isActive: boolean;
898
915
  roleIds?: string[];
916
+ groupIds?: string[];
899
917
  };
900
918
  ResourceResponse: {
901
919
  /** Format: uuid */
@@ -1049,6 +1067,8 @@ export interface components {
1049
1067
  type: components["schemas"]["GroupType"];
1050
1068
  /** Format: uuid */
1051
1069
  parentId?: string | null;
1070
+ /** Format: uuid */
1071
+ managerId?: string | null;
1052
1072
  isActive: boolean;
1053
1073
  roles?: components["schemas"]["RoleResponse"][];
1054
1074
  };
@@ -1062,6 +1082,8 @@ export interface components {
1062
1082
  type: "company" | "department" | "team";
1063
1083
  /** Format: uuid */
1064
1084
  parentId?: string;
1085
+ /** Format: uuid */
1086
+ managerId?: string | null;
1065
1087
  roleIds?: string[];
1066
1088
  };
1067
1089
  UpdateGroupCommand: {
@@ -1074,6 +1096,8 @@ export interface components {
1074
1096
  type: "company" | "department" | "team";
1075
1097
  /** Format: uuid */
1076
1098
  parentId?: string;
1099
+ /** Format: uuid */
1100
+ managerId?: string | null;
1077
1101
  roleIds?: string[];
1078
1102
  isActive?: boolean;
1079
1103
  };
@@ -2189,6 +2213,25 @@ export interface operations {
2189
2213
  };
2190
2214
  };
2191
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
+ };
2192
2235
  RbacController_group: {
2193
2236
  parameters: {
2194
2237
  query?: 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 {
@@ -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(" ");
@@ -17,6 +17,9 @@ import { usePathname, useRouter } from "next/navigation";
17
17
  import type { JSX } from "react";
18
18
  import { useEffect, useMemo, useState } from "react";
19
19
 
20
+ import { anyOrTeam } from "#core/_utils/permission";
21
+ import { safeStorage } from "#core/_utils/storage";
22
+ import { fullName } from "#core/_utils/user";
20
23
  import { Breadcrumbs } from "#core/components/breadcrumbs";
21
24
  import { LanguageSelector } from "#core/components/language-selector";
22
25
  import { ThemeToggle } from "#core/components/theme-toggle";
@@ -43,7 +46,8 @@ interface NavItem {
43
46
  key: string;
44
47
  href: string;
45
48
  icon: React.ComponentType<{ className?: string }>;
46
- perm: string | null;
49
+ /** Uma permissão, ou várias das quais basta ter uma. */
50
+ perm: string | string[] | null;
47
51
  }
48
52
 
49
53
  /**
@@ -66,8 +70,13 @@ export interface AppNavItem {
66
70
  label: string;
67
71
  href: string;
68
72
  icon: React.ComponentType<{ className?: string }>;
69
- /** Permissão exigida para o item aparecer. Ausente, aparece para todos. */
70
- permission?: string | null;
73
+ /**
74
+ * Permissão exigida para o item aparecer. Ausente, aparece para todos.
75
+ *
76
+ * Uma lista significa "qualquer uma destas" — é o que permite a mesma tela
77
+ * atender dois escopos, como `users:read:any` e `users:read:team`.
78
+ */
79
+ permission?: string | string[] | null;
71
80
  }
72
81
 
73
82
  const DASHBOARD: NavItem = {
@@ -83,7 +92,14 @@ const PROFILE: NavItem = {
83
92
  perm: null,
84
93
  };
85
94
  const ADMIN_CHILDREN: NavItem[] = [
86
- { key: "nav.users", href: "/usuarios", icon: Users, perm: "users:read:any" },
95
+ {
96
+ key: "nav.users",
97
+ href: "/usuarios",
98
+ icon: Users,
99
+ // Gerente entra pela mesma porta: a lista que ele recebe já vem podada à
100
+ // equipe dele.
101
+ perm: anyOrTeam("users:read"),
102
+ },
87
103
  { key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
88
104
  { key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
89
105
  // Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
@@ -109,13 +125,21 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
109
125
  const pathname = usePathname();
110
126
 
111
127
  const [mobileOpen, setMobileOpen] = useState(false);
112
- const [collapsed, setCollapsed] = useState<boolean>(() => {
113
- if (typeof window === "undefined") {return false;}
114
- return localStorage.getItem(COLLAPSE_KEY) === "1";
115
- });
128
+ const [collapsed, setCollapsed] = useState<boolean>(
129
+ () => safeStorage.get(COLLAPSE_KEY) === "1",
130
+ );
131
+
132
+ const allows = (perm: string | string[] | null): boolean => {
133
+ if (!perm) {
134
+ return true;
135
+ }
136
+ return Array.isArray(perm)
137
+ ? perm.some((code) => hasPermission(code))
138
+ : hasPermission(perm);
139
+ };
116
140
 
117
141
  const adminChildren = useMemo(
118
- () => ADMIN_CHILDREN.filter((c) => !c.perm || hasPermission(c.perm)),
142
+ () => ADMIN_CHILDREN.filter((c) => allows(c.perm)),
119
143
  // eslint-disable-next-line react-hooks/exhaustive-deps
120
144
  [user],
121
145
  );
@@ -125,7 +149,7 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
125
149
  const projectItems = useMemo<NavItem[]>(
126
150
  () =>
127
151
  (navItems ?? [])
128
- .filter((item) => !item.permission || hasPermission(item.permission))
152
+ .filter((item) => allows(item.permission ?? null))
129
153
  .map((item) => ({
130
154
  key: item.label,
131
155
  href: item.href,
@@ -161,7 +185,7 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
161
185
  const toggleCollapsed = (): void =>
162
186
  setCollapsed((v) => {
163
187
  const next = !v;
164
- localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
188
+ safeStorage.set(COLLAPSE_KEY, next ? "1" : "0");
165
189
  return next;
166
190
  });
167
191
 
@@ -296,7 +320,7 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
296
320
  />
297
321
  <div className="hidden text-left sm:block">
298
322
  <p className="text-sm font-semibold leading-tight">
299
- {user?.name}
323
+ {fullName(user)}
300
324
  </p>
301
325
  <p className="text-xs text-muted-foreground">
302
326
  {user?.roles?.[0] ?? ""}
@@ -306,7 +330,7 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
306
330
  </button>
307
331
  </DropdownMenuTrigger>
308
332
  <DropdownMenuContent align="end" className="w-48">
309
- <DropdownMenuLabel>{user?.name}</DropdownMenuLabel>
333
+ <DropdownMenuLabel>{fullName(user)}</DropdownMenuLabel>
310
334
  <DropdownMenuSeparator />
311
335
  <DropdownMenuItem onClick={() => router.push("/perfil")}>
312
336
  <User className="h-4 w-4" />
@@ -3,37 +3,64 @@
3
3
  import { Eye, EyeOff } from "lucide-react";
4
4
  import { forwardRef, InputHTMLAttributes, useState } from "react";
5
5
 
6
- import { Input } from "#core/components/ui";
6
+ import { Input, PasswordRequirements } from "#core/components/ui";
7
7
  import { cn } from "#core/lib/utils";
8
8
 
9
9
  interface PasswordFieldProps extends InputHTMLAttributes<HTMLInputElement> {
10
10
  error?: boolean;
11
+ /**
12
+ * Valor atual do campo. Presente, o campo mostra as exigências de senha
13
+ * logo abaixo — **só enquanto está em foco**.
14
+ *
15
+ * A lista some ao sair do campo de propósito: são cinco linhas, e em tela
16
+ * com dois campos de senha elas dobrariam de tamanho e empurrariam o botão
17
+ * para fora da vista. Enquanto se digita é quando a lista serve; depois,
18
+ * quem avisa que algo está errado é a mensagem de validação.
19
+ *
20
+ * A visibilidade é `focus-within` no CSS, e não estado: o olho de
21
+ * mostrar/ocultar vive dentro do campo, e clicar nele tiraria o foco do
22
+ * input — com estado, a lista piscaria a cada clique.
23
+ */
24
+ rulesFor?: string;
11
25
  }
12
26
 
13
27
  export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
14
- function PasswordField({ className, error, ...props }, ref) {
28
+ function PasswordField({ className, error, rulesFor, ...props }, ref) {
15
29
  const [show, setShow] = useState(false);
16
30
  return (
17
- <div className="relative">
18
- <Input
19
- ref={ref}
20
- type={show ? "text" : "password"}
21
- className={cn(
22
- "pr-10",
23
- error && "border-destructive focus-visible:ring-destructive",
24
- className,
25
- )}
26
- {...props}
27
- />
28
- <button
29
- type="button"
30
- tabIndex={-1}
31
- onClick={() => setShow((s) => !s)}
32
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground"
33
- aria-label={show ? "ocultar senha" : "mostrar senha"}
34
- >
35
- {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
36
- </button>
31
+ <div className="group">
32
+ <div className="relative">
33
+ <Input
34
+ ref={ref}
35
+ type={show ? "text" : "password"}
36
+ className={cn(
37
+ "pr-10",
38
+ error && "border-destructive focus-visible:ring-destructive",
39
+ className,
40
+ )}
41
+ {...props}
42
+ />
43
+ <button
44
+ type="button"
45
+ tabIndex={-1}
46
+ onClick={() => setShow((s) => !s)}
47
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground"
48
+ aria-label={show ? "ocultar senha" : "mostrar senha"}
49
+ >
50
+ {show ? (
51
+ <EyeOff className="h-4 w-4" />
52
+ ) : (
53
+ <Eye className="h-4 w-4" />
54
+ )}
55
+ </button>
56
+ </div>
57
+
58
+ {rulesFor !== undefined && (
59
+ <PasswordRequirements
60
+ value={rulesFor}
61
+ className="mt-2 hidden group-focus-within:flex"
62
+ />
63
+ )}
37
64
  </div>
38
65
  );
39
66
  },
@@ -7,8 +7,8 @@ const alertVariants = cva("rounded-md border px-3 py-2 text-sm", {
7
7
  variants: {
8
8
  variant: {
9
9
  info: "border-primary/30 bg-primary/10 text-primary",
10
- success: "border-success/30 bg-success/10 text-success",
11
- warning: "border-warning/30 bg-warning/10 text-warning",
10
+ success: "border-success/30 bg-success/10 text-success-strong",
11
+ warning: "border-warning/30 bg-warning/10 text-warning-strong",
12
12
  error: "border-destructive/30 bg-destructive/10 text-destructive",
13
13
  },
14
14
  },
@@ -18,10 +18,39 @@ const alertVariants = cva("rounded-md border px-3 py-2 text-sm", {
18
18
  export interface AlertProps
19
19
  extends
20
20
  React.HTMLAttributes<HTMLDivElement>,
21
- VariantProps<typeof alertVariants> {}
21
+ VariantProps<typeof alertVariants> {
22
+ /**
23
+ * Ícone à esquerda, alinhado à primeira linha do texto.
24
+ *
25
+ * Existe porque telas passaram a remontar o aviso à mão só para ter um ícone
26
+ * — e a cópia não acompanhou a correção de contraste das cores. Sem ícone, o
27
+ * componente renderiza exatamente como antes.
28
+ */
29
+ icon?: React.ReactNode;
30
+ }
22
31
 
23
- export function Alert({ variant, className, ...props }: AlertProps): React.JSX.Element {
32
+ export function Alert({
33
+ variant,
34
+ className,
35
+ icon,
36
+ children,
37
+ ...props
38
+ }: AlertProps): React.JSX.Element {
24
39
  return (
25
- <div className={cn(alertVariants({ variant }), className)} {...props} />
40
+ <div
41
+ className={cn(
42
+ alertVariants({ variant }),
43
+ icon && "flex items-start gap-2",
44
+ className,
45
+ )}
46
+ {...props}
47
+ >
48
+ {icon && (
49
+ <span className="mt-0.5 shrink-0 [&>svg]:h-4 [&>svg]:w-4" aria-hidden>
50
+ {icon}
51
+ </span>
52
+ )}
53
+ {icon ? <span>{children}</span> : children}
54
+ </div>
26
55
  );
27
56
  }
@@ -11,8 +11,8 @@ const badgeVariants = cva(
11
11
  default: "border-transparent bg-primary/10 text-primary",
12
12
  secondary: "border-transparent bg-secondary text-secondary-foreground",
13
13
  destructive: "border-transparent bg-destructive/10 text-destructive",
14
- success: "border-transparent bg-success/15 text-success",
15
- warning: "border-transparent bg-warning/15 text-warning",
14
+ success: "border-transparent bg-success/15 text-success-strong",
15
+ warning: "border-transparent bg-warning/15 text-warning-strong",
16
16
  outline: "text-foreground",
17
17
  },
18
18
  },
@@ -18,11 +18,13 @@ export * from "./filter-rule";
18
18
  export * from "./filter-sheet";
19
19
  export * from "./input";
20
20
  export * from "./label";
21
+ export * from "./password-requirements";
21
22
  export * from "./row-actions";
22
23
  export * from "./segmented-control";
23
24
  export * from "./select";
24
25
  export * from "./separator";
25
26
  export * from "./sheet";
27
+ export * from "./simple-select";
26
28
  export * from "./sonner";
27
29
  export * from "./spinner";
28
30
  export * from "./switch";
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { Check, Circle } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import {
7
+ PASSWORD_RULES,
8
+ passwordScore,
9
+ } from "#core/_utils/password";
10
+ import { useI18n } from "#core/contexts";
11
+ import { cn } from "#core/lib/utils";
12
+
13
+ export interface PasswordRequirementsProps {
14
+ /** Valor atual do campo de senha. */
15
+ value: string;
16
+ /** Esconde a barra de força e deixa só a lista. */
17
+ hideStrength?: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ /** Cor da barra por faixa de força — cheia, porque aqui é preenchimento. */
22
+ const STRENGTH_COLOR = (score: number, total: number): string => {
23
+ if (score === total) {
24
+ return "bg-success";
25
+ }
26
+ if (score >= total - 1) {
27
+ return "bg-warning";
28
+ }
29
+ return "bg-destructive";
30
+ };
31
+
32
+ /**
33
+ * Lista viva das exigências de senha, marcando o que já foi cumprido.
34
+ *
35
+ * Substitui a mensagem única de erro ("mín. 8 caracteres com maiúscula,
36
+ * minúscula, número e símbolo"), que só aparecia depois de errar e obrigava a
37
+ * pessoa a comparar a frase inteira com o que digitou para achar o que faltava.
38
+ *
39
+ * Não valida nada: quem decide é o schema Yup da tela, com o mesmo regex. Isto
40
+ * é a explicação em forma de lista — por isso mora em `components/ui` e não
41
+ * conhece formulário nenhum.
42
+ */
43
+ export function PasswordRequirements({
44
+ value,
45
+ hideStrength = false,
46
+ className,
47
+ }: PasswordRequirementsProps): JSX.Element {
48
+ const { t } = useI18n();
49
+ const total = PASSWORD_RULES.length;
50
+ const score = passwordScore(value);
51
+
52
+ return (
53
+ <div className={cn("flex flex-col gap-2", className)}>
54
+ {!hideStrength && (
55
+ <div
56
+ className="h-1.5 w-full overflow-hidden rounded-full bg-track"
57
+ role="progressbar"
58
+ aria-valuenow={score}
59
+ aria-valuemin={0}
60
+ aria-valuemax={total}
61
+ aria-label={t("validation.rules.strength")}
62
+ >
63
+ <div
64
+ className={cn(
65
+ "h-full rounded-full transition-all duration-300",
66
+ STRENGTH_COLOR(score, total),
67
+ )}
68
+ style={{ width: `${(score / total) * 100}%` }}
69
+ />
70
+ </div>
71
+ )}
72
+
73
+ {/*
74
+ `list-none` com ícone próprio, e não marcador nativo: o ✓/○ é o que
75
+ carrega o estado. `aria-live` para quem usa leitor de tela perceber a
76
+ regra sendo cumprida enquanto digita.
77
+ */}
78
+ <ul className="flex flex-col gap-1" aria-live="polite">
79
+ {PASSWORD_RULES.map((rule) => {
80
+ const ok = rule.test(value);
81
+ return (
82
+ <li
83
+ key={rule.key}
84
+ className={cn(
85
+ "flex items-start gap-2 text-xs transition-colors",
86
+ ok ? "text-success-strong" : "text-muted-foreground",
87
+ )}
88
+ >
89
+ <span className="mt-0.5 shrink-0" aria-hidden>
90
+ {ok ? (
91
+ <Check className="h-3.5 w-3.5" />
92
+ ) : (
93
+ <Circle className="h-3.5 w-3.5" />
94
+ )}
95
+ </span>
96
+ {/* Sem truncamento: a regra tem que caber inteira, quebrando linha
97
+ se precisar — cortada, ela deixa de dizer o que exige. */}
98
+ <span className="min-w-0 flex-1">{t(rule.key, rule.params)}</span>
99
+ </li>
100
+ );
101
+ })}
102
+ </ul>
103
+ </div>
104
+ );
105
+ }