feat(auth): wallet partagé = seul mode + purge identifiant-wallet↔username
AccessGateScreen 3-branches (erreur config si pas de wallet partagé) ; renommage username→identifier de l'identité du wallet (registration, ngSession, hooks, steps auth) sans toucher UserProfile.username ; .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* AccessGateScreen — the *technical access barrier* of the stopgap.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE. Festipod does not function without
|
||||
* the shared wallet (the SDK polyfill runs on it). "No shared wallet configured"
|
||||
* is therefore NOT an offered flow — it is a loud MISCONFIGURATION error
|
||||
* (`!hasSharedWallet()` → a config-error block, no functional form). Configure it
|
||||
* via FESTIPOD_SHARED_WALLET_PASSWORD.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
|
||||
* REAL NextGraph login, shown before the app renders. Because it precedes the
|
||||
* app, the user reads it as "access to the test environment", not as an app
|
||||
* login. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, not a Festipod username).
|
||||
* virtual space (a technical id, a pseudo in practice, NOT a Festipod profile
|
||||
* handle like `@mariedupont`).
|
||||
* Clicking "Entrer" records that identifier and triggers `connect()`, which
|
||||
* redirects to the broker to open the SHARED wallet. After return the identity
|
||||
* is already set (persisted before the redirect), so NG auto-connects straight
|
||||
* into the app — there is no separate "pick a username" screen.
|
||||
* into the app — there is no separate "choose a handle" screen.
|
||||
*
|
||||
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
|
||||
* wallet inline during
|
||||
@@ -17,8 +24,8 @@
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
|
||||
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. This
|
||||
* assisted flow is the default whenever the shared wallet is open pending.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
@@ -58,7 +65,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
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
|
||||
// practice, but not a Festipod profile handle). Entered HERE, at wallet access, so a
|
||||
// single act both names the space and opens it. Normalized (lowercased) upstream.
|
||||
// PREFILLED from the stored identifier so a returning user (reload / broker
|
||||
// round-trip) sees the value they already chose and never re-types it.
|
||||
@@ -107,7 +114,14 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{hasSharedWallet() && status !== 'connected' ? (
|
||||
{!hasSharedWallet() ? (
|
||||
// SOLE-MODE guard: Festipod cannot run without the shared wallet, so a
|
||||
// missing one is a misconfiguration, NOT a functional login form.
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#c92a2a', lineHeight: 1.5, margin: '0 0 12px' }}>
|
||||
Portefeuille partagé non configuré. Festipod ne fonctionne pas sans
|
||||
(définir <code>FESTIPOD_SHARED_WALLET_PASSWORD</code>).
|
||||
</Text>
|
||||
) : status !== 'connected' ? (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
|
||||
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Each @data scenario runs under a UNIQUE username (see hooks.ts
|
||||
// freshScenarioUsername), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// Each @data scenario runs under a UNIQUE identifier (see hooks.ts
|
||||
// freshScenarioIdentifier), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// "le portefeuille est vide" is trivially true on entry. So this is a fast
|
||||
// INSTANT CHECK — assert the reactive read already shows nothing — NOT the old
|
||||
// `clearWallet` per-entity-doc fan-out (a full physical-wallet enumeration that
|
||||
|
||||
@@ -8,14 +8,35 @@
|
||||
*
|
||||
* Guards the reported regression: on return the barrier used to re-ask for a
|
||||
* bare, empty identifier despite one being stored. See AuthGate.tsx.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE (see AccessGateScreen header): the
|
||||
* identifier field lives INSIDE the assisted-import flow, which renders only when
|
||||
* a shared wallet is configured; otherwise the barrier shows a config-error with
|
||||
* NO field. Production always configures one, but the @ui node harness does not
|
||||
* inject the build global, so we set it HERE — before the screen module is first
|
||||
* imported, so `sharedWallet.ts` captures it at module-eval — and lazy-import the
|
||||
* screen. This file is the only @ui module that reaches sharedWallet.ts, so this
|
||||
* ordering is deterministic.
|
||||
*/
|
||||
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';
|
||||
|
||||
globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ = 'test-shared-wallet';
|
||||
|
||||
// Lazy so sharedWallet.ts evaluates AFTER the global above is set (a static
|
||||
// import would hoist above it, capturing an empty password → config-error).
|
||||
type Gate = typeof import('../../screens/AccessGateScreen')['AccessGateScreen'];
|
||||
let gateComponent: Gate | null = null;
|
||||
async function loadGate(): Promise<Gate> {
|
||||
if (!gateComponent) {
|
||||
gateComponent = (await import('../../screens/AccessGateScreen')).AccessGateScreen;
|
||||
}
|
||||
return gateComponent;
|
||||
}
|
||||
|
||||
// Local per-scenario state (kept off the World to avoid touching its type).
|
||||
interface GateState {
|
||||
doc: Document | null;
|
||||
@@ -34,6 +55,7 @@ function stateFor(world: object): GateState {
|
||||
async function renderGate(world: object, initialIdentifier?: string): Promise<void> {
|
||||
const s = stateFor(world);
|
||||
s.entered = null;
|
||||
const AccessGateScreen = await loadGate();
|
||||
// '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(
|
||||
|
||||
@@ -49,9 +49,9 @@ import { setCurrentUser } from '@ng-eventually/client/polyfill';
|
||||
|
||||
// Festipod localStorage key for the current identifier (same-partition
|
||||
// prefill/convenience only — never the cross-frontier carrier; that's the URL
|
||||
// param). Changed from the historical 'festipod.account.username' → any
|
||||
// pre-existing stored "logins" under the old key are dropped (acceptable: this
|
||||
// is a stopgap test env; the URL param carries identity anyway).
|
||||
// param). Renamed to `.identifier` from a historical key that mislabeled this
|
||||
// account id → any pre-existing stored logins under the old key are dropped
|
||||
// (acceptable: this is a stopgap test env; the URL param carries identity anyway).
|
||||
const STORAGE_KEY = 'festipod.account.identifier';
|
||||
|
||||
/** Name of the URL param that carries the identifier across the broker frontier. */
|
||||
|
||||
@@ -343,17 +343,17 @@ export async function readRegistrationNotifications(
|
||||
* just-written participation, so a second join checking only the reactive set would
|
||||
* write a duplicate. Querying the broker sees the real state regardless of read lag.
|
||||
*
|
||||
* Scoped to the CURRENT account (`username`) via `listMyEntityDocs` — a user's own
|
||||
* Scoped to the CURRENT account (`identifier`) via `listMyEntityDocs` — a user's own
|
||||
* participations live in their own account, so there is NO need to fan out over all
|
||||
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
|
||||
*/
|
||||
export async function countUserParticipations(
|
||||
username: string,
|
||||
identifier: string,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const docs_ = await listMyEntityDocs(username, 'protected');
|
||||
const docs_ = await listMyEntityDocs(identifier, 'protected');
|
||||
let total = 0;
|
||||
for (const g of docs_) {
|
||||
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
|
||||
|
||||
@@ -22,7 +22,7 @@ setDefaultTimeout(90000);
|
||||
// account (whose shim key uses a sentinel prefix `normalizeIdentifier` can't emit).
|
||||
const RUN_NONCE = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
let scenarioSeq = 0;
|
||||
function freshScenarioUsername(): string {
|
||||
function freshScenarioIdentifier(): string {
|
||||
scenarioSeq += 1;
|
||||
return `test-${RUN_NONCE}-${scenarioSeq}`;
|
||||
}
|
||||
@@ -580,7 +580,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// the run self-heals instead of cascading failures across the rest.
|
||||
this.page = await newWalletPageResilient();
|
||||
|
||||
// FRESH VIRTUAL WALLET per scenario (see freshScenarioUsername above). Set a
|
||||
// FRESH VIRTUAL WALLET per scenario (see freshScenarioIdentifier above). Set a
|
||||
// UNIQUE app-level identifier into localStorage['festipod.account.identifier']
|
||||
// on EVERY origin (the init script runs in each frame before its scripts do —
|
||||
// including the harness iframe on 127.0.0.1). At mount the harness's
|
||||
@@ -588,11 +588,11 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// login(DEFAULT_HARNESS_USER)` is skipped and the scenario runs on a fresh,
|
||||
// empty virtual wallet. Overwrites any value persisted in the Chromium profile
|
||||
// (init scripts run on each navigation), so no accumulated wallet leaks in.
|
||||
const freshUser = freshScenarioUsername();
|
||||
(this as any).freshUser = freshUser;
|
||||
const freshIdentifier = freshScenarioIdentifier();
|
||||
(this as any).freshIdentifier = freshIdentifier;
|
||||
await this.page.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque origin */ }
|
||||
}, freshUser);
|
||||
}, freshIdentifier);
|
||||
|
||||
// Capture console for debugging AND collect into the World so smoke
|
||||
// scenarios can assert no runtime error was emitted during the connected
|
||||
@@ -622,8 +622,8 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
);
|
||||
|
||||
// NO per-scenario registry/wallet reset needed anymore (was T03.j
|
||||
// resetDataState). Each @data scenario now runs under a UNIQUE username
|
||||
// (freshScenarioUsername, set into localStorage above), so the shim hands it
|
||||
// resetDataState). Each @data scenario now runs under a UNIQUE identifier
|
||||
// (freshScenarioIdentifier, set into localStorage above), so the shim hands it
|
||||
// a FRESH, EMPTY virtual wallet whose account registry starts empty by
|
||||
// construction — nothing to purge. This also drops the ≤10s reset cost that
|
||||
// shared the Before hook's budget with the (slow) broker login.
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function login() {
|
||||
* REAL NextGraph logout — stops the session of the SHARED wallet.
|
||||
*
|
||||
* STOPGAP: must stay HIDDEN (Settings/debug only). The everyday "Déconnexion"
|
||||
* is the FAUX one (AccountContext.logout, clears the username only). Calling
|
||||
* is the FAUX one (AccountContext.logout, clears the identifier only). Calling
|
||||
* this forces a new broker redirect on the next access — see
|
||||
* decision_2026-06-15_shared-wallet-login-flow.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user