rl-core-front 0.4.0 → 0.6.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
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
 
5
+ import { safeStorage } from "#core/_utils/storage";
6
+
5
7
  const STORAGE_PREFIX = "cols:";
6
8
 
7
9
  export interface UseColumnVisibilityResult {
@@ -20,10 +22,13 @@ export function useColumnVisibility(
20
22
  const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
21
23
 
22
24
  useEffect(() => {
23
- if (!storageKey || typeof window === "undefined") {return;}
25
+ if (!storageKey) {return;}
26
+ const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
27
+ if (!raw) {return;}
24
28
  try {
25
- const raw = window.localStorage.getItem(`${STORAGE_PREFIX}${storageKey}`);
26
- if (raw) {setHiddenColumns(JSON.parse(raw));}
29
+ // O `try` que sobrou é do JSON: o acesso ao armazenamento já é seguro,
30
+ // mas o conteúdo pode estar corrompido por uma versão anterior da tela.
31
+ setHiddenColumns(JSON.parse(raw));
27
32
  } catch {
28
33
  setHiddenColumns([]);
29
34
  }
@@ -37,15 +42,11 @@ export function useColumnVisibility(
37
42
  const saveHiddenColumns = useCallback(
38
43
  (nextHidden: string[]) => {
39
44
  setHiddenColumns(nextHidden);
40
- if (!storageKey || typeof window === "undefined") {return;}
41
- try {
42
- window.localStorage.setItem(
43
- `${STORAGE_PREFIX}${storageKey}`,
44
- JSON.stringify(nextHidden),
45
- );
46
- } catch {
47
- /* ignore */
48
- }
45
+ if (!storageKey) {return;}
46
+ safeStorage.set(
47
+ `${STORAGE_PREFIX}${storageKey}`,
48
+ JSON.stringify(nextHidden),
49
+ );
49
50
  },
50
51
  [storageKey],
51
52
  );
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export interface UseCountdownResult {
6
+ /** Segundos restantes, nunca negativo. */
7
+ remaining: number;
8
+ /** `mm:ss` pronto para exibir. */
9
+ formatted: string;
10
+ /** Chegou a zero. */
11
+ expired: boolean;
12
+ }
13
+
14
+ const format = (totalSeconds: number): string => {
15
+ const minutes = Math.floor(totalSeconds / 60);
16
+ const seconds = totalSeconds % 60;
17
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
18
+ };
19
+
20
+ /**
21
+ * Contagem regressiva em segundos.
22
+ *
23
+ * Conta pelo relógio, não somando ticks: um intervalo de 1s atrasa quando a aba
24
+ * fica em segundo plano, e o tempo iria derretendo. O instante-alvo é fixado
25
+ * uma vez, dentro do efeito, e cada tique só relê a diferença — voltar para a
26
+ * aba mostra o tempo real, não o que o timer conseguiu contar.
27
+ *
28
+ * `seconds` nulo (prazo ainda desconhecido) deixa o contador parado em zero sem
29
+ * marcar como expirado.
30
+ *
31
+ * O relógio é lido só no efeito e no callback do intervalo: `Date.now()` é
32
+ * impuro e durante o render produziria resultado instável a cada re-render.
33
+ */
34
+ export function useCountdown(seconds: number | null): UseCountdownResult {
35
+ /*
36
+ * Guarda de que prazo veio a contagem. Sem isso, trocar `seconds` deixaria o
37
+ * valor do prazo anterior à mostra até o primeiro tique — e um zero herdado
38
+ * marcaria como expirado um contador que acabou de começar.
39
+ */
40
+ const [tick, setTick] = useState<{ source: number | null; value: number }>({
41
+ source: seconds,
42
+ value: seconds ?? 0,
43
+ });
44
+
45
+ useEffect(() => {
46
+ if (seconds === null) {
47
+ return;
48
+ }
49
+ const deadline = Date.now() + seconds * 1000;
50
+ const id = setInterval(
51
+ () =>
52
+ setTick({
53
+ source: seconds,
54
+ value: Math.max(0, Math.round((deadline - Date.now()) / 1000)),
55
+ }),
56
+ 1000,
57
+ );
58
+ return () => clearInterval(id);
59
+ }, [seconds]);
60
+
61
+ // Enquanto o tique ainda é do prazo anterior, vale o prazo cheio recebido.
62
+ const remaining =
63
+ seconds === null ? 0 : tick.source === seconds ? tick.value : seconds;
64
+
65
+ return {
66
+ remaining,
67
+ formatted: format(remaining),
68
+ expired: seconds !== null && remaining === 0,
69
+ };
70
+ }
@@ -34,12 +34,24 @@ export interface RunOptions {
34
34
  * "Registro criado" (ex.: "Link de primeiro acesso enviado").
35
35
  */
36
36
  success?: RequestOperation | string;
37
+ /**
38
+ * Reage ao erro com o `errorCode` em mãos — para quando a tela precisa fazer
39
+ * algo além de mostrar a mensagem (ex.: voltar ao passo de credenciais
40
+ * quando a verificação de 2FA vence).
41
+ *
42
+ * Vem por callback e não pelo estado `errorCode` porque o estado só chega no
43
+ * render seguinte: quem lê logo depois do `await run(...)` pegaria o valor
44
+ * anterior.
45
+ */
46
+ onError?: (error: { code: string | null; message: string }) => void;
37
47
  }
38
48
 
39
49
  export interface UseRequestResult {
40
50
  run: <T>(fn: () => Promise<T>, options?: RunOptions) => Promise<T | null>;
41
51
  loading: boolean;
42
52
  error: string | null;
53
+ /** Código estável do último erro (`AppErrorCode` do backend), para render condicional. */
54
+ errorCode: string | null;
43
55
  setError: Dispatch<SetStateAction<string | null>>;
44
56
  }
45
57
 
@@ -53,6 +65,7 @@ export function useRequest(): UseRequestResult {
53
65
  const { notify } = useToast();
54
66
  const [loading, setLoading] = useState(false);
55
67
  const [error, setError] = useState<string | null>(null);
68
+ const [errorCode, setErrorCode] = useState<string | null>(null);
56
69
 
57
70
  /**
58
71
  * Ordem de preferência da mensagem:
@@ -93,6 +106,7 @@ export function useRequest(): UseRequestResult {
93
106
  async <T>(fn: () => Promise<T>, options?: RunOptions): Promise<T | null> => {
94
107
  setLoading(true);
95
108
  setError(null);
109
+ setErrorCode(null);
96
110
  try {
97
111
  const result = await fn();
98
112
  if (options?.success) {
@@ -101,7 +115,12 @@ export function useRequest(): UseRequestResult {
101
115
  return result;
102
116
  } catch (e) {
103
117
  const err = e as AxiosError<ApiErrorBody>;
104
- setError(messageFor(err.response?.data));
118
+ const body = err.response?.data;
119
+ const message = messageFor(body);
120
+ const code = body?.errorCode ?? null;
121
+ setError(message);
122
+ setErrorCode(code);
123
+ options?.onError?.({ code, message });
105
124
  return null;
106
125
  } finally {
107
126
  setLoading(false);
@@ -110,5 +129,5 @@ export function useRequest(): UseRequestResult {
110
129
  [messageFor, notify, successMessage],
111
130
  );
112
131
 
113
- return { run, loading, error, setError };
132
+ return { run, loading, error, errorCode, setError };
114
133
  }
@@ -212,6 +212,10 @@ export const en: Messages = {
212
212
  loginSuccess: "Signed in successfully",
213
213
  twoFactorConfigured: "2FA configured successfully",
214
214
  codeSent: "Code sent to your email",
215
+ verificationExpired:
216
+ "Verification expired. Sign in again to continue — if you already scanned the QR code, it is still valid.",
217
+ expiresIn: "Time to confirm: {time}",
218
+ expiredHint: "Time is up. Sign in again to continue.",
215
219
  },
216
220
  recovery: {
217
221
  forgotTitle: "Recover password",
@@ -252,6 +256,8 @@ export const en: Messages = {
252
256
  phone: "Phone",
253
257
  email: "Email",
254
258
  roles: "Roles",
259
+ team: "Team",
260
+ selectTeam: "Select the team",
255
261
  status: "Status",
256
262
  active: "Active",
257
263
  inactive: "Inactive",
@@ -285,13 +291,25 @@ export const en: Messages = {
285
291
  avatarUpdated: "Profile photo updated",
286
292
  avatarRemoved: "Profile photo removed",
287
293
  },
288
- rbac: { title: "Roles & Permissions" },
294
+ rbac: {
295
+ title: "Roles & Permissions",
296
+ manager: "Manager",
297
+ noManager: "No manager",
298
+ },
289
299
  validation: {
290
300
  required: "Required field",
291
301
  email: "Invalid email",
292
302
  passwordStrong: "Min. 8 chars with uppercase, lowercase, number and symbol",
293
303
  passwordsNotMatch: "Passwords do not match",
294
304
  codeShort: "Code too short",
305
+ rules: {
306
+ strength: "Password strength",
307
+ minLength: "At least {count} characters",
308
+ lowercase: "At least one lowercase letter",
309
+ uppercase: "At least one uppercase letter",
310
+ digit: "At least one number",
311
+ special: "At least one special character (!@#$...)",
312
+ },
295
313
  },
296
314
  errors: {
297
315
  unexpected: "Unexpected error",
@@ -303,6 +321,7 @@ export const en: Messages = {
303
321
  PASSWORD_REUSED:
304
322
  "This password has already been used. Choose one you have never used in this system.",
305
323
  TOO_MANY_REQUESTS: "Too many attempts. Wait a moment and try again.",
324
+ PENDING_TOKEN_EXPIRED: "Verification expired. Sign in again to continue.",
306
325
  },
307
326
  },
308
327
  };
@@ -214,6 +214,10 @@ export const pt = {
214
214
  loginSuccess: "Login realizado com sucesso",
215
215
  twoFactorConfigured: "2FA configurado com sucesso",
216
216
  codeSent: "Código enviado para seu email",
217
+ verificationExpired:
218
+ "A verificação expirou. Entre novamente para continuar — se você já escaneou o QR code, ele continua valendo.",
219
+ expiresIn: "Tempo para confirmar: {time}",
220
+ expiredHint: "O tempo acabou. Entre novamente para continuar.",
217
221
  },
218
222
  recovery: {
219
223
  forgotTitle: "Recuperar senha",
@@ -254,6 +258,8 @@ export const pt = {
254
258
  phone: "Telefone",
255
259
  email: "Email",
256
260
  roles: "Papéis",
261
+ team: "Equipe",
262
+ selectTeam: "Selecione a equipe",
257
263
  status: "Status",
258
264
  active: "Ativo",
259
265
  inactive: "Inativo",
@@ -288,13 +294,27 @@ export const pt = {
288
294
  avatarUpdated: "Foto de perfil atualizada",
289
295
  avatarRemoved: "Foto de perfil removida",
290
296
  },
291
- rbac: { title: "Papéis & Permissões" },
297
+ rbac: {
298
+ title: "Papéis & Permissões",
299
+ manager: "Gerente",
300
+ noManager: "Sem gerente",
301
+ },
292
302
  validation: {
293
303
  required: "Campo obrigatório",
294
304
  email: "Email inválido",
295
305
  passwordStrong:
296
306
  "Mín. 8 caracteres com maiúscula, minúscula, número e símbolo",
297
307
  passwordsNotMatch: "As senhas não conferem",
308
+ // Uma linha por exigência — é o que o PasswordRequirements lista enquanto
309
+ // a pessoa digita. Devem descrever a mesma coisa que `passwordStrong`.
310
+ rules: {
311
+ strength: "Força da senha",
312
+ minLength: "No mínimo {count} caracteres",
313
+ lowercase: "Pelo menos uma letra minúscula",
314
+ uppercase: "Pelo menos uma letra maiúscula",
315
+ digit: "Pelo menos um número",
316
+ special: "Pelo menos um caractere especial (!@#$...)",
317
+ },
298
318
  codeShort: "Código muito curto",
299
319
  },
300
320
  errors: {
@@ -311,6 +331,8 @@ export const pt = {
311
331
  "Esta senha já foi utilizada. Escolha uma senha que você ainda não usou neste sistema.",
312
332
  TOO_MANY_REQUESTS:
313
333
  "Muitas tentativas em pouco tempo. Aguarde um instante e tente de novo.",
334
+ PENDING_TOKEN_EXPIRED:
335
+ "A verificação expirou. Entre novamente para continuar.",
314
336
  },
315
337
  },
316
338
  };
package/src/index.ts CHANGED
@@ -41,6 +41,9 @@ export {
41
41
  // ---------------------------------------------------------------------------
42
42
  export * from "#core/components/ui";
43
43
  export { cn } from "#core/lib/utils";
44
+ // O projeto que criar tela com escopo de equipe monta o par de códigos com ele,
45
+ // em vez de repetir os sufixos à mão.
46
+ export { anyOrTeam } from "#core/_utils/permission";
44
47
 
45
48
  // ---------------------------------------------------------------------------
46
49
  // Hooks de listagem, requisição e filtro
@@ -1,6 +1,6 @@
1
1
  /*
2
- * Design system do rl-core-front: tokens de cor, base do body, correção do
3
- * autofill e a barra de progresso do toast.
2
+ * Design system do rl-core-front: tipografia, tokens de cor, base do body,
3
+ * correção do autofill e a barra de progresso do toast.
4
4
  *
5
5
  * Um projeto que consome o pacote importa este arquivo no globals.css dele,
6
6
  * depois do `@import "tailwindcss"`:
@@ -10,6 +10,44 @@
10
10
  * @import "rl-core-front/styles.css";
11
11
  */
12
12
 
13
+ /*
14
+ * Poppins, hospedada no próprio pacote — sem CDN, sem requisição a terceiro e
15
+ * sem passo de instalação no projeto: quem importa este arquivo já recebe a
16
+ * fonte. Só o subset `latin`, que cobre português e inglês por inteiro; glifo
17
+ * fora dele cai no fallback da stack, que é o comportamento normal do browser.
18
+ *
19
+ * Não usamos `next/font` aqui de propósito. O pacote é publicado em
20
+ * código-fonte, e `next/font` é resolvido pelo build de QUEM INSTALA — a mesma
21
+ * armadilha do alias `@/` descrita no §4 do CLAUDE.md.
22
+ */
23
+ @font-face {
24
+ font-family: 'Poppins';
25
+ font-style: normal;
26
+ font-weight: 400;
27
+ font-display: swap;
28
+ src: url('./fonts/poppins-latin-400.woff2') format('woff2');
29
+ }
30
+ @font-face {
31
+ font-family: 'Poppins';
32
+ font-style: normal;
33
+ font-weight: 500;
34
+ font-display: swap;
35
+ src: url('./fonts/poppins-latin-500.woff2') format('woff2');
36
+ }
37
+ @font-face {
38
+ font-family: 'Poppins';
39
+ font-style: normal;
40
+ font-weight: 600;
41
+ font-display: swap;
42
+ src: url('./fonts/poppins-latin-600.woff2') format('woff2');
43
+ }
44
+ @font-face {
45
+ font-family: 'Poppins';
46
+ font-style: normal;
47
+ font-weight: 700;
48
+ font-display: swap;
49
+ src: url('./fonts/poppins-latin-700.woff2') format('woff2');
50
+ }
13
51
 
14
52
  @layer base {
15
53
  :root {
@@ -20,6 +58,20 @@
20
58
  * escuro. É a causa raiz do input branco.
21
59
  */
22
60
  color-scheme: light;
61
+
62
+ /*
63
+ * Tipografia — o ponto de extensão da fonte. O core entrega Poppins; um
64
+ * projeto troca a família inteira redefinindo esta variável no globals.css
65
+ * dele, inclusive apontando para o que o `next/font` gerou:
66
+ *
67
+ * :root { --font-sans: var(--font-geist), sans-serif; }
68
+ *
69
+ * Fica no `:root` e não é redeclarada no `.dark`: fonte não muda com tema.
70
+ */
71
+ --font-sans: 'Poppins', ui-sans-serif, system-ui, -apple-system,
72
+ 'Segoe UI', sans-serif;
73
+ --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
74
+
23
75
  --background: 210 20% 98%;
24
76
  --foreground: 222 47% 11%;
25
77
  --card: 0 0% 100%;
@@ -40,9 +92,32 @@
40
92
  --success-foreground: 0 0% 100%;
41
93
  --warning: 38 92% 50%;
42
94
  --warning-foreground: 0 0% 100%;
95
+
96
+ /*
97
+ * Versão legível como TEXTO. Verde e âmbar são claros por natureza: no tema
98
+ * claro, `--success`/`--warning` escritos sobre fundo branco dão ~2:1 de
99
+ * contraste, abaixo do mínimo de acessibilidade. Preenchimento continua
100
+ * usando a cor cheia; quem vira letra usa esta.
101
+ */
102
+ --success-strong: 142 72% 29%;
103
+ --warning-strong: 32 95% 30%;
43
104
  --border: 214 32% 91%;
44
105
  --input: 214 32% 91%;
45
106
  --ring: 211 80% 42%;
107
+
108
+ /*
109
+ * Trilha e pílula acesa dos controles segmentados (`Tabs` e
110
+ * `SegmentedControl`, que dividem a mesma pele).
111
+ *
112
+ * Existem como par próprio porque a regra que precisa valer é relativa: a
113
+ * pílula acesa tem que ser mais clara que a trilha em que ela está. Antes
114
+ * isto era `bg-muted` + `bg-card`, e no tema escuro a relação se invertia
115
+ * (trilha 18%, acesa 13%) — a opção selecionada ficava mais ESCURA que o
116
+ * fundo dela e só se distinguia pelo peso da fonte.
117
+ */
118
+ --track: 210 40% 94%;
119
+ --track-active: 0 0% 100%;
120
+
46
121
  --radius: 0.6rem;
47
122
  }
48
123
 
@@ -68,9 +143,17 @@
68
143
  --success-foreground: 0 0% 100%;
69
144
  --warning: 38 92% 55%;
70
145
  --warning-foreground: 213 30% 10%;
146
+
147
+ /* No escuro o problema se inverte: como texto, precisam clarear. */
148
+ --success-strong: 142 60% 62%;
149
+ --warning-strong: 38 92% 65%;
71
150
  --border: 213 20% 22%;
72
151
  --input: 213 20% 24%;
73
152
  --ring: 211 85% 60%;
153
+
154
+ /* Trilha abaixo do card (13%) e pílula acima dele: a acesa salta. */
155
+ --track: 213 25% 11%;
156
+ --track-active: 213 20% 24%;
74
157
  }
75
158
  }
76
159
 
@@ -80,7 +163,21 @@
80
163
  }
81
164
  body {
82
165
  @apply bg-background text-foreground;
166
+ font-family: var(--font-sans);
83
167
  font-feature-settings: 'rlig' 1, 'calt' 1;
168
+ -webkit-font-smoothing: antialiased;
169
+ -moz-osx-font-smoothing: grayscale;
170
+ }
171
+
172
+ /*
173
+ * Poppins é geométrica e larga: no tamanho de título, o espaçamento padrão
174
+ * abre demais. Fechar um pouco vale só do `xl` para cima — em texto corrido
175
+ * o mesmo ajuste prejudicaria a leitura.
176
+ */
177
+ h1,
178
+ h2,
179
+ h3 {
180
+ letter-spacing: -0.02em;
84
181
  }
85
182
 
86
183
  /*
@@ -33,6 +33,15 @@ const preset: Omit<Config, 'content'> = {
33
33
  screens: { '2xl': '1400px' },
34
34
  },
35
35
  extend: {
36
+ /*
37
+ * Apontam para os tokens, e não para uma família fixa: é o que permite a
38
+ * um projeto trocar a fonte redefinindo `--font-sans` no CSS dele, sem
39
+ * tocar neste preset nem no pacote. Ver `styles/core.css`.
40
+ */
41
+ fontFamily: {
42
+ sans: ['var(--font-sans)'],
43
+ mono: ['var(--font-mono)'],
44
+ },
36
45
  colors: {
37
46
  border: 'hsl(var(--border))',
38
47
  input: 'hsl(var(--input))',
@@ -70,10 +79,18 @@ const preset: Omit<Config, 'content'> = {
70
79
  success: {
71
80
  DEFAULT: 'hsl(var(--success))',
72
81
  foreground: 'hsl(var(--success-foreground))',
82
+ /* `strong` é a variante para texto — ver core.css. */
83
+ strong: 'hsl(var(--success-strong))',
73
84
  },
74
85
  warning: {
75
86
  DEFAULT: 'hsl(var(--warning))',
76
87
  foreground: 'hsl(var(--warning-foreground))',
88
+ strong: 'hsl(var(--warning-strong))',
89
+ },
90
+ /* Trilha e pílula acesa dos controles segmentados — ver core.css. */
91
+ track: {
92
+ DEFAULT: 'hsl(var(--track))',
93
+ active: 'hsl(var(--track-active))',
77
94
  },
78
95
  },
79
96
  borderRadius: {