Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
6 changed files with 182 additions and 3 deletions
Showing only changes of commit 13da2d9e03 - Show all commits
@@ -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
+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