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
|
@@ -4,6 +4,8 @@ import * as React from "react";
|
|
|
4
4
|
|
|
5
5
|
import { cn } from "#core/lib/utils";
|
|
6
6
|
|
|
7
|
+
import { TRACK_CLASS, TRACK_ITEM_CLASS } from "./track.styles";
|
|
8
|
+
|
|
7
9
|
export interface SegmentedOption {
|
|
8
10
|
value: string;
|
|
9
11
|
label: string;
|
|
@@ -49,7 +51,7 @@ export const SegmentedControl = ({
|
|
|
49
51
|
role="group"
|
|
50
52
|
aria-label={label}
|
|
51
53
|
className={cn(
|
|
52
|
-
|
|
54
|
+
TRACK_CLASS,
|
|
53
55
|
fullWidth ? "flex w-full" : "inline-flex",
|
|
54
56
|
className,
|
|
55
57
|
)}
|
|
@@ -60,12 +62,11 @@ export const SegmentedControl = ({
|
|
|
60
62
|
key={option.value}
|
|
61
63
|
type="button"
|
|
62
64
|
aria-pressed={option.value === value}
|
|
65
|
+
// Só para vestir a pele compartilhada, que seleciona por `data-state`.
|
|
66
|
+
// Quem anuncia o estado ao leitor de tela continua sendo o aria-pressed.
|
|
67
|
+
data-state={option.value === value ? "active" : "inactive"}
|
|
63
68
|
onClick={() => onValueChange(option.value)}
|
|
64
|
-
className={cn(
|
|
65
|
-
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
66
|
-
fullWidth && "flex-1",
|
|
67
|
-
option.value === value && "bg-card text-foreground shadow-sm",
|
|
68
|
-
)}
|
|
69
|
+
className={cn(TRACK_ITEM_CLASS, fullWidth && "flex-1")}
|
|
69
70
|
>
|
|
70
71
|
{option.label}
|
|
71
72
|
</button>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from "#core/components/ui/select";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mesmo formato que o `Label` dos enums do §7 já produz — nada a converter
|
|
15
|
+
* entre um catálogo de opções e este componente.
|
|
16
|
+
*/
|
|
17
|
+
export interface SelectOption {
|
|
18
|
+
value: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SimpleSelectProps {
|
|
23
|
+
value: string;
|
|
24
|
+
onChange: (value: string) => void;
|
|
25
|
+
options: SelectOption[];
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Rótulo da opção "nenhum". Ausente, o campo não oferece vazio.
|
|
29
|
+
*
|
|
30
|
+
* O Radix não aceita `value=""` num item, então a opção vazia precisa de uma
|
|
31
|
+
* chave-sentinela — detalhe que some aqui dentro em vez de reaparecer em toda
|
|
32
|
+
* tela que precisa de um campo opcional.
|
|
33
|
+
*/
|
|
34
|
+
emptyLabel?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EMPTY = "__none__";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* O caso comum do `Select`: uma lista de opções e um valor.
|
|
42
|
+
*
|
|
43
|
+
* Os primitivos do `select.tsx` continuam disponíveis para quem precisa de
|
|
44
|
+
* grupos, separadores ou item com markup — este cobre o resto, que era a
|
|
45
|
+
* mesma casca de cinco componentes repetida a cada campo.
|
|
46
|
+
*/
|
|
47
|
+
export function SimpleSelect({
|
|
48
|
+
value,
|
|
49
|
+
onChange,
|
|
50
|
+
options,
|
|
51
|
+
placeholder,
|
|
52
|
+
emptyLabel,
|
|
53
|
+
disabled,
|
|
54
|
+
}: SimpleSelectProps): JSX.Element {
|
|
55
|
+
return (
|
|
56
|
+
<Select
|
|
57
|
+
value={value || (emptyLabel ? EMPTY : "")}
|
|
58
|
+
onValueChange={(next) => onChange(next === EMPTY ? "" : next)}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
>
|
|
61
|
+
<SelectTrigger>
|
|
62
|
+
<SelectValue placeholder={placeholder} />
|
|
63
|
+
</SelectTrigger>
|
|
64
|
+
<SelectContent>
|
|
65
|
+
{emptyLabel && <SelectItem value={EMPTY}>{emptyLabel}</SelectItem>}
|
|
66
|
+
{options.map((option) => (
|
|
67
|
+
<SelectItem key={option.value} value={option.value}>
|
|
68
|
+
{option.label}
|
|
69
|
+
</SelectItem>
|
|
70
|
+
))}
|
|
71
|
+
</SelectContent>
|
|
72
|
+
</Select>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -19,7 +19,9 @@ const Switch = React.forwardRef<
|
|
|
19
19
|
>
|
|
20
20
|
<SwitchPrimitives.Thumb
|
|
21
21
|
className={cn(
|
|
22
|
-
|
|
22
|
+
// `bg-card` e não `bg-background`: no tema claro o card é branco puro e
|
|
23
|
+
// destaca do trilho; o fundo da página quase se confunde com ele.
|
|
24
|
+
"pointer-events-none block h-4 w-4 rounded-full bg-card shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
|
23
25
|
)}
|
|
24
26
|
/>
|
|
25
27
|
</SwitchPrimitives.Root>
|
|
@@ -5,6 +5,8 @@ import * as React from "react";
|
|
|
5
5
|
|
|
6
6
|
import { cn } from "#core/lib/utils";
|
|
7
7
|
|
|
8
|
+
import { TRACK_CLASS, TRACK_ITEM_CLASS } from "./track.styles";
|
|
9
|
+
|
|
8
10
|
const Tabs = TabsPrimitive.Root;
|
|
9
11
|
|
|
10
12
|
const TabsList = React.forwardRef<
|
|
@@ -13,10 +15,7 @@ const TabsList = React.forwardRef<
|
|
|
13
15
|
>(({ className, ...props }, ref) => (
|
|
14
16
|
<TabsPrimitive.List
|
|
15
17
|
ref={ref}
|
|
16
|
-
className={cn(
|
|
17
|
-
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
18
|
-
className,
|
|
19
|
-
)}
|
|
18
|
+
className={cn("inline-flex", TRACK_CLASS, className)}
|
|
20
19
|
{...props}
|
|
21
20
|
/>
|
|
22
21
|
));
|
|
@@ -28,10 +27,7 @@ const TabsTrigger = React.forwardRef<
|
|
|
28
27
|
>(({ className, ...props }, ref) => (
|
|
29
28
|
<TabsPrimitive.Trigger
|
|
30
29
|
ref={ref}
|
|
31
|
-
className={cn(
|
|
32
|
-
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
33
|
-
className,
|
|
34
|
-
)}
|
|
30
|
+
className={cn(TRACK_ITEM_CLASS, className)}
|
|
35
31
|
{...props}
|
|
36
32
|
/>
|
|
37
33
|
));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pele compartilhada dos controles em trilha: `Tabs` e `SegmentedControl`.
|
|
3
|
+
*
|
|
4
|
+
* Os dois sempre tiveram a mesma aparência, mas cada um com a sua cópia das
|
|
5
|
+
* classes — então o contraste quebrado no tema escuro precisava ser corrigido
|
|
6
|
+
* em dois lugares, e só um foi. Aqui é uma verdade só (§6 do CLAUDE.md).
|
|
7
|
+
*
|
|
8
|
+
* Ambos marcam o item com `data-state="active" | "inactive"`: o `Tabs` porque o
|
|
9
|
+
* Radix já emite esse atributo, o `SegmentedControl` porque escrevê-lo à mão
|
|
10
|
+
* sai mais barato que manter uma segunda lista de classes condicionais.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A trilha: o fundo em que as opções vivem. */
|
|
14
|
+
export const TRACK_CLASS =
|
|
15
|
+
"h-10 items-center justify-center rounded-lg bg-track p-1 text-muted-foreground ring-1 ring-inset ring-border/60";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A opção. O estado aceso vem por `data-[state=active]`, com anel próprio:
|
|
19
|
+
* a sombra sozinha é invisível sobre fundo escuro, e era parte do motivo de a
|
|
20
|
+
* opção selecionada sumir no tema escuro.
|
|
21
|
+
*/
|
|
22
|
+
export const TRACK_ITEM_CLASS = [
|
|
23
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5",
|
|
24
|
+
"text-sm font-medium ring-offset-background transition-all",
|
|
25
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
26
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
27
|
+
// Feedback de mouse no que ainda não está selecionado — antes não havia nenhum.
|
|
28
|
+
"data-[state=inactive]:hover:bg-foreground/5 data-[state=inactive]:hover:text-foreground",
|
|
29
|
+
"data-[state=active]:bg-track-active data-[state=active]:text-foreground",
|
|
30
|
+
"data-[state=active]:shadow-sm data-[state=active]:ring-1 data-[state=active]:ring-border",
|
|
31
|
+
].join(" ");
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
useState,
|
|
11
11
|
} from "react";
|
|
12
12
|
|
|
13
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
14
|
+
|
|
13
15
|
export const STORAGE_KEY = "color-mode";
|
|
14
16
|
|
|
15
17
|
export type ColorPreference = "light" | "dark" | "system";
|
|
@@ -54,7 +56,7 @@ export function ColorModeProvider({ children }: { children: React.ReactNode }):
|
|
|
54
56
|
const [resolved, setResolved] = useState(false);
|
|
55
57
|
|
|
56
58
|
useEffect(() => {
|
|
57
|
-
const saved =
|
|
59
|
+
const saved = safeStorage.get(STORAGE_KEY);
|
|
58
60
|
if (isPreference(saved)) {
|
|
59
61
|
setPreferenceState(saved);
|
|
60
62
|
}
|
|
@@ -77,7 +79,7 @@ export function ColorModeProvider({ children }: { children: React.ReactNode }):
|
|
|
77
79
|
|
|
78
80
|
const setPreference = useCallback((p: ColorPreference) => {
|
|
79
81
|
setPreferenceState(p);
|
|
80
|
-
|
|
82
|
+
safeStorage.set(STORAGE_KEY, p);
|
|
81
83
|
}, []);
|
|
82
84
|
|
|
83
85
|
const toggle = useCallback(
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
useState,
|
|
11
11
|
} from "react";
|
|
12
12
|
|
|
13
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
13
14
|
import { en } from "#core/i18n/messages/en";
|
|
14
15
|
import { pt } from "#core/i18n/messages/pt";
|
|
15
16
|
|
|
@@ -100,13 +101,13 @@ export function I18nProvider({
|
|
|
100
101
|
);
|
|
101
102
|
|
|
102
103
|
useEffect(() => {
|
|
103
|
-
const saved = (
|
|
104
|
+
const saved = (safeStorage.get(STORAGE_KEY) as Locale) || null;
|
|
104
105
|
if (saved === "pt" || saved === "en") {setLocaleState(saved);}
|
|
105
106
|
}, []);
|
|
106
107
|
|
|
107
108
|
const setLocale = useCallback((l: Locale) => {
|
|
108
109
|
setLocaleState(l);
|
|
109
|
-
|
|
110
|
+
safeStorage.set(STORAGE_KEY, l);
|
|
110
111
|
document.cookie = `${STORAGE_KEY}=${l}; path=/; max-age=31536000`;
|
|
111
112
|
}, []);
|
|
112
113
|
|
|
@@ -46,6 +46,12 @@ const SOCKET_URL =
|
|
|
46
46
|
process.env.NEXT_PUBLIC_SOCKET_URL ?? "http://localhost:3108";
|
|
47
47
|
const NAMESPACE = "/notifications";
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* O servidor pede que a sessão seja relida (ver socket.constants.ts do backend).
|
|
51
|
+
* Chega quando um admin mexe no que o usuário pode ver — hoje, seus papéis.
|
|
52
|
+
*/
|
|
53
|
+
export const SESSION_REFRESH_EVENT = "session:refresh";
|
|
54
|
+
|
|
49
55
|
/**
|
|
50
56
|
* Conexão única de socket da aplicação.
|
|
51
57
|
*
|
|
@@ -60,7 +66,7 @@ export function SocketProvider({
|
|
|
60
66
|
}: {
|
|
61
67
|
children: React.ReactNode;
|
|
62
68
|
}): JSX.Element {
|
|
63
|
-
const { user } = useAuth();
|
|
69
|
+
const { user, refreshProfile } = useAuth();
|
|
64
70
|
const [status, setStatus] = useState<SocketStatus>("disconnected");
|
|
65
71
|
const [reconnectCount, setReconnectCount] = useState(0);
|
|
66
72
|
const socketRef = useRef<Socket | null>(null);
|
|
@@ -134,6 +140,26 @@ export function SocketProvider({
|
|
|
134
140
|
[],
|
|
135
141
|
);
|
|
136
142
|
|
|
143
|
+
/**
|
|
144
|
+
* O que o usuário pode ver mudou enquanto ele estava logado.
|
|
145
|
+
*
|
|
146
|
+
* São duas coisas velhas ao mesmo tempo: o perfil em memória (menu e
|
|
147
|
+
* permissões saem dele) e as salas do socket, resolvidas no handshake a
|
|
148
|
+
* partir dos papéis. Reler o perfil e refazer a conexão corrige as duas sem
|
|
149
|
+
* obrigar ninguém a recarregar a página — e a reconexão ainda repõe o que
|
|
150
|
+
* chegou na janela em que o socket esteve fora.
|
|
151
|
+
*/
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
return on<{ userId: string }>(SESSION_REFRESH_EVENT, () => {
|
|
154
|
+
void refreshProfile();
|
|
155
|
+
const socket = socketRef.current;
|
|
156
|
+
if (socket) {
|
|
157
|
+
socket.disconnect();
|
|
158
|
+
socket.connect();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}, [on, refreshProfile]);
|
|
162
|
+
|
|
137
163
|
const value = useMemo<SocketContextType>(
|
|
138
164
|
() => ({ status, reconnectCount, on }),
|
|
139
165
|
[status, reconnectCount, on],
|
|
@@ -131,7 +131,7 @@ export function AuditDiff({ change }: AuditDiffProps): JSX.Element {
|
|
|
131
131
|
<TableCell className="whitespace-pre-wrap text-destructive/90">
|
|
132
132
|
{row.before}
|
|
133
133
|
</TableCell>
|
|
134
|
-
<TableCell className="whitespace-pre-wrap text-success">
|
|
134
|
+
<TableCell className="whitespace-pre-wrap text-success-strong">
|
|
135
135
|
{row.after}
|
|
136
136
|
</TableCell>
|
|
137
137
|
</TableRow>
|
|
@@ -4,6 +4,7 @@ import { AlertTriangle } from "lucide-react";
|
|
|
4
4
|
import type { JSX } from "react";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
Alert,
|
|
7
8
|
Button,
|
|
8
9
|
Dialog,
|
|
9
10
|
DialogContent,
|
|
@@ -43,10 +44,9 @@ export function BackupCodesDialog({ open, codes, onClose }: Props): JSX.Element
|
|
|
43
44
|
<DialogTitle>{t("auth.backupTitle")}</DialogTitle>
|
|
44
45
|
</DialogHeader>
|
|
45
46
|
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
</div>
|
|
47
|
+
<Alert variant="warning" icon={<AlertTriangle />}>
|
|
48
|
+
{t("auth.backupWarning")}
|
|
49
|
+
</Alert>
|
|
50
50
|
|
|
51
51
|
<div className="grid grid-cols-2 gap-2 rounded-lg bg-muted p-4 font-mono text-sm text-foreground">
|
|
52
52
|
{codes.map((c) => (
|
|
@@ -17,16 +17,29 @@ import {
|
|
|
17
17
|
interface Props {
|
|
18
18
|
loading: boolean;
|
|
19
19
|
onSubmit: (email: string, password: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Email já preenchido. Serve a quem foi devolvido para cá porque a
|
|
22
|
+
* verificação de 2FA venceu: a senha o sistema não pode guardar, mas fazer a
|
|
23
|
+
* pessoa redigitar o email também seria gratuito.
|
|
24
|
+
*/
|
|
25
|
+
defaultEmail?: string;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
export function LoginForm({
|
|
28
|
+
export function LoginForm({
|
|
29
|
+
loading,
|
|
30
|
+
onSubmit,
|
|
31
|
+
defaultEmail,
|
|
32
|
+
}: Props): JSX.Element {
|
|
23
33
|
const { t } = useI18n();
|
|
24
34
|
const schema = useMemo(() => makeLoginSchema(t), [t]);
|
|
25
35
|
const {
|
|
26
36
|
register,
|
|
27
37
|
handleSubmit,
|
|
28
38
|
formState: { errors },
|
|
29
|
-
} = useForm<LoginFormData>({
|
|
39
|
+
} = useForm<LoginFormData>({
|
|
40
|
+
resolver: yupResolver(schema),
|
|
41
|
+
defaultValues: { email: defaultEmail ?? "", password: "" },
|
|
42
|
+
});
|
|
30
43
|
|
|
31
44
|
return (
|
|
32
45
|
<form
|
|
@@ -37,13 +50,15 @@ export function LoginForm({ loading, onSubmit }: Props): JSX.Element {
|
|
|
37
50
|
<Input
|
|
38
51
|
type="email"
|
|
39
52
|
autoComplete="email"
|
|
40
|
-
|
|
53
|
+
// Com o email já preenchido, o cursor vai direto para o que falta.
|
|
54
|
+
autoFocus={!defaultEmail}
|
|
41
55
|
{...register("email")}
|
|
42
56
|
/>
|
|
43
57
|
</Field>
|
|
44
58
|
<Field label={t("auth.password")} error={errors.password?.message}>
|
|
45
59
|
<PasswordField
|
|
46
60
|
autoComplete="current-password"
|
|
61
|
+
autoFocus={!!defaultEmail}
|
|
47
62
|
error={!!errors.password}
|
|
48
63
|
{...register("password")}
|
|
49
64
|
/>
|
|
@@ -13,15 +13,25 @@ import {
|
|
|
13
13
|
CodeFormData,
|
|
14
14
|
makeCodeSchema,
|
|
15
15
|
} from "#core/features/login/validation/schemas";
|
|
16
|
+
import { useCountdown } from "#core/hooks/use-countdown";
|
|
17
|
+
import { cn } from "#core/lib/utils";
|
|
16
18
|
|
|
17
19
|
interface Props {
|
|
18
20
|
setup: TotpSetup;
|
|
19
21
|
loading: boolean;
|
|
20
22
|
onConfirm: (code: string) => void;
|
|
23
|
+
/** Prazo para confirmar, em segundos. `null` esconde o contador. */
|
|
24
|
+
expiresInSeconds?: number | null;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
export function TwoFactorSetup({
|
|
27
|
+
export function TwoFactorSetup({
|
|
28
|
+
setup,
|
|
29
|
+
loading,
|
|
30
|
+
onConfirm,
|
|
31
|
+
expiresInSeconds = null,
|
|
32
|
+
}: Props): JSX.Element {
|
|
24
33
|
const { t } = useI18n();
|
|
34
|
+
const { formatted, remaining, expired } = useCountdown(expiresInSeconds);
|
|
25
35
|
const schema = useMemo(() => makeCodeSchema(t), [t]);
|
|
26
36
|
const {
|
|
27
37
|
register,
|
|
@@ -62,7 +72,27 @@ export function TwoFactorSetup({ setup, loading, onConfirm }: Props): JSX.Elemen
|
|
|
62
72
|
{...register("code")}
|
|
63
73
|
/>
|
|
64
74
|
</Field>
|
|
65
|
-
|
|
75
|
+
{/*
|
|
76
|
+
O prazo fica à vista porque é justamente aqui que a pessoa demora —
|
|
77
|
+
instalar o app, escanear, digitar. Antes ele vencia em silêncio e o
|
|
78
|
+
código certo voltava como inválido.
|
|
79
|
+
*/}
|
|
80
|
+
{expiresInSeconds !== null && (
|
|
81
|
+
<p
|
|
82
|
+
className={cn(
|
|
83
|
+
"text-center text-xs",
|
|
84
|
+
// Último minuto: deixa de ser informação e passa a ser aviso.
|
|
85
|
+
remaining <= 60 ? "text-warning-strong" : "text-muted-foreground",
|
|
86
|
+
)}
|
|
87
|
+
aria-live="polite"
|
|
88
|
+
>
|
|
89
|
+
{expired
|
|
90
|
+
? t("auth.expiredHint")
|
|
91
|
+
: t("auth.expiresIn", { time: formatted })}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<Button type="submit" size="lg" disabled={loading || expired}>
|
|
66
96
|
{loading ? t("auth.confirming") : t("auth.confirmActivate")}
|
|
67
97
|
</Button>
|
|
68
98
|
</form>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
|
+
import { AppErrorCode } from "#core/_services/api/app-error-code.enum";
|
|
11
12
|
import { authService, TotpSetup } from "#core/_services/auth";
|
|
12
13
|
import { useAuth, useI18n, useToast } from "#core/contexts";
|
|
13
14
|
import { useRequest } from "#core/hooks/use-request";
|
|
@@ -26,6 +27,15 @@ export interface UseLoginFlowResult {
|
|
|
26
27
|
loading: boolean;
|
|
27
28
|
error: string | null;
|
|
28
29
|
setError: Dispatch<SetStateAction<string | null>>;
|
|
30
|
+
/**
|
|
31
|
+
* Aviso que explica por que a tela voltou ao início. Diferente de `error`,
|
|
32
|
+
* atravessa a troca de passo.
|
|
33
|
+
*/
|
|
34
|
+
notice: string | null;
|
|
35
|
+
/** Último email digitado — repõe o campo quando a verificação vence. */
|
|
36
|
+
email: string;
|
|
37
|
+
/** Prazo da etapa do código, em segundos. `null` fora dessa etapa. */
|
|
38
|
+
expiresInSeconds: number | null;
|
|
29
39
|
submitCredentials: (email: string, password: string) => Promise<void>;
|
|
30
40
|
submitCode: (
|
|
31
41
|
code: string,
|
|
@@ -46,15 +56,52 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
46
56
|
const [pendingToken, setPendingToken] = useState("");
|
|
47
57
|
const [setup, setSetup] = useState<TotpSetup | null>(null);
|
|
48
58
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
59
|
+
const [email, setEmail] = useState("");
|
|
60
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
61
|
+
const [expiresInSeconds, setExpiresInSeconds] = useState<number | null>(null);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A janela entre a senha e o código venceu.
|
|
65
|
+
*
|
|
66
|
+
* Devolve ao primeiro passo em vez de deixar a pessoa presa numa tela cujo
|
|
67
|
+
* botão só responde erro — que era o que acontecia no primeiro acesso: o
|
|
68
|
+
* usuário instalava o autenticador, digitava o código certo e recebia
|
|
69
|
+
* "inválido", sem caminho nenhum. O email volta preenchido, e o QR do próximo
|
|
70
|
+
* login é o mesmo (o backend reaproveita o segredo pendente), então quem já
|
|
71
|
+
* escaneou não precisa escanear de novo.
|
|
72
|
+
*/
|
|
73
|
+
const onExpired = useCallback(
|
|
74
|
+
({ code }: { code: string | null }) => {
|
|
75
|
+
if (code !== AppErrorCode.PENDING_TOKEN_EXPIRED) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setStep("credentials");
|
|
79
|
+
setSetup(null);
|
|
80
|
+
setPendingToken("");
|
|
81
|
+
setExpiresInSeconds(null);
|
|
82
|
+
/*
|
|
83
|
+
* Vai em `notice`, e não em `error`: a tela limpa o erro a cada troca de
|
|
84
|
+
* passo, e é justamente um passo que estamos trocando aqui — o aviso
|
|
85
|
+
* sumiria no mesmo render em que aparece.
|
|
86
|
+
*/
|
|
87
|
+
setNotice(t("auth.verificationExpired"));
|
|
88
|
+
},
|
|
89
|
+
[t],
|
|
90
|
+
);
|
|
49
91
|
|
|
50
92
|
const submitCredentials = useCallback(
|
|
51
93
|
async (email: string, password: string) => {
|
|
94
|
+
setEmail(email);
|
|
95
|
+
setNotice(null);
|
|
52
96
|
const res = await run(() => authService.login(email, password));
|
|
53
97
|
if (!res) {return;}
|
|
54
98
|
setPendingToken(res.pendingToken);
|
|
99
|
+
setExpiresInSeconds(res.expiresInSeconds);
|
|
55
100
|
|
|
56
101
|
if (res.status === "SETUP_REQUIRED") {
|
|
57
|
-
const s = await run(() => authService.setupTotp(res.pendingToken)
|
|
102
|
+
const s = await run(() => authService.setupTotp(res.pendingToken), {
|
|
103
|
+
onError: onExpired,
|
|
104
|
+
});
|
|
58
105
|
if (!s) {return;}
|
|
59
106
|
setSetup(s);
|
|
60
107
|
setStep("setup");
|
|
@@ -70,13 +117,14 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
70
117
|
.catch(() => {});
|
|
71
118
|
}
|
|
72
119
|
},
|
|
73
|
-
[run, notify, t],
|
|
120
|
+
[run, notify, t, onExpired],
|
|
74
121
|
);
|
|
75
122
|
|
|
76
123
|
const submitCode = useCallback(
|
|
77
124
|
async (code: string, method?: "totp" | "email" | "backup") => {
|
|
78
|
-
const res = await run(
|
|
79
|
-
authService.verify(pendingToken, code, method),
|
|
125
|
+
const res = await run(
|
|
126
|
+
() => authService.verify(pendingToken, code, method),
|
|
127
|
+
{ onError: onExpired },
|
|
80
128
|
);
|
|
81
129
|
if (!res) {return;}
|
|
82
130
|
|
|
@@ -89,13 +137,15 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
89
137
|
router.replace("/dashboard");
|
|
90
138
|
}
|
|
91
139
|
},
|
|
92
|
-
[run, pendingToken, notify, router, t, refreshProfile],
|
|
140
|
+
[run, pendingToken, notify, router, t, refreshProfile, onExpired],
|
|
93
141
|
);
|
|
94
142
|
|
|
95
143
|
const requestEmailCode = useCallback(async () => {
|
|
96
|
-
const res = await run(() => authService.requestEmailCode(pendingToken)
|
|
144
|
+
const res = await run(() => authService.requestEmailCode(pendingToken), {
|
|
145
|
+
onError: onExpired,
|
|
146
|
+
});
|
|
97
147
|
if (res?.sent) {notify(t("auth.codeSent"), "success");}
|
|
98
|
-
}, [run, pendingToken, notify, t]);
|
|
148
|
+
}, [run, pendingToken, notify, t, onExpired]);
|
|
99
149
|
|
|
100
150
|
const finishBackup = useCallback(async () => {
|
|
101
151
|
await refreshProfile();
|
|
@@ -111,6 +161,9 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
111
161
|
loading,
|
|
112
162
|
error,
|
|
113
163
|
setError,
|
|
164
|
+
notice,
|
|
165
|
+
email,
|
|
166
|
+
expiresInSeconds,
|
|
114
167
|
submitCredentials,
|
|
115
168
|
submitCode,
|
|
116
169
|
requestEmailCode,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Clock } from "lucide-react";
|
|
3
4
|
import type { JSX } from "react";
|
|
4
5
|
import { useEffect } from "react";
|
|
5
6
|
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
LanguageSelector,
|
|
10
11
|
ThemeToggle,
|
|
11
12
|
} from "#core/components";
|
|
12
|
-
import { Card, Separator } from "#core/components/ui";
|
|
13
|
+
import { Alert, Card, Separator } from "#core/components/ui";
|
|
13
14
|
import { useBrand, useI18n } from "#core/contexts";
|
|
14
15
|
import {
|
|
15
16
|
BackupCodesDialog,
|
|
@@ -46,20 +47,32 @@ export function LoginScreen(): JSX.Element {
|
|
|
46
47
|
marca ao lado, só o título — repeti-la aqui seria dizer duas vezes. */}
|
|
47
48
|
<BrandHeader title={titles[flow.step]} showBrand={!aoLado} />
|
|
48
49
|
|
|
50
|
+
{/* Explica por que a tela voltou ao início; não é falha de credencial. */}
|
|
51
|
+
{flow.notice && (
|
|
52
|
+
<Alert variant="warning" icon={<Clock />} className="mb-4">
|
|
53
|
+
{flow.notice}
|
|
54
|
+
</Alert>
|
|
55
|
+
)}
|
|
56
|
+
|
|
49
57
|
{flow.error && (
|
|
50
|
-
<
|
|
58
|
+
<Alert variant="error" className="mb-4">
|
|
51
59
|
{flow.error}
|
|
52
|
-
</
|
|
60
|
+
</Alert>
|
|
53
61
|
)}
|
|
54
62
|
|
|
55
63
|
{flow.step === "credentials" && (
|
|
56
|
-
<LoginForm
|
|
64
|
+
<LoginForm
|
|
65
|
+
loading={flow.loading}
|
|
66
|
+
onSubmit={flow.submitCredentials}
|
|
67
|
+
defaultEmail={flow.email}
|
|
68
|
+
/>
|
|
57
69
|
)}
|
|
58
70
|
|
|
59
71
|
{flow.step === "setup" && flow.setup && (
|
|
60
72
|
<TwoFactorSetup
|
|
61
73
|
setup={flow.setup}
|
|
62
74
|
loading={flow.loading}
|
|
75
|
+
expiresInSeconds={flow.expiresInSeconds}
|
|
63
76
|
onConfirm={(code) => flow.submitCode(code, "totp")}
|
|
64
77
|
/>
|
|
65
78
|
)}
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
|
-
import { useForm } from "react-hook-form";
|
|
5
|
+
import { useForm, useWatch } from "react-hook-form";
|
|
6
6
|
import * as yup from "yup";
|
|
7
7
|
|
|
8
8
|
import { authService } from "#core/_services/auth";
|
|
9
9
|
import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
|
|
10
10
|
import { PasswordField } from "#core/components";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
Alert,
|
|
13
|
+
Button,
|
|
14
|
+
Card,
|
|
15
|
+
Field,
|
|
16
|
+
} from "#core/components/ui";
|
|
12
17
|
import { useAuth, useI18n } from "#core/contexts";
|
|
13
18
|
import { useRequest } from "#core/hooks/use-request";
|
|
14
19
|
|
|
@@ -36,8 +41,13 @@ export function ForcePasswordChange({
|
|
|
36
41
|
const {
|
|
37
42
|
register,
|
|
38
43
|
handleSubmit,
|
|
44
|
+
control,
|
|
39
45
|
formState: { errors },
|
|
40
46
|
} = useForm({ resolver: yupResolver(schema) });
|
|
47
|
+
const [newPassword = "", confirm = ""] = useWatch({
|
|
48
|
+
control,
|
|
49
|
+
name: ["newPassword", "confirm"],
|
|
50
|
+
});
|
|
41
51
|
|
|
42
52
|
const onSubmit = handleSubmit(async (data) => {
|
|
43
53
|
const res = await run(
|
|
@@ -81,6 +91,7 @@ export function ForcePasswordChange({
|
|
|
81
91
|
>
|
|
82
92
|
<PasswordField
|
|
83
93
|
error={!!errors.newPassword}
|
|
94
|
+
rulesFor={newPassword}
|
|
84
95
|
{...register("newPassword")}
|
|
85
96
|
/>
|
|
86
97
|
</Field>
|
|
@@ -88,7 +99,11 @@ export function ForcePasswordChange({
|
|
|
88
99
|
label={t("profile.confirmPassword")}
|
|
89
100
|
error={errors.confirm?.message}
|
|
90
101
|
>
|
|
91
|
-
<PasswordField
|
|
102
|
+
<PasswordField
|
|
103
|
+
error={!!errors.confirm}
|
|
104
|
+
rulesFor={confirm}
|
|
105
|
+
{...register("confirm")}
|
|
106
|
+
/>
|
|
92
107
|
</Field>
|
|
93
108
|
<Button type="submit" size="lg" disabled={loading}>
|
|
94
109
|
{loading ? t("common.loading") : t("profile.changePassword")}
|