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.
- package/package.json +1 -1
- package/src/_services/api/app-error-code.enum.ts +23 -0
- package/src/_services/api/schema.d.ts +43 -0
- package/src/_services/auth/index.ts +2 -0
- package/src/_utils/password.ts +40 -0
- package/src/_utils/permission.ts +14 -0
- package/src/_utils/storage.ts +64 -0
- package/src/_utils/user.ts +19 -0
- package/src/components/app-shell.tsx +37 -13
- package/src/components/password-field.tsx +49 -22
- package/src/components/ui/alert.tsx +34 -5
- package/src/components/ui/badge.tsx +2 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/password-requirements.tsx +105 -0
- package/src/components/ui/segmented-control.tsx +7 -6
- package/src/components/ui/simple-select.tsx +74 -0
- package/src/components/ui/switch.tsx +3 -1
- package/src/components/ui/tabs.tsx +4 -8
- package/src/components/ui/track.styles.ts +31 -0
- package/src/contexts/color-mode-context.tsx +4 -2
- package/src/contexts/i18n-context.tsx +3 -2
- package/src/contexts/socket-context.tsx +27 -1
- package/src/features/audit/components/audit-diff.tsx +1 -1
- package/src/features/login/components/backup-codes-dialog.tsx +4 -4
- package/src/features/login/components/login-form.tsx +18 -3
- package/src/features/login/components/two-factor-setup.tsx +32 -2
- package/src/features/login/hooks/use-login-flow.ts +60 -7
- package/src/features/login/login-screen.tsx +17 -4
- package/src/features/profile/force-password-change.tsx +18 -3
- package/src/features/profile/profile-screen.tsx +8 -1
- package/src/features/rbac/panels/groups-panel.tsx +58 -23
- package/src/features/rbac/panels/roles-panel.tsx +2 -2
- package/src/features/rbac/services/rbac.service.ts +23 -27
- package/src/features/recovery/reset-password-screen.tsx +16 -2
- package/src/features/users/components/user-form-dialog.tsx +49 -2
- package/src/features/users/hooks/use-users.ts +15 -1
- package/src/features/users/services/users.service.ts +16 -0
- package/src/features/users/users-screen.tsx +17 -12
- package/src/hooks/use-column-visibility.ts +13 -12
- package/src/hooks/use-countdown.ts +70 -0
- package/src/hooks/use-request.ts +21 -2
- package/src/i18n/messages/en.ts +20 -1
- package/src/i18n/messages/pt.ts +23 -1
- package/src/index.ts +3 -0
- package/src/styles/core.css +99 -2
- package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
- 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.
|
|
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 {
|
package/src/_utils/password.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
114
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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="
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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({
|
|
32
|
+
export function Alert({
|
|
33
|
+
variant,
|
|
34
|
+
className,
|
|
35
|
+
icon,
|
|
36
|
+
children,
|
|
37
|
+
...props
|
|
38
|
+
}: AlertProps): React.JSX.Element {
|
|
24
39
|
return (
|
|
25
|
-
<div
|
|
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
|
+
}
|