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:
+12
-1
@@ -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"
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user