diff --git a/apps/host/index.html b/apps/host/index.html index 93ceeb92..b46222b3 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -91,7 +91,9 @@ diff --git a/packages/ui/src/styles/modals.css b/packages/ui/src/styles/modals.css index de5de929..9849d6b1 100644 --- a/packages/ui/src/styles/modals.css +++ b/packages/ui/src/styles/modals.css @@ -222,3 +222,17 @@ color: rgba(255, 255, 255, 0.55); margin-bottom: 16px; } +.auth-modal-get-app { + display: block; + margin-top: 14px; + color: rgba(255, 255, 255, 0.55); + font-size: 0.8rem; + text-decoration: underline; + transition: color 0.15s ease; +} +.auth-modal-get-app:hover { + color: #fff; +} +.auth-modal-get-app[hidden] { + display: none; +} diff --git a/packages/ui/src/styles/topbar.css b/packages/ui/src/styles/topbar.css index 835ecbbb..1f62be3f 100644 --- a/packages/ui/src/styles/topbar.css +++ b/packages/ui/src/styles/topbar.css @@ -365,6 +365,9 @@ border-color: transparent; color: rgba(255, 255, 255, 0.65); } +.topbar-btn[aria-busy="true"] { + cursor: progress; +} .topbar-btn:active { background: rgba(255, 255, 255, 0.11); } diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index b2f7e64a..8ec58d33 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -77,6 +77,7 @@ let modalQr: HTMLElement; let modalReason: HTMLElement; let modalHint: HTMLElement; let modalClose: HTMLElement; +let modalGetApp: HTMLAnchorElement; let userPopover: HTMLElement; let userPopoverUsername: HTMLElement; let userPopoverDisconnect: HTMLElement; @@ -105,6 +106,9 @@ const USER_SVG = ` { @@ -490,6 +497,7 @@ function renderAuthState(state: DotliAuthState): void { function renderLoggedOut(): void { authButton.innerHTML = USER_SVG; authButton.title = "Login with Polkadot Mobile"; + authButton.setAttribute("aria-label", "Login with Polkadot Mobile"); setUserPopoverNoUsernameHint(false); window.dispatchEvent(new Event("dotli:logged-out")); } @@ -501,6 +509,7 @@ function renderTruapiLoggedIn(state: TruapiSessionUiState): void { ? `
${escapeHtml(initials)}
` : `
${USER_SVG}
`; authButton.title = "Account"; + authButton.setAttribute("aria-label", "Account"); const username = state.primaryUsername ?? state.fullUsername ?? state.liteUsername; userPopoverUsername.textContent = @@ -627,6 +636,7 @@ function renderPairing(payload: string): void { } function renderAuthenticating(): void { + modalGetApp.hidden = true; // Invalidate an in-flight lazy QR render so it cannot replace this progress // state after the wallet handshake has already been accepted. currentQrPayload = null; @@ -646,83 +656,157 @@ interface FriendlyAuthError { title: string; subtitle: string; detail?: string; + retryable?: boolean; +} + +interface AuthErrorRule { + match: RegExp; + title: string; + subtitle: string; + retryable?: boolean; + hideDetail?: boolean; } +// First match wins, so chain-specific wording and runtime boot failures sit +// above the broad declined, timeout, and transport buckets. +const AUTH_ERROR_RULES: readonly AuthErrorRule[] = [ + { + match: /Invalid Transaction|rejected by the node|re-broadcast rejected/, + title: "Statement Store transaction rejected", + subtitle: + "Polkadot Mobile could not register this browser because the chain rejected the registration transaction.", + }, + { + match: /SubstrateSdk\.JSONRPCError error 1/, + title: "Statement Store registration failed", + subtitle: + "Polkadot Mobile reported a JSON-RPC failure while registering this browser as a device.", + }, + { + match: /OriginPersonProviderError/, + title: "Your account is still being set up", + subtitle: "Please try again later", + hideDetail: true, + }, + { + match: + /version mismatch|unsupported version|incompatible|malformed ?frame/i, + title: "Update Polkadot Mobile", + subtitle: + "This browser and your Polkadot Mobile app are out of step. Update the app and try again.", + }, + { + match: /denied|rejected|declined/i, + title: "Login was declined", + subtitle: + "The request was declined in Polkadot Mobile. Start again and approve it on your phone.", + }, + { + match: /cancel/i, + title: "Login was cancelled", + subtitle: + "The pairing stopped before it finished. Try again when you are ready.", + }, + { + match: /timed? ?out|timeout/i, + title: "Login timed out", + subtitle: + "Polkadot Mobile did not answer in time. Check that your phone is online and try again.", + }, + { + match: /not supported|unsupported/i, + title: "Login is not available here", + subtitle: "This page cannot sign you in with Polkadot Mobile.", + retryable: false, + }, + { + match: + /worker init failed|wasm|webassembly|dynamically imported module|auth host was disposed/i, + title: "The login service did not start", + subtitle: + "This page could not start its login runtime. Reload the page and try again.", + }, + { + match: + /disconnected|connection is closed|transport closed|not connected|connection (refused|reset|aborted)|network (unreachable|down)|host unreachable|failed to fetch|networkerror|load failed/i, + title: "Connection to Polkadot Mobile was lost", + subtitle: + "The link between this browser and your phone dropped before login finished. Check that both are online and try again.", + }, + { + match: /handshake|statement[- ]store|allowance/i, + title: "Pairing could not complete", + subtitle: + "This browser and Polkadot Mobile could not exchange their pairing messages. Try again in a moment.", + }, +]; + function exhaustedAllowanceError(message: string): FriendlyAuthError { return { title: "No Statement Store slots left", subtitle: "Polkadot Mobile has no free slot to register this browser. Try again once the current allowance period rolls over.", detail: message, + // Retrying cannot succeed until the allowance period rolls over. + retryable: false, }; } -// Recognize known wallet-side SSO failures and return friendly copy, or null to -// fall back to the raw error. -function friendlyAuthError(message: string): FriendlyAuthError | null { - if (message.includes("Invalid Transaction")) { - return { - title: "Statement Store transaction rejected", - subtitle: - "Polkadot Mobile could not register this browser because the chain rejected the registration transaction.", - detail: message, - }; - } - if (message.includes("SubstrateSdk.JSONRPCError error 1")) { +// Map a wallet or transport failure to copy a first-time user can act on. +// Unknown reasons keep the raw text as a detail line for bug reports. +function friendlyAuthError(message: string): FriendlyAuthError { + const rule = AUTH_ERROR_RULES.find((candidate) => + candidate.match.test(message), + ); + if (rule === undefined) { return { - title: "Statement Store registration failed", + title: "Login did not complete", subtitle: - "Polkadot Mobile reported a JSON-RPC failure while registering this browser as a device.", + "Something interrupted the connection to Polkadot Mobile. Try again, and make sure the app is installed and up to date.", detail: message, }; } - if (message.includes("OriginPersonProviderError")) { - return { - title: "Your account is still being set up", - subtitle: "Please try again later", - }; - } - return null; + return { + title: rule.title, + subtitle: rule.subtitle, + detail: rule.hideDetail === true ? undefined : message, + retryable: rule.retryable, + }; } function renderError(message: string, kind: LoginFailureKind): void { + modalGetApp.hidden = true; const container = document.createElement("div"); container.className = "auth-modal-error-view"; - const exhaustedPeriod = kind === "NoFreeAllowanceSlots"; - const friendly = exhaustedPeriod - ? exhaustedAllowanceError(message) - : friendlyAuthError(message); - if (friendly) { - const icon = document.createElement("div"); - icon.className = "auth-modal-pending-icon"; - icon.innerHTML = PENDING_ICON_SVG; - container.appendChild(icon); - - const title = document.createElement("div"); - title.className = "auth-modal-pending-title"; - title.textContent = friendly.title; - container.appendChild(title); - - const subtitle = document.createElement("div"); - subtitle.className = "auth-modal-pending-subtitle"; - subtitle.textContent = friendly.subtitle; - container.appendChild(subtitle); - - if (friendly.detail !== undefined && friendly.detail.length > 0) { - const detail = document.createElement("p"); - detail.className = "auth-modal-error"; - detail.textContent = friendly.detail; - container.appendChild(detail); - } - } else { - const msg = document.createElement("p"); - msg.className = "auth-modal-error"; - msg.textContent = message; - container.appendChild(msg); + const friendly = + kind === "NoFreeAllowanceSlots" + ? exhaustedAllowanceError(message) + : friendlyAuthError(message); + + const icon = document.createElement("div"); + icon.className = "auth-modal-pending-icon"; + icon.innerHTML = PENDING_ICON_SVG; + container.appendChild(icon); + + const title = document.createElement("div"); + title.className = "auth-modal-pending-title"; + title.textContent = friendly.title; + container.appendChild(title); + + const subtitle = document.createElement("div"); + subtitle.className = "auth-modal-pending-subtitle"; + subtitle.textContent = friendly.subtitle; + container.appendChild(subtitle); + + if (friendly.detail !== undefined && friendly.detail.length > 0) { + const detail = document.createElement("p"); + detail.className = "auth-modal-error"; + detail.textContent = friendly.detail; + container.appendChild(detail); } - if (!exhaustedPeriod) { + if (friendly.retryable !== false) { const retry = document.createElement("button"); retry.className = "auth-modal-retry"; retry.textContent = "Retry"; @@ -2404,6 +2488,9 @@ function openModal( options: { dotSuffix?: boolean } = {}, ): void { modalQr.innerHTML = `
`; + // Desktop users scan with a phone that already has the app, so the install + // link only helps on the phone itself. + modalGetApp.hidden = !isMobileDevice(); // Mobile leads with the deeplink button. The QR toggle swaps this copy later. modalHint.textContent = isMobileDevice() ? "Sign in with the Polkadot app on this device" diff --git a/packages/ui/tests/topbar.test.ts b/packages/ui/tests/topbar.test.ts index fb6962e9..8bdbb303 100644 --- a/packages/ui/tests/topbar.test.ts +++ b/packages/ui/tests/topbar.test.ts @@ -35,6 +35,12 @@ vi.mock("@dotli/protocol/client", () => ({ }, })); +const device = vi.hoisted(() => ({ mobile: false })); + +vi.mock("@dotli/shared/device", () => ({ + isMobileDevice: () => device.mobile, +})); + async function flushMicrotasks(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -43,12 +49,13 @@ async function flushMicrotasks(): Promise { function installTopbarDom(): void { document.body.innerHTML = ` - +
+
@@ -74,6 +81,7 @@ beforeEach(() => { vi.resetModules(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + device.mobile = false; localStorage.clear(); sharedAuth.storage.clear(); sharedAuth.listeners.clear(); @@ -612,6 +620,210 @@ describe("topbar login cancellation", () => { expect(modalText).toContain("submit RPC error: Invalid Transaction"); expect(modalText).toContain("Retry"); }); + + it("As a new user, I am told when I declined the login on my phone", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + kind: "Other", + reason: "Login request denied", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("Login was declined"); + expect(modalText).toContain("Retry"); + }); + + it("As a new user, I am told when Polkadot Mobile did not answer in time", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + kind: "Other", + reason: "runtime call timed out", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("Login timed out"); + expect(modalText).toContain("Retry"); + }); + + it("As a new user, I am not offered a retry when this page cannot log in at all", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + kind: "Other", + reason: "Login is not supported by this host", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("Login is not available here"); + expect(modalText).not.toContain("Retry"); + }); + + it("As a new user, a login runtime that fails to load reads as a page problem rather than a phone problem", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + // Observed with the asset server down: the auth worker never booted. + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + kind: "Other", + reason: "worker init failed: undefined", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("The login service did not start"); + expect(modalText).toContain("Retry"); + }); + + it("As a new user, a chunk that fails to fetch is a runtime problem, not a lost phone connection", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + kind: "Other", + reason: + "TypeError: Failed to fetch dynamically imported module: http://localhost:5173/assets/web-1228KImM.js", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("The login service did not start"); + expect(modalText).not.toContain("Connection to Polkadot Mobile was lost"); + }); + + it("As a new user, an unknown failure still reads as a login problem with the raw reason kept for bug reports", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + kind: "Other", + reason: "Host failure", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("Login did not complete"); + expect( + document.querySelector("#auth-modal-qr .auth-modal-error")?.textContent, + ).toBe("Host failure"); + expect(modalText).toContain("Retry"); + }); +}); + +describe("topbar first login guidance", () => { + it("As a user, the login button says it is connecting until the top bar is ready", async () => { + // Given + installTopbarDom(); + const button = document.getElementById("auth-button"); + expect(button?.title).toBe("Connecting..."); + expect(button?.getAttribute("aria-busy")).toBe("true"); + + // When + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + // Then + expect(button?.title).toBe("Login with Polkadot Mobile"); + expect(button?.getAttribute("aria-label")).toBe( + "Login with Polkadot Mobile", + ); + expect(button?.hasAttribute("aria-busy")).toBe(false); + expect(button?.hasAttribute("disabled")).toBe(false); + }); + + it("As a new user on a phone without the app, the login modal shows me where to get Polkadot Mobile", async () => { + // Given + device.mobile = true; + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + const getApp = document.getElementById("auth-modal-get-app"); + + // When + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "Pairing", + deeplink: "polkadotapp://pair?handshake=test", + label: "localhost:3000", + }, + }), + ); + + // Then + expect(getApp?.hidden).toBe(false); + expect(getApp?.getAttribute("href")).toBe( + "https://docs.polkadot.com/apps/", + ); + + // When the wallet has approved, installing the app is no longer the ask. + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { tag: "Authenticating" }, + }), + ); + + // Then + expect(getApp?.hidden).toBe(true); + }); + + it("As a desktop user scanning with my phone, the modal does not offer an app install link", async () => { + // Given + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + // When + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "Pairing", + deeplink: "polkadotapp://pair?handshake=test", + label: "localhost:3000", + }, + }), + ); + + // Then + expect(document.getElementById("auth-modal-get-app")?.hidden).toBe(true); + }); }); describe("topbar boot rehydration", () => {