Compare commits
No commits in common. "09904cffc139a8308e0346339652a518fde8915d" and "0c4e8d089c18d992c53f6644679721595b1b6dde" have entirely different histories.
09904cffc1
...
0c4e8d089c
9 changed files with 7 additions and 314 deletions
|
|
@ -1,105 +0,0 @@
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// 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,11 +61,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit button: full-width, visually separated from the fields above it.
|
// Submit button: full-width, visually separated from the fields above it.
|
||||||
// Scoped to `[type="submit"]` specifically — the card also contains the
|
button {
|
||||||
// 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,10 +27,6 @@
|
||||||
"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,7 +3,6 @@ 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.
|
||||||
|
|
@ -79,10 +78,11 @@ 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>
|
||||||
<PasswordInput
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
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,7 +3,6 @@ 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.
|
||||||
|
|
@ -99,10 +98,11 @@ 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>
|
||||||
<PasswordInput
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
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,14 +762,6 @@ 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