From 13da2d9e036d982075182f24a219d2d13e127176 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 13 Jul 2026 11:13:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20pr=C3=A9remplir=20l'identifiant=20?= =?UTF-8?q?=C3=A0=20la=20barri=C3=A8re=20=E2=80=94=20plus=20de=20re-saisie?= =?UTF-8?q?=20=C3=A0=20l'arriv=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptôme (vraie app) : au retour dans Festipod, la barrière redemandait un identifiant NU et VIDE alors qu'il était déjà choisi/stocké. Cause racine (pas une perte de localStorage — l'identifiant survit au round-trip) : au rechargement, AccountProvider restaure `username` depuis le store, mais NextGraphContext repart en `disconnected`, donc AuthGate réaffiche la barrière ; et AccessGateScreen initialisait son champ à useState('') → vide malgré le stocké. Fix : AuthGate passe `initialIdentifier={username}` ; AccessGateScreen préremplit le champ. L'identifiant est saisi UNE FOIS au premier accès, persisté, puis prérempli au retour — jamais retapé. Test garde-fou @ui (barriere-acces-identifiant.feature) : prérempli / vide au premier accès / Entrer remonte la valeur. Rouge si on remet useState(''). Utile car le flux de barrière est désactivé en @e2e (__FESTIPOD_ACCESS_GATE_DISABLED__), donc invisible à cette couche. renderElement() ajouté au harness @ui pour rendre un composant prop-driven hors registre/providers. Doctrine: app-security/knowledge_authentication documente la saisie-unique + prérempli. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app-security/knowledge_authentication.md | 1 + src/app/AuthGate.tsx | 13 ++- .../barriere-acces-identifiant.feature | 27 +++++ src/modules/auth/screens/AccessGateScreen.tsx | 12 ++- .../auth/steps/ui/barriere-acces.steps.ts | 101 ++++++++++++++++++ src/shared/test-harness/renderHelper.tsx | 31 ++++++ 6 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 src/modules/auth/features/barriere-acces-identifiant.feature create mode 100644 src/modules/auth/steps/ui/barriere-acces.steps.ts diff --git a/.project/concepts/app-security/knowledge_authentication.md b/.project/concepts/app-security/knowledge_authentication.md index 74f6c11..bb344bd 100644 --- a/.project/concepts/app-security/knowledge_authentication.md +++ b/.project/concepts/app-security/knowledge_authentication.md @@ -11,6 +11,7 @@ summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisat - La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans de [[decision_2026-06-15_shared-wallet-login-flow]]). - Cet **identifiant est un id technique** (un pseudo en pratique, **pas** un username Festipod) : il est **normalisé** (trim, `@` retiré, **minuscules**) puis persisté (`AccountContext` → `IdentityStore`), donc un rechargement — ou un autre appareil rouvrant le même wallet partagé — retombe sur le même espace. C'est cet id qui est donné au SDK (`setCurrentUser`) et sur lequel les caps et le compte shim sont clés. +- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement / retour du round-trip broker, l'identifiant est déjà stocké, mais la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche donc la barrière tant que `status !== 'connected'`. Le champ d'`AccessGateScreen` est alors **prérempli** avec la valeur stockée (prop `initialIdentifier`, passée par `AuthGate` depuis `useAccount().username`) — l'utilisateur ne le retape jamais et ne voit jamais un champ nu et vide à l'arrivée. Il n'est réellement saisi qu'au **premier accès** (aucune valeur stockée). Régression gardée par `src/modules/auth/features/barriere-acces-identifiant.feature` (@ui) — d'autant plus utile que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche. - Une fois la session ouverte, l'utilisateur courant et son accès aux stores par scope sont fournis par `NextGraphContext`. ## Le wallet de test diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx index 8b6a245..a3d1e24 100644 --- a/src/app/AuthGate.tsx +++ b/src/app/AuthGate.tsx @@ -49,12 +49,23 @@ export function AuthGate({ children }: { children: ReactNode }) { // Access barrier — shown until BOTH the wallet is open AND the space is named. // "Entrer" records the identifier (persisted immediately, so it survives the // broker redirect) and, if the wallet isn't open yet, triggers the connect. + // + // On return (reload / broker round-trip) the identifier is already stored, so + // we PREFILL the field with it (`initialIdentifier`) — the user never sees a + // bare empty prompt they must re-type. It is captured ONCE, at first access. if (status !== 'connected' || !username) { const onEnter = (identifier: string) => { login(identifier); if (status !== 'connected') connect(); }; - return ; + return ( + + ); } // The app. diff --git a/src/modules/auth/features/barriere-acces-identifiant.feature b/src/modules/auth/features/barriere-acces-identifiant.feature new file mode 100644 index 0000000..17dc23f --- /dev/null +++ b/src/modules/auth/features/barriere-acces-identifiant.feature @@ -0,0 +1,27 @@ +# language: fr +@AUTH @priority-1 +Fonctionnalité: Barrière d'accès — l'identifiant se saisit une seule fois + En tant qu'utilisateur qui revient dans Festipod + Je veux retrouver l'identifiant que j'ai déjà choisi, pré-rempli + Afin de ne jamais avoir à le retaper à l'arrivée + + # Garde-fou contre la régression rapportée : au retour (rechargement / round-trip + # broker) la barrière re-demandait un identifiant NU et VIDE alors qu'il était + # déjà stocké. L'identifiant est capturé UNE FOIS au premier accès, persisté, + # puis pré-rempli. Voir AuthGate + AccessGateScreen. + + @ui + Scénario: Le champ identifiant est pré-rempli avec la valeur déjà stockée + Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice" + Alors le champ identifiant contient "alice" + + @ui + Scénario: Un premier accès sans identifiant stocké affiche un champ vide + Étant donné que la barrière d'accès s'affiche sans identifiant stocké + Alors le champ identifiant est vide + + @ui + Scénario: Entrer remonte l'identifiant saisi + Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice" + Quand je clique sur "Entrer" dans la barrière + Alors l'identifiant remonté à l'application est "alice" diff --git a/src/modules/auth/screens/AccessGateScreen.tsx b/src/modules/auth/screens/AccessGateScreen.tsx index ddfce2f..63099bc 100644 --- a/src/modules/auth/screens/AccessGateScreen.tsx +++ b/src/modules/auth/screens/AccessGateScreen.tsx @@ -28,6 +28,12 @@ import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasS interface AccessGateScreenProps { status: 'disconnected' | 'connecting' | 'connected' | 'error'; error?: string; + /** + * The identifier already stored for this space (the persisted one), used to + * PREFILL the field so a returning user never re-types it. Empty on a truly + * first access. Normalized upstream; shown verbatim. + */ + initialIdentifier?: string; /** Enter the space: the raw identifier the user typed (normalized upstream). */ onEnter: (identifier: string) => void; } @@ -48,13 +54,15 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac ); } -export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) { +export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) { const connecting = status === 'connecting'; const [copied, setCopied] = useState(false); // The identifier that names this virtual space (a technical id — a pseudo in // practice, but not a Festipod username). Entered HERE, at wallet access, so a // single act both names the space and opens it. Normalized (lowercased) upstream. - const [identifier, setIdentifier] = useState(''); + // PREFILLED from the stored identifier so a returning user (reload / broker + // round-trip) sees the value they already chose and never re-types it. + const [identifier, setIdentifier] = useState(initialIdentifier ?? ''); const copyPassword = async () => { try { diff --git a/src/modules/auth/steps/ui/barriere-acces.steps.ts b/src/modules/auth/steps/ui/barriere-acces.steps.ts new file mode 100644 index 0000000..3f37471 --- /dev/null +++ b/src/modules/auth/steps/ui/barriere-acces.steps.ts @@ -0,0 +1,101 @@ +/** + * @ui steps for the access barrier (AccessGateScreen). + * + * These render the prop-driven AccessGateScreen directly (via renderElement) — + * it is NOT a registry/route screen, its state comes from props (status, + * initialIdentifier, onEnter). We assert on the rendered DOM: the identifier + * field is PREFILLED from the stored value, and "Entrer" reports the identifier. + * + * Guards the reported regression: on return the barrier used to re-ask for a + * bare, empty identifier despite one being stored. See AuthGate.tsx. + */ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import React from 'react'; +import { renderElement } from '../../../../shared/test-harness/renderHelper'; +import { AccessGateScreen } from '../../screens/AccessGateScreen'; +import type { FestipodWorld } from '../../../../shared/support/world'; + +// Local per-scenario state (kept off the World to avoid touching its type). +interface GateState { + doc: Document | null; + entered: string | null; +} +const gateStates = new WeakMap(); +function stateFor(world: object): GateState { + let s = gateStates.get(world); + if (!s) { + s = { doc: null, entered: null }; + gateStates.set(world, s); + } + return s; +} + +async function renderGate(world: object, initialIdentifier?: string): Promise { + const s = stateFor(world); + s.entered = null; + // 'connecting' would disable the button; 'disconnected' is the returning-user + // state (session not yet restored) — the exact case that re-prompted before. + s.doc = await renderElement( + React.createElement(AccessGateScreen, { + status: 'disconnected', + initialIdentifier, + onEnter: (id: string) => { + s.entered = id; + }, + }), + ); +} + +Given( + 'la barrière d\'accès s\'affiche avec l\'identifiant stocké {string}', + async function (this: FestipodWorld, identifier: string) { + await renderGate(this, identifier); + }, +); + +Given( + 'la barrière d\'accès s\'affiche sans identifiant stocké', + async function (this: FestipodWorld) { + await renderGate(this, ''); + }, +); + +function identifierField(world: object): HTMLInputElement { + const s = stateFor(world); + expect(s.doc, 'The access barrier should be rendered').to.not.be.null; + const input = s.doc!.querySelector('[data-testid="identifier-input"]') as HTMLInputElement | null; + expect(input, 'The identifier field should be present').to.not.be.null; + return input!; +} + +Then( + 'le champ identifiant contient {string}', + function (this: FestipodWorld, expected: string) { + expect(identifierField(this).value).to.equal(expected); + }, +); + +Then('le champ identifiant est vide', function (this: FestipodWorld) { + expect(identifierField(this).value).to.equal(''); +}); + +When('je clique sur {string} dans la barrière', function (this: FestipodWorld, _label: string) { + const s = stateFor(this); + const input = identifierField(this); + // Submit via Enter on the field (canEnter is satisfied by the prefilled value). + const KeyboardEventCtor = (globalThis as { KeyboardEvent?: typeof KeyboardEvent }).KeyboardEvent; + const evt = KeyboardEventCtor + ? new KeyboardEventCtor('keydown', { key: 'Enter', bubbles: true }) + : Object.assign(new (globalThis as { Event: typeof Event }).Event('keydown', { bubbles: true }), { key: 'Enter' }); + input.dispatchEvent(evt); + expect(s.doc, 'The access barrier should be rendered').to.not.be.null; +}); + +Then( + 'l\'identifiant remonté à l\'application est {string}', + function (this: FestipodWorld, expected: string) { + const s = stateFor(this); + expect(s.entered, 'onEnter should have been called with the identifier').to.equal(expected); + }, +); diff --git a/src/shared/test-harness/renderHelper.tsx b/src/shared/test-harness/renderHelper.tsx index 509ddfd..8444ebb 100644 --- a/src/shared/test-harness/renderHelper.tsx +++ b/src/shared/test-harness/renderHelper.tsx @@ -104,6 +104,37 @@ export async function renderScreen(screenId: string, path?: string): Promise { + await ensureDomGlobals(); + if (!window) throw new Error('DOM globals not installed'); + + if (root) { + root.unmount(); + root = null; + } + + const doc = window.document as unknown as Document; + doc.body.innerHTML = '
'; + const container = doc.getElementById('root')!; + + root = createRoot(container); + + await new Promise((resolve) => { + root.render(element); + setTimeout(resolve, 0); + }); + + return doc; +} + /** * Convert a registry path with `:id` placeholders to a concrete URL using the * first seed event/user when applicable. Tests can override via the explicit