Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ test.describe("Users page", () => {

await page.getByRole("button", { name: "Create User" }).click();

await expect(page.getByText("Password must be at least 8 characters")).toBeVisible();
await expect(page.getByText("Password must be at least 12 characters")).toBeVisible();
});

test("passwords must match", async ({ page }) => {
Expand Down Expand Up @@ -330,7 +330,7 @@ test.describe("Users page", () => {
await page.getByRole("button", { name: "Save Changes" }).click();

await expect.poll(() => patchCalled).toBe(true);
await expect(page.getByText("Password must be at least 8 characters")).not.toBeVisible();
await expect(page.getByText("Password must be at least 12 characters")).not.toBeVisible();
});

test("passwords must match if password is filled", async ({ page }) => {
Expand Down
9 changes: 7 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,13 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
// ---------------------------------------------------------------------------

export const api = {
get<T>(path: string, headers?: Record<string, string>, signal?: AbortSignal): Promise<T> {
return request<T>(path, { method: "GET", headers, signal });
get<T>(
path: string,
headers?: Record<string, string>,
signal?: AbortSignal,
opts?: Pick<RequestOptions, "authenticated">,
): Promise<T> {
return request<T>(path, { method: "GET", headers, signal, ...opts });
},

post<T>(
Expand Down
43 changes: 43 additions & 0 deletions src/api/passwordReset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "./client";
import { requestPasswordReset, resetPassword, validatePasswordResetToken } from "./passwordReset";

vi.mock("./client", () => ({
api: { get: vi.fn(), post: vi.fn() },
}));

describe("passwordReset API", () => {
beforeEach(() => vi.clearAllMocks());

it("uses public forgot-password endpoint", async () => {
vi.mocked(api.post).mockResolvedValue({ success: true, message: "ok" });
await requestPasswordReset("person@example.com");
expect(api.post).toHaveBeenCalledWith(
"/auth/email/forgot-password",
{ email: "person@example.com" },
{ authenticated: false },
);
});

it("URL-encodes token and validates without authentication", async () => {
vi.mocked(api.get).mockResolvedValue({ valid: true, message: "ok", expires_at: null });
const controller = new AbortController();
await validatePasswordResetToken("token/with space", controller.signal);
expect(api.get).toHaveBeenCalledWith(
"/auth/email/reset-password/token%2Fwith%20space",
undefined,
controller.signal,
{ authenticated: false },
);
});

it("submits matching password fields to public reset endpoint", async () => {
vi.mocked(api.post).mockResolvedValue({ success: true, message: "ok" });
await resetPassword("token/one", "new-password", "new-password");
expect(api.post).toHaveBeenCalledWith(
"/auth/email/reset-password/token%2Fone",
{ new_password: "new-password", confirm_password: "new-password" },
{ authenticated: false },
);
});
});
43 changes: 43 additions & 0 deletions src/api/passwordReset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { api } from "./client";

interface SuccessResponse {
success: boolean;
message: string;
}

export interface PasswordResetTokenValidationResponse {
valid: boolean;
message: string;
expires_at: string | null;
}

const resetPath = (token: string) => `/auth/email/reset-password/${encodeURIComponent(token)}`;

export function requestPasswordReset(email: string): Promise<SuccessResponse> {
return api.post<SuccessResponse>(
"/auth/email/forgot-password",
{ email },
{ authenticated: false },
);
}

export function validatePasswordResetToken(
token: string,
signal?: AbortSignal,
): Promise<PasswordResetTokenValidationResponse> {
return api.get<PasswordResetTokenValidationResponse>(resetPath(token), undefined, signal, {
authenticated: false,
});
}

export function resetPassword(
token: string,
newPassword: string,
confirmPassword: string,
): Promise<SuccessResponse> {
return api.post<SuccessResponse>(
resetPath(token),
{ new_password: newPassword, confirm_password: confirmPassword },
{ authenticated: false },
);
}
31 changes: 31 additions & 0 deletions src/api/passwordResetErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "./client";
import { classifyPasswordResetError } from "./passwordResetErrors";

describe("classifyPasswordResetError", () => {
it.each([
[403, "disabled"],
[410, "expired"],
[429, "rateLimited"],
[500, "failed"],
])("maps HTTP %s to %s", (status, kind) => {
expect(classifyPasswordResetError(new ApiError(status, null, `HTTP ${status}`))).toEqual({
kind,
});
});

it("preserves 400 detail without guessing its meaning from wording", () => {
const detail = "El enlace ya no es válido";
expect(classifyPasswordResetError(new ApiError(400, { detail }, "HTTP 400"))).toEqual({
kind: "badRequest",
message: detail,
});
});

it("handles a 400 without detail", () => {
expect(classifyPasswordResetError(new ApiError(400, null, "HTTP 400"))).toEqual({
kind: "badRequest",
message: null,
});
});
});
24 changes: 24 additions & 0 deletions src/api/passwordResetErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ApiError } from "./client";
import { extractApiErrorDetail } from "@/utils/errors";

export type PasswordResetError =
| { kind: "disabled" }
| { kind: "expired" }
| { kind: "badRequest"; message: string | null }
| { kind: "rateLimited" }
| { kind: "failed" };

/** Classify password-reset API failures in one place for both public auth screens. */
export function classifyPasswordResetError(error: unknown): PasswordResetError {
if (!(error instanceof ApiError)) return { kind: "failed" };

if (error.status === 403) return { kind: "disabled" };
if (error.status === 410) return { kind: "expired" };
if (error.status === 429) return { kind: "rateLimited" };

if (error.status === 400) {
return { kind: "badRequest", message: extractApiErrorDetail(error.body) };
}

return { kind: "failed" };
}
19 changes: 18 additions & 1 deletion src/components/ui/combobox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, within, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Combobox, type ComboboxOption } from "./combobox";

Expand Down Expand Up @@ -132,6 +132,23 @@ describe("Combobox", () => {
await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument());
});

it("cancels pending blur work when unmounted", () => {
vi.useFakeTimers();
try {
const { unmount } = render(<Combobox options={OPTIONS} />);
const input = screen.getByRole("combobox");

fireEvent.focus(input);
fireEvent.blur(input);
expect(vi.getTimerCount()).toBe(1);

unmount();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});

it("closes on Escape without selecting", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
Expand Down
15 changes: 14 additions & 1 deletion src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,19 @@ export function Combobox({
const [activeIndex, setActiveIndex] = React.useState(-1);
const containerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const listboxId = React.useId();
const optionIdPrefix = React.useId();

const clearBlurTimeout = React.useCallback(() => {
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
}, []);

React.useEffect(() => clearBlurTimeout, [clearBlurTimeout]);

const selectedOption = options.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label || value || "";

Expand Down Expand Up @@ -87,6 +97,7 @@ export function Combobox({
}, [activeIndex, open]);

const handleOpen = () => {
clearBlurTimeout();
if (!disabled) {
setOpen(true);
setSearchValue("");
Expand Down Expand Up @@ -151,7 +162,9 @@ export function Combobox({
};

const handleBlur = () => {
setTimeout(() => {
clearBlurTimeout();
blurTimeoutRef.current = setTimeout(() => {
blurTimeoutRef.current = null;
if (!containerRef.current?.contains(document.activeElement)) {
setOpen(false);
setSearchValue("");
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useUserForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vi.mock("@/hooks/useQuery", () => ({

const messages = {
"users.form.error.emailInvalid": "Invalid email address",
"users.form.error.passwordMinLength": "Password must be at least 8 characters",
"users.form.error.passwordMinLength": "Password must be at least 12 characters",
"users.form.error.passwordsDoNotMatch": "Passwords do not match",
"users.form.error.createFailed": "Failed to create user",
"users.form.error.updateFailed": "Failed to update user",
Expand Down Expand Up @@ -293,7 +293,7 @@ describe("useUserForm", () => {
vi.advanceTimersByTime(300);
});

expect(result.current.errors.password).toBe("Password must be at least 8 characters");
expect(result.current.errors.password).toBe("Password must be at least 12 characters");

act(() => {
result.current.validateField("password", "LongEnoughPassword123!");
Expand Down Expand Up @@ -419,7 +419,7 @@ describe("useUserForm", () => {
});

expect(isValid).toBe(false);
expect(result.current.errors.password).toBe("Password must be at least 8 characters");
expect(result.current.errors.password).toBe("Password must be at least 12 characters");
});

it("should return false for mismatched passwords", () => {
Expand Down
21 changes: 21 additions & 0 deletions src/i18n/locales/en-US/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,32 @@
"auth.forgotPassword.description": "Enter your email to receive reset instructions",
"auth.forgotPassword.email": "Email Address",
"auth.forgotPassword.submit": "Send Reset Link",
"auth.forgotPassword.submitting": "Sending reset link…",
"auth.forgotPassword.backToLogin": "Back to Sign In",
"auth.forgotPassword.success": "If this email is registered, you will receive a reset link.",
"auth.forgotPassword.error.rateLimited": "Too many requests. Please try again later.",
"auth.forgotPassword.error.disabled": "Password reset is currently unavailable.",
"auth.forgotPassword.error.failed": "We could not send a reset link. Please try again.",
"auth.forgotPassword.error.invalidEmail": "Enter a valid email address.",
"auth.resetPassword.title": "Create New Password",
"auth.resetPassword.description": "Enter and confirm your new password.",
"auth.resetPassword.password": "New Password",
"auth.resetPassword.confirmPassword": "Confirm Password",
"auth.resetPassword.submit": "Reset Password",
"auth.resetPassword.submitting": "Resetting password…",
"auth.resetPassword.passwordHint": "Use at least 12 characters and 3 of: uppercase, lowercase, number, or special character. Privileged accounts may require more.",
"auth.resetPassword.successTitle": "Password changed",
"auth.resetPassword.success": "Your password was changed for ContextForge.",
"auth.resetPassword.returnToLogin": "Return to login",
"auth.resetPassword.requestNewLink": "Request New Link",
"auth.resetPassword.error.tooShort": "Password must be at least 12 characters.",
"auth.resetPassword.error.complexity": "Password must contain at least 3 of: uppercase, lowercase, number, or special character.",
"auth.resetPassword.error.mismatch": "Passwords do not match.",
"auth.resetPassword.error.invalid": "This reset link is invalid or has already been used.",
"auth.resetPassword.error.expired": "This reset link has expired.",
"auth.resetPassword.error.disabled": "Password reset is currently unavailable.",
"auth.resetPassword.error.rateLimited": "Too many requests. Please try again later.",
"auth.resetPassword.error.failed": "We could not reset your password. Please try again.",
"auth.changePassword.title": "Change Password",
"auth.changePassword.currentPassword": "Current Password",
"auth.changePassword.newPassword": "New Password",
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/en-US/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
"users.form.error.createFailed": "Failed to create user. Please try again.",
"users.form.error.emailInvalid": "Invalid email address",
"users.form.error.emailRequired": "Email is required",
"users.form.error.passwordMinLength": "Password must be at least 8 characters",
"users.form.error.passwordMinLength": "Password must be at least 12 characters",
"users.form.error.passwordRequired": "Password is required",
"users.form.error.passwordsDoNotMatch": "Passwords do not match",
"users.form.fullName.placeholder": "John Doe",
"users.form.fullName": "Full Name",
"users.form.isActive": "Account is active",
"users.form.isAdmin": "Grant admin privileges",
"users.form.password.placeholder": "Enter password (min 8 characters)",
"users.form.password.placeholder": "Enter password (min 12 characters)",
"users.form.password": "Password",
"users.form.passwordChangeRequired": "Require password change on first login",
"users.form.title": "Create User",
Expand Down
21 changes: 21 additions & 0 deletions src/i18n/locales/es-ES/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,32 @@
"auth.forgotPassword.description": "Ingresa tu correo electrónico para recibir instrucciones de restablecimiento",
"auth.forgotPassword.email": "Dirección de Correo Electrónico",
"auth.forgotPassword.submit": "Enviar Enlace de Restablecimiento",
"auth.forgotPassword.submitting": "Enviando enlace de restablecimiento…",
"auth.forgotPassword.backToLogin": "Volver a Iniciar Sesión",
"auth.forgotPassword.success": "Si este correo está registrado, recibirás un enlace de restablecimiento.",
"auth.forgotPassword.error.rateLimited": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
"auth.forgotPassword.error.disabled": "El restablecimiento de contraseña no está disponible actualmente.",
"auth.forgotPassword.error.failed": "No pudimos enviar el enlace de restablecimiento. Inténtalo de nuevo.",
"auth.forgotPassword.error.invalidEmail": "Ingresa una dirección de correo electrónico válida.",
"auth.resetPassword.title": "Crear Nueva Contraseña",
"auth.resetPassword.description": "Ingresa y confirma tu nueva contraseña.",
"auth.resetPassword.password": "Nueva Contraseña",
"auth.resetPassword.confirmPassword": "Confirmar Contraseña",
"auth.resetPassword.submit": "Restablecer Contraseña",
"auth.resetPassword.submitting": "Restableciendo contraseña…",
"auth.resetPassword.passwordHint": "Usa al menos 12 caracteres y 3 de estos tipos: mayúscula, minúscula, número o carácter especial. Las cuentas privilegiadas pueden requerir más.",
"auth.resetPassword.successTitle": "Contraseña cambiada",
"auth.resetPassword.success": "Tu contraseña de ContextForge fue cambiada.",
"auth.resetPassword.returnToLogin": "Volver al inicio de sesión",
"auth.resetPassword.requestNewLink": "Solicitar Nuevo Enlace",
"auth.resetPassword.error.tooShort": "La contraseña debe tener al menos 12 caracteres.",
"auth.resetPassword.error.complexity": "La contraseña debe contener al menos 3 de estos tipos: mayúscula, minúscula, número o carácter especial.",
"auth.resetPassword.error.mismatch": "Las contraseñas no coinciden.",
"auth.resetPassword.error.invalid": "Este enlace de restablecimiento no es válido o ya fue utilizado.",
"auth.resetPassword.error.expired": "Este enlace de restablecimiento ha caducado.",
"auth.resetPassword.error.disabled": "El restablecimiento de contraseña no está disponible actualmente.",
"auth.resetPassword.error.rateLimited": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
"auth.resetPassword.error.failed": "No pudimos restablecer tu contraseña. Inténtalo de nuevo.",
"auth.changePassword.title": "Cambiar Contraseña",
"auth.changePassword.currentPassword": "Contraseña Actual",
"auth.changePassword.newPassword": "Nueva Contraseña",
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/es-ES/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
"users.form.error.createFailed": "Error al crear usuario. Por favor, inténtelo de nuevo.",
"users.form.error.emailInvalid": "Correo electrónico inválido",
"users.form.error.emailRequired": "El correo electrónico es obligatorio",
"users.form.error.passwordMinLength": "La contraseña debe tener al menos 8 caracteres",
"users.form.error.passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
"users.form.error.passwordRequired": "La contraseña es obligatoria",
"users.form.error.passwordsDoNotMatch": "Las contraseñas no coinciden",
"users.form.fullName.placeholder": "Juan Pérez",
"users.form.fullName": "Nombre Completo",
"users.form.isActive": "La cuenta está activa",
"users.form.isAdmin": "Otorgar privilegios de administrador",
"users.form.password.placeholder": "Ingrese contraseña (mín. 8 caracteres)",
"users.form.password.placeholder": "Ingrese contraseña (mín. 12 caracteres)",
"users.form.password": "Contraseña",
"users.form.passwordChangeRequired": "Requerir cambio de contraseña en el primer inicio de sesión",
"users.form.title": "Crear Usuario",
Expand Down
Loading
Loading