fix(auth): préremplir l'identifiant à la barrière — plus de re-saisie à l'arrivée

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-13 11:13:50 +02:00
parent 7302936502
commit 13da2d9e03
6 changed files with 182 additions and 3 deletions
+12 -1
View File
@@ -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 <AccessGateScreen status={status} error={error} onEnter={onEnter} />;
return (
<AccessGateScreen
status={status}
error={error}
initialIdentifier={username ?? ''}
onEnter={onEnter}
/>
);
}
// The app.
@@ -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"
+10 -2
View File
@@ -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 {
@@ -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<object, GateState>();
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<void> {
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);
},
);
+31
View File
@@ -104,6 +104,37 @@ export async function renderScreen(screenId: string, path?: string): Promise<Doc
return doc;
}
/**
* Render an arbitrary React element (a prop-driven component) into the same
* happy-dom container. Unlike {@link renderScreen}, this does NOT go through the
* screen registry or wrap in the data/router providers — use it for standalone,
* prop-driven components such as the AccessGateScreen (the access barrier), which
* take their state as props rather than reading it from context. Returns the
* rendered document for DOM assertions.
*/
export async function renderElement(element: React.ReactElement): Promise<Document> {
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 = '<div id="root"></div>';
const container = doc.getElementById('root')!;
root = createRoot(container);
await new Promise<void>((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