From 24bd2239fcaaa0e7e8201aeaf9254f10f00794a5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 19:33:29 +0200 Subject: [PATCH 1/3] feat(auth): bouton afficher / masquer le mot de passe (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau composant `components/ui/PasswordInput` : un `` de mot de passe avec un bouton œil (`lucide-react` Eye/EyeOff) qui bascule le `type` entre "password" et "text". Visibilité en état local, toujours masquée au montage, jamais persistée. Bouton `type="button"` (ne soumet pas le formulaire) + `aria-label` traduit (`common.passwordInput.*`) + `aria-pressed`. Branché dans `LoginPage` et `SignupPage` à la place du `` nu. La règle `.auth-card button` de `auth-form.scss` est resserrée sur `button[type="submit"]` pour que le bouton œil n'hérite pas du traitement plein-largeur du bouton de connexion. Tests : `cypress/component/PasswordInput.cy.tsx` (7 cas, composant isolé) + `cypress/e2e/auth-form.cy.ts` (mode layout, l'œil sur /login et /signup). Closes #134 Co-Authored-By: Claude Sonnet 5 --- .../cypress/component/PasswordInput.cy.tsx | 105 ++++++++++++++++++ apps/web/cypress/e2e/auth-form.cy.ts | 50 +++++++++ apps/web/src/components/ui/PasswordInput.tsx | 78 +++++++++++++ .../web/src/components/ui/password-input.scss | 58 ++++++++++ apps/web/src/features/auth/auth-form.scss | 6 +- apps/web/src/locales/fr/translation.json | 4 + apps/web/src/pages/auth/LoginPage.tsx | 6 +- apps/web/src/pages/auth/SignupPage.tsx | 6 +- specs/frontend-architecture.md | 8 ++ 9 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 apps/web/cypress/component/PasswordInput.cy.tsx create mode 100644 apps/web/cypress/e2e/auth-form.cy.ts create mode 100644 apps/web/src/components/ui/PasswordInput.tsx create mode 100644 apps/web/src/components/ui/password-input.scss diff --git a/apps/web/cypress/component/PasswordInput.cy.tsx b/apps/web/cypress/component/PasswordInput.cy.tsx new file mode 100644 index 0000000..2111357 --- /dev/null +++ b/apps/web/cypress/component/PasswordInput.cy.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import "../../src/i18n/i18n"; +import { PasswordInput } from "../../src/components/ui/PasswordInput"; + +// Mounts PasswordInput in isolation (no form, no router, no backend) — the +// same "generic component test" posture as CheckboxOption.cy.tsx. Needs +// `../../src/i18n/i18n` imported for its side effect: unlike Checkbox/Radio, +// this component calls `useTranslation()` for the eye button's aria-label, +// and with no in the tree it falls back to that default +// instance (see TechStepCorrectionPopover.cy.tsx for the same note). + +const SHOW_LABEL = "Afficher le mot de passe"; +const HIDE_LABEL = "Masquer le mot de passe"; + +/** + * Controlled wrapper — PasswordInput holds no text state of its own, so a + * real caller (LoginPage, SignupPage) always owns the value. `onSpy` lets a + * test assert on what each change reports. + */ +function Wrapper({ onSpy }: { onSpy?: (value: string) => void }) { + const [value, setValue] = useState(""); + return ( + { + setValue(next); + onSpy?.(next); + }} + autoComplete="current-password" + /> + ); +} + +describe("PasswordInput", () => { + it("renders a masked password field by default", () => { + cy.mount(); + cy.get("input#password").should("have.attr", "type", "password"); + }); + + it("forwards id and autoComplete to the native input", () => { + cy.mount(); + cy.get("input#password").should("have.attr", "autocomplete", "current-password"); + }); + + it("reveals the text when the eye is clicked, then re-masks it on a second click", () => { + cy.mount(); + cy.get("input#password").type("hunter2").should("have.attr", "type", "password"); + + cy.get("button.password-input__toggle").click(); + cy.get("input#password").should("have.attr", "type", "text").and("have.value", "hunter2"); + + cy.get("button.password-input__toggle").click(); + cy.get("input#password").should("have.attr", "type", "password").and("have.value", "hunter2"); + }); + + it("labels the toggle with the action the next click performs, and exposes state via aria-pressed", () => { + cy.mount(); + cy.get("button.password-input__toggle") + .should("have.attr", "aria-label", SHOW_LABEL) + .and("have.attr", "aria-pressed", "false"); + + cy.get("button.password-input__toggle").click(); + cy.get("button.password-input__toggle") + .should("have.attr", "aria-label", HIDE_LABEL) + .and("have.attr", "aria-pressed", "true"); + }); + + it("is a controlled input — it shows the parent's value and reports every change", () => { + const onSpy = cy.stub().as("onSpy"); + cy.mount(); + + cy.get("input#password").type("abc"); + cy.get("input#password").should("have.value", "abc"); + cy.get("@onSpy").should("have.been.calledWith", "abc"); + }); + + it("does not submit a surrounding form when the eye is clicked — the toggle is a plain button", () => { + const onSubmit = cy.stub().as("onSubmit"); + cy.mount( +
{ + e.preventDefault(); + onSubmit(); + }} + > + + + , + ); + + cy.get("button.password-input__toggle").click(); + cy.get("@onSubmit").should("not.have.been.called"); + }); + + it("starts hidden again on a fresh mount — visibility is never persisted", () => { + cy.mount(); + cy.get("button.password-input__toggle").click(); + cy.get("input#password").should("have.attr", "type", "text"); + + // A brand-new mount, as if the visitor navigated back to the page later. + cy.mount(); + cy.get("input#password").should("have.attr", "type", "password"); + }); +}); diff --git a/apps/web/cypress/e2e/auth-form.cy.ts b/apps/web/cypress/e2e/auth-form.cy.ts new file mode 100644 index 0000000..f0e19b1 --- /dev/null +++ b/apps/web/cypress/e2e/auth-form.cy.ts @@ -0,0 +1,50 @@ +// Mocks the API via cy.intercept — this job runs no live backend (see +// .github/workflows/ci.yml). The signup/login *journeys* live in +// auth.feature; this plain layout spec covers only the show/hide password +// toggle (PasswordInput) wired into both auth pages — "a section of an +// existing page" in specs/dev-conventions.md's test taxonomy, so a plain +// `.cy.ts`, not a Gherkin scenario. + +describe("Auth forms — password show/hide toggle", () => { + beforeEach(() => { + // Both pages sit behind RedirectIfAuthenticated; a 401 on the session + // check keeps the visitor logged out so the form actually renders. + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + }); + + for (const { label, path } of [ + { label: "login", path: "/login" }, + { label: "signup", path: "/signup" }, + ]) { + it(`reveals then re-masks the password field on ${label}`, () => { + cy.visit(path); + + // Starts masked, with the toggle offering to reveal it. + cy.get("#password").should("have.attr", "type", "password"); + cy.get('[aria-label="Afficher le mot de passe"]') + .should("be.visible") + .and("have.attr", "aria-pressed", "false"); + + // Reveal. + cy.get('[aria-label="Afficher le mot de passe"]').click(); + cy.get("#password").should("have.attr", "type", "text"); + cy.get('[aria-label="Masquer le mot de passe"]').should("have.attr", "aria-pressed", "true"); + + // Re-mask. + cy.get('[aria-label="Masquer le mot de passe"]').click(); + cy.get("#password").should("have.attr", "type", "password"); + }); + } + + it("does not submit the login form when the eye is clicked", () => { + cy.intercept("POST", "**/auth/login", cy.spy().as("loginRequest")); + cy.visit("/login"); + + cy.get("#email").type("alice@example.com"); + cy.get("#password").type("correct-horse-battery-staple"); + cy.get('[aria-label="Afficher le mot de passe"]').click(); + + cy.get("#password").should("have.attr", "type", "text"); + cy.get("@loginRequest").should("not.have.been.called"); + }); +}); diff --git a/apps/web/src/components/ui/PasswordInput.tsx b/apps/web/src/components/ui/PasswordInput.tsx new file mode 100644 index 0000000..b69491a --- /dev/null +++ b/apps/web/src/components/ui/PasswordInput.tsx @@ -0,0 +1,78 @@ +import { Eye, EyeOff } from "lucide-react"; +import { type ChangeEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import "./password-input.scss"; + +/** + * A password `` paired with a show/hide "eye" toggle — the button + * swaps the field's `type` between `"password"` and `"text"` so a visitor + * can re-read what they typed before submitting. A bare password field + * gives no feedback at all, and a mistyped-but-invisible password is one + * of the most common causes of a failed login; this makes it checkable + * without a password manager. + * + * Lives in `components/ui/` rather than `features/auth/` because nothing + * about it is auth-specific — it is just a password field. Used today by + * {@link LoginPage} and {@link SignupPage}. + * + * Visibility is local state that always starts hidden on mount and is + * never persisted: revealing a password is a deliberate, momentary action, + * not a preference worth remembering across page loads or sessions. + * + * The field's own look (border, padding, background) is inherited from the + * surrounding context — the same arrangement as {@link CheckboxOption}, + * which is styled through `global.scss` rather than carrying its own CSS. + * The colocated `password-input.scss` only positions the toggle inside the + * field and reserves horizontal room for it. + */ +export function PasswordInput({ + id, + value, + onChange, + autoComplete, +}: { + /** Forwarded to the `` so an external `