Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/host/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@
<button
id="auth-button"
class="topbar-btn"
title="Login with Polkadot Mobile"
title="Connecting..."
aria-label="Connecting..."
aria-busy="true"
disabled
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
Expand Down Expand Up @@ -240,6 +242,7 @@ <h2 id="auth-modal-title">Login with Polkadot Mobile</h2>
<div class="auth-modal-qr" id="auth-modal-qr">
<div class="spinner"></div>
</div>
<a class="auth-modal-get-app" id="auth-modal-get-app" target="_blank" rel="noopener noreferrer" hidden>Don't have the app? Get Polkadot Mobile</a>
<button class="auth-modal-close" id="auth-modal-close">
Cancel
</button>
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/styles/modals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions packages/ui/src/styles/topbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
193 changes: 140 additions & 53 deletions packages/ui/src/topbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -105,6 +106,9 @@ const USER_SVG = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" st

// Track the current QR payload to prevent stale canvas appends
let currentQrPayload: string | null = null;

// Lists the current Polkadot Mobile store listings for phones without the app.
const POLKADOT_MOBILE_DOWNLOAD_URL = "https://docs.polkadot.com/apps/";
let truapiSessionConnected = false;
let blockingModalCoordinator: BlockingModalCoordinator | null = null;
let authModalScope: BlockingModalScope | null = null;
Expand Down Expand Up @@ -276,6 +280,8 @@ export function initTopBar(
modalReason = getElement("auth-modal-reason");
modalHint = getElement("auth-modal-hint");
modalClose = getElement("auth-modal-close");
modalGetApp = getElement("auth-modal-get-app") as HTMLAnchorElement;
modalGetApp.href = POLKADOT_MOBILE_DOWNLOAD_URL;
userPopover = getElement("user-popover");
userPopoverUsername = getElement("user-popover-username");
userPopoverDisconnect = getElement("user-popover-disconnect");
Expand All @@ -288,6 +294,7 @@ export function initTopBar(
// Auth button: opens modal (logged out) or popover (logged in)
authButton.addEventListener("click", handleAuthButtonClick);
authButton.removeAttribute("disabled");
authButton.removeAttribute("aria-busy");

// Modal close button
modalClose.addEventListener("click", () => {
Expand Down Expand Up @@ -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"));
}
Expand All @@ -501,6 +509,7 @@ function renderTruapiLoggedIn(state: TruapiSessionUiState): void {
? `<div class="user-badge">${escapeHtml(initials)}</div>`
: `<div class="user-badge user-badge-anon">${USER_SVG}</div>`;
authButton.title = "Account";
authButton.setAttribute("aria-label", "Account");
const username =
state.primaryUsername ?? state.fullUsername ?? state.liteUsername;
userPopoverUsername.textContent =
Expand Down Expand Up @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -2404,6 +2488,9 @@ function openModal(
options: { dotSuffix?: boolean } = {},
): void {
modalQr.innerHTML = `<div class="spinner"></div>`;
// 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"
Expand Down
Loading
Loading