chore(pr-review): squash merge #40

Merged by strict manual review cycle 20260303T215220Z.
This commit is contained in:
Davi Rezende
2026-03-03 19:02:44 -03:00
committed by GitHub
parent d91b5b009c
commit 52f71b19eb
10 changed files with 281 additions and 40 deletions

View File

@@ -10,15 +10,18 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@floating-ui/react": "^0.27.18",
"@hookform/resolvers": "^5.2.2",
"cmdk": "^1.1.1",
"i18next": "^24.0.0",
"lucide-react": "^0.575.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-2d": "^1.26.0",
"react-hook-form": "^7.71.2",
"react-i18next": "^15.0.0",
"react-resizable-panels": "^4.6.5",
"react-router": "^7.0.0",
"zod": "^4.3.6",
"zustand": "^5.0.0"
},
"devDependencies": {
@@ -1124,6 +1127,18 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2024,6 +2039,12 @@
"win32"
]
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4895,6 +4916,22 @@
"react": "*"
}
},
"node_modules/react-hook-form": {
"version": "7.71.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
@@ -5909,6 +5946,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",

View File

@@ -15,15 +15,18 @@
},
"dependencies": {
"@floating-ui/react": "^0.27.18",
"@hookform/resolvers": "^5.2.2",
"cmdk": "^1.1.1",
"i18next": "^24.0.0",
"lucide-react": "^0.575.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-2d": "^1.26.0",
"react-hook-form": "^7.71.2",
"react-i18next": "^15.0.0",
"react-resizable-panels": "^4.6.5",
"react-router": "^7.0.0",
"zod": "^4.3.6",
"zustand": "^5.0.0"
},
"devDependencies": {

View File

@@ -145,6 +145,13 @@ const resources = {
loginTitle: "Acessar BR-ACC",
registerTitle: "Criar conta",
loginSubtitle: "Plataforma de inteligência em dados públicos",
emailRequired: "E-mail é obrigatório.",
emailInvalid: "E-mail inválido.",
passwordRequired: "Senha é obrigatória.",
passwordMinLength: "Senha deve ter no mínimo 8 caracteres.",
confirmPasswordRequired: "Confirme a senha.",
confirmPasswordMismatch: "As senhas não coincidem.",
inviteCodeRequired: "Código de convite é obrigatório.",
},
search: {
placeholder: "CPF, CNPJ ou nome...",
@@ -581,6 +588,13 @@ const resources = {
loginTitle: "Access BR-ACC",
registerTitle: "Create account",
loginSubtitle: "Public data intelligence platform",
emailRequired: "Email is required.",
emailInvalid: "Invalid email.",
passwordRequired: "Password is required.",
passwordMinLength: "Password must be at least 8 characters.",
confirmPasswordRequired: "Please confirm your password.",
confirmPasswordMismatch: "Passwords do not match.",
inviteCodeRequired: "Invite code is required.",
},
search: {
placeholder: "CPF, CNPJ, or name...",

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
/** Message keys match i18n auth.* keys for validation errors */
const messages = {
emailRequired: "emailRequired",
emailInvalid: "emailInvalid",
passwordRequired: "passwordRequired",
passwordMinLength: "passwordMinLength",
confirmPasswordRequired: "confirmPasswordRequired",
confirmPasswordMismatch: "confirmPasswordMismatch",
inviteCodeRequired: "inviteCodeRequired",
} as const;
export const loginSchema = z.object({
email: z
.string()
.min(1, { message: messages.emailRequired })
.email({ message: messages.emailInvalid }),
password: z
.string()
.min(1, { message: messages.passwordRequired })
.min(8, { message: messages.passwordMinLength }),
});
export const registerSchema = z
.object({
email: z
.string()
.min(1, { message: messages.emailRequired })
.email({ message: messages.emailInvalid }),
password: z
.string()
.min(1, { message: messages.passwordRequired })
.min(8, { message: messages.passwordMinLength }),
confirmPassword: z
.string()
.min(1, { message: messages.confirmPasswordRequired }),
inviteCode: z
.string()
.min(1, { message: messages.inviteCodeRequired })
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: messages.inviteCodeRequired }),
})
.refine((data) => data.password === data.confirmPassword, {
message: messages.confirmPasswordMismatch,
path: ["confirmPassword"],
});
export type LoginFormValues = z.infer<typeof loginSchema>;
export type RegisterFormValues = z.infer<typeof registerSchema>;
type TFunction = (key: string) => string;
/** Maps Zod/rhf field error message (i18n key) to translated string for auth forms. */
export function getAuthErrorMessage(
message: string | undefined,
t: TFunction,
): string {
if (!message) return "";
const key = `auth.${message}`;
const out = t(key);
return out === key ? key : out;
}

View File

@@ -115,6 +115,12 @@
color: var(--accent);
}
.fieldError {
color: var(--color-danger);
font-size: var(--font-size-xs);
margin-top: var(--space-2xs);
}
.error {
color: var(--color-danger);
font-size: var(--font-size-xs);

View File

@@ -85,6 +85,18 @@ describe("Login", () => {
expect(mockLogin).toHaveBeenCalledWith("test@example.com", "password123");
});
it("shows validation errors when submitting empty form", async () => {
const user = userEvent.setup();
renderLogin();
await user.click(screen.getByRole("button", { name: /entrar/i }));
expect(mockLogin).not.toHaveBeenCalled();
expect(
screen.getByText(/e-mail é obrigatório/i),
).toBeInTheDocument();
});
it("shows error from store", () => {
mockStoreState.error = "auth.invalidCredentials";
renderLogin();

View File

@@ -1,7 +1,13 @@
import { type FormEvent, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router";
import {
loginSchema,
type LoginFormValues,
getAuthErrorMessage,
} from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
import styles from "./Login.module.css";
@@ -11,16 +17,20 @@ export function Login() {
const navigate = useNavigate();
const { login, loading, error } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
mode: "onTouched",
});
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
await login(email, password);
const onSubmit = form.handleSubmit(async (data) => {
await login(data.email, data.password);
if (useAuthStore.getState().token) {
navigate("/app");
}
};
});
const { errors } = form.formState;
return (
<div className={styles.page}>
@@ -30,7 +40,7 @@ export function Login() {
<p className={styles.subtitle}>{t("auth.loginSubtitle")}</p>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
<form className={styles.form} onSubmit={onSubmit}>
{error && <div className={styles.error}>{t(error)}</div>}
<div className={styles.field}>
@@ -41,11 +51,16 @@ export function Login() {
id="email"
className={styles.input}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...form.register("email")}
/>
{errors.email?.message && (
<span id="email-error" className={styles.fieldError} role="alert">
{getAuthErrorMessage(errors.email.message, t)}
</span>
)}
</div>
<div className={styles.field}>
@@ -56,18 +71,22 @@ export function Login() {
id="password"
className={styles.input}
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-invalid={Boolean(errors.password)}
aria-describedby={errors.password ? "password-error" : undefined}
{...form.register("password")}
/>
{errors.password?.message && (
<span id="password-error" className={styles.fieldError} role="alert">
{getAuthErrorMessage(errors.password.message, t)}
</span>
)}
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={loading}
disabled={form.formState.isSubmitting || loading}
>
{loading ? t("common.loading") : t("auth.login")}
</button>

View File

@@ -115,6 +115,12 @@
color: var(--accent);
}
.fieldError {
color: var(--color-danger);
font-size: var(--font-size-xs);
margin-top: var(--space-2xs);
}
.error {
color: var(--color-danger);
font-size: var(--font-size-xs);

View File

@@ -61,7 +61,10 @@ describe("Register", () => {
renderRegister();
expect(screen.getByLabelText(/e-mail/i)).toBeInTheDocument();
expect(screen.getByLabelText(/senha/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^senha$/i)).toBeInTheDocument();
expect(
screen.getByLabelText(/confirmar senha/i),
).toBeInTheDocument();
expect(screen.getByLabelText(/c\u00F3digo de convite/i)).toBeInTheDocument();
});
@@ -69,7 +72,7 @@ describe("Register", () => {
renderRegister();
const emailInput = screen.getByLabelText(/e-mail/i);
const passwordInput = screen.getByLabelText(/senha/i);
const passwordInput = screen.getByLabelText(/^senha$/i);
expect(emailInput).toHaveAttribute("type", "email");
expect(passwordInput).toHaveAttribute("type", "password");
@@ -83,11 +86,31 @@ describe("Register", () => {
expect(submitBtn).toBeInTheDocument();
await user.type(screen.getByLabelText(/e-mail/i), "test@example.com");
await user.type(screen.getByLabelText(/senha/i), "password123");
await user.type(screen.getByLabelText(/^senha$/i), "password123");
await user.type(
screen.getByLabelText(/confirmar senha/i),
"password123",
);
await user.type(screen.getByLabelText(/c\u00F3digo de convite/i), "INV-123");
await user.click(submitBtn);
expect(mockRegister).toHaveBeenCalledWith("test@example.com", "password123", "INV-123");
expect(mockRegister).toHaveBeenCalledWith(
"test@example.com",
"password123",
"INV-123",
);
});
it("shows validation errors when submitting empty form", async () => {
const user = userEvent.setup();
renderRegister();
await user.click(screen.getByRole("button", { name: /registrar/i }));
expect(mockRegister).not.toHaveBeenCalled();
expect(
screen.getByText(/e-mail é obrigatório/i),
).toBeInTheDocument();
});
it("shows error from store", () => {

View File

@@ -1,7 +1,13 @@
import { type FormEvent, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router";
import {
registerSchema,
type RegisterFormValues,
getAuthErrorMessage,
} from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
import styles from "./Register.module.css";
@@ -9,19 +15,27 @@ import styles from "./Register.module.css";
export function Register() {
const { t } = useTranslation();
const navigate = useNavigate();
const { register, loading, error } = useAuthStore();
const { register: registerUser, loading, error } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [inviteCode, setInviteCode] = useState("");
const form = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
inviteCode: "",
},
mode: "onTouched",
});
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
await register(email, password, inviteCode);
const onSubmit = form.handleSubmit(async (data) => {
await registerUser(data.email, data.password, data.inviteCode);
if (useAuthStore.getState().token) {
navigate("/app");
}
};
});
const { errors } = form.formState;
return (
<div className={styles.page}>
@@ -31,7 +45,7 @@ export function Register() {
<p className={styles.subtitle}>{t("auth.loginSubtitle")}</p>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
<form className={styles.form} onSubmit={onSubmit}>
{error && <div className={styles.error}>{t(error)}</div>}
<div className={styles.field}>
@@ -42,11 +56,16 @@ export function Register() {
id="reg-email"
className={styles.input}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "reg-email-error" : undefined}
{...form.register("email")}
/>
{errors.email?.message && (
<span id="reg-email-error" className={styles.fieldError} role="alert">
{getAuthErrorMessage(errors.email.message, t)}
</span>
)}
</div>
<div className={styles.field}>
@@ -57,12 +76,36 @@ export function Register() {
id="reg-password"
className={styles.input}
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
aria-invalid={Boolean(errors.password)}
aria-describedby={errors.password ? "reg-password-error" : undefined}
{...form.register("password")}
/>
{errors.password?.message && (
<span id="reg-password-error" className={styles.fieldError} role="alert">
{getAuthErrorMessage(errors.password.message, t)}
</span>
)}
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="reg-confirm-password">
{t("auth.confirmPassword")}
</label>
<input
id="reg-confirm-password"
className={styles.input}
type="password"
autoComplete="new-password"
aria-invalid={Boolean(errors.confirmPassword)}
aria-describedby={errors.confirmPassword ? "reg-confirm-password-error" : undefined}
{...form.register("confirmPassword")}
/>
{errors.confirmPassword?.message && (
<span id="reg-confirm-password-error" className={styles.fieldError} role="alert">
{getAuthErrorMessage(errors.confirmPassword.message, t)}
</span>
)}
</div>
<div className={styles.field}>
@@ -73,16 +116,22 @@ export function Register() {
id="reg-invite"
className={styles.input}
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
autoComplete="off"
aria-invalid={Boolean(errors.inviteCode)}
aria-describedby={errors.inviteCode ? "reg-invite-error" : undefined}
{...form.register("inviteCode")}
/>
{errors.inviteCode?.message && (
<span id="reg-invite-error" className={styles.fieldError} role="alert">
{getAuthErrorMessage(errors.inviteCode.message, t)}
</span>
)}
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={loading}
disabled={form.formState.isSubmitting || loading}
>
{loading ? t("common.loading") : t("auth.register")}
</button>