mirror of
https://github.com/kharonsec/br-acc
synced 2026-04-25 17:15:02 +02:00
chore(pr-review): squash merge #40
Merged by strict manual review cycle 20260303T215220Z.
This commit is contained in:
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
63
frontend/src/lib/validations/auth.ts
Normal file
63
frontend/src/lib/validations/auth.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user