feat(auth): bouton afficher / masquer le mot de passe (#134) #141

Merged
kyuno merged 3 commits from feat/login-password-toggle into main 2026-09-09 20:44:33 +02:00
9 changed files with 314 additions and 7 deletions
Showing only changes of commit 24bd2239fc - Show all commits

View file

@ -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 <I18nextProvider> 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 (
<PasswordInput
id="password"
value={value}
onChange={(next) => {
setValue(next);
onSpy?.(next);
}}
autoComplete="current-password"
/>
);
}
describe("PasswordInput", () => {
it("renders a masked password field by default", () => {
cy.mount(<Wrapper />);
cy.get("input#password").should("have.attr", "type", "password");
});
it("forwards id and autoComplete to the native input", () => {
cy.mount(<Wrapper />);
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(<Wrapper />);
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(<Wrapper />);
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(<Wrapper onSpy={onSpy} />);
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(
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<Wrapper />
<button type="submit">Go</button>
</form>,
);
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(<Wrapper />);
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(<Wrapper />);
cy.get("input#password").should("have.attr", "type", "password");
});
});

View file

@ -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");
});
});

View file

@ -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 `<input>` 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 `<input>` so an external `<label htmlFor>` keeps targeting it. */
id: string;
/** Controlled value — this component holds no text state of its own. */
value: string;
/** Called with the new text on each change (event unwrapped, like {@link CheckboxOption}). */
onChange: (value: string) => void;
/**
* Password-manager hint, forwarded verbatim — callers pass
* `"current-password"` on a login form, `"new-password"` on signup.
*/
autoComplete?: string;
}) {
const { t } = useTranslation();
// Always hidden on mount — see the component doc comment on why this is
// never persisted.
const [visible, setVisible] = useState(false);
// The label always names the action the *next* click performs; the
// pressed state is carried separately by `aria-pressed` below.
const toggleLabel = visible ? t("common.passwordInput.hide") : t("common.passwordInput.show");
return (
<span className="password-input">
<input
id={id}
type={visible ? "text" : "password"}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
autoComplete={autoComplete}
/>
<button
type="button"
className="password-input__toggle"
// `type="button"` (not the implicit `"submit"`) so toggling
// visibility never submits the form the field sits in.
aria-pressed={visible}
aria-label={toggleLabel}
title={toggleLabel}
onClick={() => setVisible((v) => !v)}
>
{visible ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
</button>
</span>
);
}

View file

@ -0,0 +1,58 @@
// =============================================================================
// PasswordInput (components/ui/PasswordInput.tsx) — positions the show/hide
// "eye" toggle inside the password field and keeps typed text clear of it.
//
// The field's own look (border, padding, colours) is inherited from the
// surrounding context — e.g. `.auth-card input` in features/auth/auth-form.scss
// — exactly as CheckboxOption leans on global.scss. This sheet owns only the
// wrapper, the toggle button, and the space the icon needs.
// =============================================================================
// No `@use` of the theme partial: every token below (--color-*, --space-*) is a
// CSS custom property declared on :root in styles/global.scss, available at
// runtime without a compile-time import.
.password-input {
// Positioning context for the absolutely-placed toggle. `display: flex` lets
// the inner <input> fill the row on its own (it replaces a bare <input> that
// used to be a direct flex child of the form column).
position: relative;
display: flex;
input {
flex: 1;
min-width: 0;
// Keep the caret and text from sliding under the icon: field padding on the
// left + the ~20px glyph + matching breathing room on the right.
padding-right: calc(var(--space-sm) + 20px + var(--space-sm));
}
}
// The eye button: sits flush inside the field's right edge, full height so its
// hit area matches the input, no surface of its own so it reads as part of the
// field rather than a second control.
.password-input__toggle {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-sm);
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
&:hover {
color: var(--color-text);
}
// lucide-react renders a 24×24 <svg> by default — bring it closer to the
// field's text size.
svg {
width: 20px;
height: 20px;
}
}

View file

@ -61,7 +61,11 @@
} }
// Submit button: full-width, visually separated from the fields above it. // Submit button: full-width, visually separated from the fields above it.
button { // Scoped to `[type="submit"]` specifically — the card also contains the
// PasswordInput's `type="button"` eye toggle, which must NOT inherit this
// full-width primary-coloured treatment (it styles itself in
// components/ui/password-input.scss).
button[type="submit"] {
margin-top: var(--space-md); margin-top: var(--space-md);
padding: 0.6rem; padding: 0.6rem;
font-family: var(--font-body); font-family: var(--font-body);

View file

@ -27,6 +27,10 @@
"vendredi": "Vendredi", "vendredi": "Vendredi",
"samedi": "Samedi", "samedi": "Samedi",
"dimanche": "Dimanche" "dimanche": "Dimanche"
},
"passwordInput": {
"show": "Afficher le mot de passe",
"hide": "Masquer le mot de passe"
} }
}, },
"errors": { "errors": {

View file

@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { PasswordInput } from "../../components/ui/PasswordInput";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
// Shared with SignupPage — see the file for why it's colocated in // Shared with SignupPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page. // features/auth/ rather than duplicated per page.
@ -78,11 +79,10 @@ export function LoginPage() {
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>} {fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("auth.login.passwordLabel")}</label> <label htmlFor="password">{t("auth.login.passwordLabel")}</label>
<input <PasswordInput
id="password" id="password"
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={setPassword}
autoComplete="current-password" autoComplete="current-password"
/> />
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>} {fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}

View file

@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { PasswordInput } from "../../components/ui/PasswordInput";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
// Shared with LoginPage — see the file for why it's colocated in // Shared with LoginPage — see the file for why it's colocated in
// features/auth/ rather than duplicated per page. // features/auth/ rather than duplicated per page.
@ -98,11 +99,10 @@ export function SignupPage() {
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>} {fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label> <label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
<input <PasswordInput
id="password" id="password"
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={setPassword}
autoComplete="new-password" autoComplete="new-password"
/> />
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>} {fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}

View file

@ -762,6 +762,14 @@ Clic sur une ligne :
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul `:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques. d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
- **`PasswordInput.tsx`** (+ `password-input.scss`) — un `<input>` de mot de
passe avec un bouton œil (`lucide-react` `Eye`/`EyeOff`) qui bascule son
`type` entre `"password"` et `"text"`. Visibilité en état local, toujours
masquée au montage, jamais persistée. Bouton `type="button"` +
`aria-label` traduit (`common.passwordInput.*`) + `aria-pressed`. Le style
du champ lui-même est hérité du contexte (comme `CheckboxOption` via
`global.scss`) ; la feuille colocalisée ne fait que positionner le bouton.
Utilisé par `LoginPage` et `SignupPage`.
--- ---