feat(auth): bouton afficher / masquer le mot de passe (#134) #141
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.
|
||||
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);
|
||||
padding: 0.6rem;
|
||||
font-family: var(--font-body);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
"vendredi": "Vendredi",
|
||||
"samedi": "Samedi",
|
||||
"dimanche": "Dimanche"
|
||||
},
|
||||
"passwordInput": {
|
||||
"show": "Afficher le mot de passe",
|
||||
"hide": "Masquer le mot de passe"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { PasswordInput } from "../../components/ui/PasswordInput";
|
||||
import { useAuth } from "../../features/auth/AuthContext";
|
||||
// Shared with SignupPage — see the file for why it's colocated in
|
||||
// features/auth/ rather than duplicated per page.
|
||||
|
|
@ -78,11 +79,10 @@ export function LoginPage() {
|
|||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||
|
||||
<label htmlFor="password">{t("auth.login.passwordLabel")}</label>
|
||||
<input
|
||||
<PasswordInput
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={setPassword}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{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 { Link, useNavigate } from "react-router-dom";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { PasswordInput } from "../../components/ui/PasswordInput";
|
||||
import { useAuth } from "../../features/auth/AuthContext";
|
||||
// Shared with LoginPage — see the file for why it's colocated in
|
||||
// features/auth/ rather than duplicated per page.
|
||||
|
|
@ -98,11 +99,10 @@ export function SignupPage() {
|
|||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
||||
|
||||
<label htmlFor="password">{t("auth.signup.passwordLabel")}</label>
|
||||
<input
|
||||
<PasswordInput
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={setPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{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
|
||||
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
|
||||
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