fix: la barrière s'affiche à chaque chargement en page de tête, comme chez Festipod
Notre version décidait d'afficher la barrière sur la présence de l'identifiant. Festipod décidait sur la session — jamais établie en page de tête, donc l'écran s'affichait toujours, l'identifiant servant seulement à pré-remplir le champ. La différence n'est pas ergonomique. L'identifiant est un état qu'on observe ; le portefeuille, lui, vit dans le stockage d'une autre origine et nous est illisible. Un écran conditionnel doit donc DEVINER cet état invisible — et quand il devine « déjà installé » alors que le portefeuille a disparu du navigateur, il cache les seuls contrôles qui répareraient la situation et précipite la personne dans une impasse. Impasse observée sur le site réel : sans portefeuille, la page du broker affiche un texte statique, zéro bouton, un seul lien vers nextgraph.eu qui NE TRANSPORTE AUCUN retour vers l'application. Le retour arrière du navigateur est la seule issue — et il ne sert à rien si la barrière ne reprend pas la personne à l'arrivée. Le discriminant devient le cadre, pas l'identifiant : page de tête → toujours, iframe → on s'efface. C'est le signal que @ng-org/web utilise lui-même et que Festipod utilisait un étage plus bas. On ne détecte rien et on ne demande rien. Les trois étapes s'affichent toujours ; qui possède déjà son portefeuille ignore les deux premières. Aucune case « je l'ai déjà » : savoir si l'on a importé un portefeuille dans ce navigateur est une question trop technique pour être posée. Garde-fou repris de Festipod, qu'on n'avait pas : après un aller-retour vers l'onglet NextGraph et un retour arrière, la barrière restait figée sur un bouton mort. Elle recharge désormais sur pageshow persisted — seul moyen de rejouer le init() qui porte la redirection. Vérifié sur navigateur : cliquer « Import a Wallet File » ouvre un sélecteur sur place, sans navigation ni changement d'onglet, et notre onglet ne reçoit AUCUN signal quand l'import réussit. Détecter le retour est donc impossible, pas seulement fragile.
This commit is contained in:
@@ -53,6 +53,30 @@
|
||||
* on every path, whatever settled the identity. Hence {@link rememberIdentity} on all
|
||||
* three, and hence {@link settleIdentity}, which the polyfill's own `init()` awaits before
|
||||
* it delegates (`surface/lifecycle.ts`) — so no application has to know that order.
|
||||
*
|
||||
* ── The barrier belongs to the TOP-LEVEL page, on every load ──────────────
|
||||
* That same frontier decides WHEN the barrier shows, and the rule is not "when we do not
|
||||
* know who this is". It is {@link insideBroker}:
|
||||
*
|
||||
* - **top-level** — the page has no session and is on its way to the hand-over. The
|
||||
* barrier ALWAYS shows, prefilled with whatever identifier is already known. It is the
|
||||
* last screen anyone sees before the redirect, and the only screen a person who holds
|
||||
* no wallet will ever be offered: the broker sends a walletless visitor to a static
|
||||
* dead-end with no return path, so a barrier that skipped itself would strand them.
|
||||
* - **inside the broker iframe** — the round-trip has happened, the URL carries the
|
||||
* identifier, a session is coming. Resolve and stand aside.
|
||||
*
|
||||
* Keying on identity PRESENCE instead is what this replaced, and it failed silently in the
|
||||
* direction that matters: a returning visitor was recognised from storage, the barrier
|
||||
* never showed, and someone who had cleared the wallet from this browser — or never
|
||||
* imported it, having arrived on a device the identifier had reached by URL — was handed
|
||||
* straight to a dead end with no way back but the browser's own Back button.
|
||||
*
|
||||
* Nothing here detects whether the wallet is already imported, and nothing may: that lives
|
||||
* in the wallet application's own origin, which this page cannot read. So the steps are
|
||||
* always all shown, and someone who already holds the wallet simply walks past them. The
|
||||
* alternative — asking the person whether they have imported a wallet in this browser — is
|
||||
* a question about a technical act they have no way to answer reliably.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -84,6 +108,27 @@ function normalizeIdentity(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this document running INSIDE the broker's iframe, rather than top-level?
|
||||
*
|
||||
* The one signal that separates the two contexts this flow runs in, and it is deliberately
|
||||
* the SAME one the level below uses: `@ng-org/web`'s `init()` compares `window.self` to
|
||||
* `window.top` to decide whether to hand the page over. Asking the question the same way
|
||||
* means the barrier shows exactly on the pages that are about to be redirected, and never
|
||||
* on the one that came back — no second source of truth to drift from.
|
||||
*
|
||||
* A session would be the more obvious signal — it exists only inside the iframe — but this
|
||||
* half of signing in runs BEFORE any session can exist ({@link settleIdentity}), so asking
|
||||
* for one would deadlock. The frame check answers with nothing but the page.
|
||||
*
|
||||
* No `window` at all (server-side, a unit test) is not a frame: nothing there is going to
|
||||
* be redirected, so the caller is treated as top-level and owns the identity itself.
|
||||
*/
|
||||
function insideBroker(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.self !== window.top;
|
||||
}
|
||||
|
||||
/** Where the gate stashes the identifier so a plain reload prefills it. */
|
||||
const STORAGE_KEY = "ng-eventually:identity";
|
||||
/** The URL parameter — the only channel that survives the broker round-trip. */
|
||||
@@ -162,19 +207,47 @@ function rememberIdentity(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the gate and resolve with the identifier the user entered.
|
||||
* Un-stick a top-level page that was handed to the broker and came BACK.
|
||||
*
|
||||
* No prefill parameter, deliberately: the gate is shown ONLY when no identity is known,
|
||||
* so there is never a value to prefill. The consumer this was moved from did prefill,
|
||||
* because its screen reappeared after the broker round-trip — here the URL carries the
|
||||
* identity across that round-trip, so a returning user does not see the barrier at all.
|
||||
* The need is met one level up rather than papered over in the form.
|
||||
* The hand-over is a navigation, so the browser may keep this document alive in its
|
||||
* back/forward cache. Someone who reaches the broker without a wallet lands on a static
|
||||
* dead end with no return path — the Back button is their only way out — and what Back
|
||||
* restores is this page EXACTLY as it left: the barrier frozen on "Accès en cours…", its
|
||||
* button dead, and an `init()` that has already delegated and will never redirect again.
|
||||
* Nothing on that page can be clicked back to life.
|
||||
*
|
||||
* A reload is the reset, and it is the whole of it: `init()` runs again, settles again,
|
||||
* and puts the barrier back up — prefilled from the address bar the hand-over left behind
|
||||
* — with its button live. Only a restore from the cache does this (`persisted`), and a
|
||||
* reload is not one, so it cannot loop.
|
||||
*
|
||||
* Armed on the way OUT rather than at load, so it exists exactly on the pages that have
|
||||
* been handed over: someone still typing at the barrier who wanders off and comes back
|
||||
* keeps what they typed instead of having it reloaded away.
|
||||
*/
|
||||
function armReturnFromHandOver(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
|
||||
if (!event.persisted) return;
|
||||
globalThis.location?.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the barrier and resolve with the identifier the person confirmed.
|
||||
*
|
||||
* `prefill` is what is already known about them — from the URL, from storage — and it goes
|
||||
* in the field, never around the screen. The barrier still shows: a known identifier says
|
||||
* nothing about whether this browser holds the wallet, and skipping the screen on the
|
||||
* strength of it strands anyone who does not (see the header). So a returning visitor
|
||||
* confirms a filled field, which is one click and no typing, and a first-time one gets the
|
||||
* same screen with an empty field.
|
||||
*
|
||||
* Deliberately plain DOM: this is a technical barrier shown before an application
|
||||
* renders, like a password prompt on a closed beta. Binding it to a UI framework would
|
||||
* make every consumer adopt that framework for a screen that is going away.
|
||||
*/
|
||||
function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
|
||||
function askForIdentity(cfg: SharedWalletConfig, prefill: string): Promise<string> {
|
||||
const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL;
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement("div");
|
||||
@@ -229,11 +302,32 @@ function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
|
||||
|
||||
const input = root.querySelector("input") as HTMLInputElement;
|
||||
const go = root.querySelector("button.go") as HTMLButtonElement;
|
||||
// Set on the live element rather than into the markup above: the value is a person's
|
||||
// identifier and the markup is a template string, so interpolating it there would put
|
||||
// whatever a link carried into the page as HTML.
|
||||
input.value = prefill;
|
||||
const sync = (): void => { go.disabled = input.value.trim().length === 0; };
|
||||
// Answered once, and once only. A disabled button dispatches no click, but the field
|
||||
// still takes an Enter key — so without this a second press would arm a second return
|
||||
// listener and resolve a promise that has already been answered.
|
||||
let entered = false;
|
||||
const enter = (): void => {
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
host.remove();
|
||||
if (!value || entered) return;
|
||||
entered = true;
|
||||
if (insideBroker()) {
|
||||
// The application renders behind the barrier: the session is already coming, and
|
||||
// this screen has nothing left to say.
|
||||
host.remove();
|
||||
} else {
|
||||
// Top-level, settling is followed by the hand-over — `init()` awaits this and then
|
||||
// navigates. Leaving the page bare for that moment would show a blank application;
|
||||
// worse, coming BACK to a bare page would leave nothing to press. So the barrier
|
||||
// stays up and says what is happening, and the return path is armed.
|
||||
go.disabled = true;
|
||||
go.textContent = "Accès en cours…";
|
||||
armReturnFromHandOver();
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
@@ -311,21 +405,35 @@ export function settleIdentity(): Promise<PrincipalId> {
|
||||
|
||||
/**
|
||||
* Resolve the identifier the three ways it can arrive — already set by the caller, read
|
||||
* from the page (URL, then storage), or typed at the barrier — and remember it on every
|
||||
* from the page (URL, then storage), or confirmed at the barrier — and remember it on every
|
||||
* one of them ({@link rememberIdentity}), which is the path-independent part that the
|
||||
* round-trip depends on.
|
||||
*
|
||||
* What a KNOWN identifier does depends on which side of the frontier is asking, and that is
|
||||
* the whole shape of this function ({@link insideBroker}): inside the iframe it settles the
|
||||
* question, top-level it only fills the field in. Reading it is never enough top-level,
|
||||
* because it says nothing about whether this browser holds the wallet.
|
||||
*/
|
||||
async function resolveIdentity(): Promise<PrincipalId> {
|
||||
const already = getCurrentUser();
|
||||
if (already !== null) {
|
||||
// The caller took charge of the identity itself — the escape hatch the "no shared
|
||||
// wallet configured" error below names. It is not a person arriving at a page, so
|
||||
// there is nobody to show a barrier to, and no hand-over to protect: honour it and
|
||||
// just put it where the round-trip will find it. Nothing an application ships does
|
||||
// this; the harnesses that drive the surface directly do.
|
||||
rememberIdentity(already);
|
||||
return already;
|
||||
}
|
||||
|
||||
const known = storedIdentity();
|
||||
if (known) {
|
||||
// Even though `storedIdentity()` just read it: what it read may have come from THIS
|
||||
// partition's storage, which the round-trip does not carry. The address bar does.
|
||||
if (known !== null && insideBroker()) {
|
||||
// Inside the iframe the round-trip has already happened: the identifier came across in
|
||||
// the URL and a session is on its way. Stand aside.
|
||||
//
|
||||
// Remembered again even though `storedIdentity()` just read it: what it read may have
|
||||
// come from THIS partition's storage, which the round-trip does not carry. The address
|
||||
// bar does.
|
||||
rememberIdentity(known);
|
||||
adoptCurrentUser(known);
|
||||
return known;
|
||||
@@ -347,7 +455,7 @@ async function resolveIdentity(): Promise<PrincipalId> {
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = await askForIdentity(cfg);
|
||||
const chosen = await askForIdentity(cfg, known ?? "");
|
||||
const normalized = normalizeIdentity(chosen);
|
||||
rememberIdentity(normalized);
|
||||
adoptCurrentUser(normalized);
|
||||
@@ -362,8 +470,11 @@ async function resolveIdentity(): Promise<PrincipalId> {
|
||||
* the day the wallet supplies the identity, this resolves without showing anything and
|
||||
* the caller's code is unchanged.
|
||||
*
|
||||
* A returning user never sees the gate: the identifier survives the broker round-trip in
|
||||
* the URL, and a plain reload finds it in storage.
|
||||
* A returning user sees the barrier again, with their identifier already in the field —
|
||||
* one click, no typing. It is not shown because they are unknown (they are not); it is
|
||||
* shown because a top-level page is one redirect away from a dead end for anyone whose
|
||||
* browser has lost the wallet, and this screen is the only place that hands it back out.
|
||||
* Past the round-trip, inside the iframe, it never appears.
|
||||
*
|
||||
* **It no longer has to be called before `init()`** — the order is structural now. Whatever
|
||||
* settles the identity leaves `?ng-id=` in the address bar, and `init()` hands the broker
|
||||
|
||||
Reference in New Issue
Block a user