feat(auth): bouton afficher / masquer le mot de passe (#134)
Some checks failed
Some checks failed
Nouveau composant `components/ui/PasswordInput` : un `<input>` 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 `<input type="password">` 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 <noreply@anthropic.com>
This commit is contained in:
parent
0c4e8d089c
commit
24bd2239fc
9 changed files with 314 additions and 7 deletions
105
apps/web/cypress/component/PasswordInput.cy.tsx
Normal file
105
apps/web/cypress/component/PasswordInput.cy.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/web/cypress/e2e/auth-form.cy.ts
Normal file
50
apps/web/cypress/e2e/auth-form.cy.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/web/src/components/ui/PasswordInput.tsx
Normal file
78
apps/web/src/components/ui/PasswordInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/src/components/ui/password-input.scss
Normal file
58
apps/web/src/components/ui/password-input.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue