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
@@ -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
- "h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
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
- "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
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 = localStorage.getItem(STORAGE_KEY);
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
- localStorage.setItem(STORAGE_KEY, p);
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 = (localStorage.getItem(STORAGE_KEY) as Locale) || null;
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
- localStorage.setItem(STORAGE_KEY, l);
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
- <div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
47
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
48
- <span>{t("auth.backupWarning")}</span>
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({ loading, onSubmit }: Props): JSX.Element {
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>({ resolver: yupResolver(schema) });
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
- autoFocus
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({ setup, loading, onConfirm }: Props): JSX.Element {
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
- <Button type="submit" size="lg" disabled={loading}>
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
- <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
58
+ <Alert variant="error" className="mb-4">
51
59
  {flow.error}
52
- </div>
60
+ </Alert>
53
61
  )}
54
62
 
55
63
  {flow.step === "credentials" && (
56
- <LoginForm loading={flow.loading} onSubmit={flow.submitCredentials} />
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 { Alert, Button, Card, Field } from "#core/components/ui";
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 error={!!errors.confirm} {...register("confirm")} />
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")}