UNPKG

accounts

Version:

Tempo Accounts SDK

331 lines (301 loc) 11 kB
import { defaultSize, isInsecureContext } from '../../Dialog.js' import * as IO from '../../IntersectionObserver.js' import * as TrustedHosts from '../../TrustedHosts.js' /** * Wallet-window mounts for the postMessage adapter — the UI half of a wata * postMessage session. * * A mount owns where the wallet page lives (hidden overlay iframe or popup * window) and how it surfaces: the adapter drives `show`/`hide` from its * request queue, and the wata transport acquires and releases the window * handle through `target`/`close`. Mirrors the bespoke `Dialog.iframe` / * `Dialog.popup` UX without the bespoke messenger coupling. */ /** Mounted wallet-window UI driven by the adapter's request queue. */ export type Mount = { /** Tears down the session's window handle (wata transport close). */ close: (handle: Window | MessagePort) => void /** Removes all mounted DOM. */ destroy: () => void /** Puts the UI away once the request queue drains. */ hide: () => void /** Mode the wallet page renders its approval chrome in. */ mode: 'iframe' | 'popup' /** Surfaces and focuses the UI for a pending request. */ show: () => void /** Acquires the window handle for the wata session, mounting UI as needed. */ target: () => Window | null } /** Creates a {@link Mount} bound to the wallet page URL. */ export type Factory = ((parameters: Factory.Parameters) => Mount) & { /** Mode advertised to the wallet page (`?mode=`), known pre-instantiation. */ mode: 'iframe' | 'popup' } export declare namespace Factory { /** Parameters for a mount factory. */ export type Parameters = { /** Wallet page URL (already origin- and mode-tagged). */ host: string /** Called when the user dismisses the UI without answering. */ onDismiss: () => void /** * Called when the mounted wallet page was reloaded out from under the * session (e.g. React hydration re-appending the iframe) and the * session must re-handshake. */ onInvalidate: () => void } } /** * Picks the default mount: an overlay iframe unless the context can't * support one — insecure contexts lack WebAuthn in iframes, and without * IntersectionObserver v2 an untrusted host can't run its occlusion * defense. */ export function auto(options: auto.Options = {}): Factory { if (typeof window === 'undefined') return popup() if (isInsecureContext()) return popup() const trusted = (() => { const trustedHosts = options.trustedHosts ?? trustedHostsFor(options.source) if (!trustedHosts) return false const hostname = window.location.hostname.replace(/^www\./, '') return TrustedHosts.match(trustedHosts, hostname, options.source) })() if (!IO.supported() && !trusted) return popup() return iframe() } export declare namespace auto { /** Options for {@link auto}. */ type Options = { /** Wallet hostname used for bundled and same-site trusted-host matching. */ source?: string | undefined /** Hostnames trusted to render the wallet in an iframe without IO v2. */ trustedHosts?: readonly string[] | undefined } } function trustedHostsFor(source: string | undefined) { if (!source) return undefined const hostname = source.replace(/^www\./, '') for (const [source_, trustedHosts] of Object.entries(TrustedHosts.hosts)) if (TrustedHosts.sameRegistrableDomain(hostname, source_)) return trustedHosts return undefined } /** Mounts the wallet page in a hidden full-viewport overlay iframe. */ export function iframe(): Factory { return Object.assign( (parameters: Factory.Parameters): Mount => { const { host, onDismiss, onInvalidate } = parameters const url = new URL(host) const origin = url.origin const scheme = url.searchParams.get('scheme') const root = document.createElement('dialog') // Distinct marker from the bespoke `Dialog.iframe` (`data-tempo-wallet`) // so the two mount systems never collide in the DOM or in cleanup. root.dataset.tempoWalletPostmessage = '' root.setAttribute('role', 'dialog') root.setAttribute('aria-closed', 'true') root.setAttribute('aria-label', 'Tempo Wallet') root.setAttribute('hidden', 'until-found') Object.assign(root.style, { background: 'transparent', border: '0', outline: '0', padding: '0', position: 'fixed', }) const frame = document.createElement('iframe') frame.dataset.testid = 'tempo-wallet-postmessage' frame.setAttribute( 'allow', [ `publickey-credentials-get ${origin}`, `publickey-credentials-create ${origin}`, 'clipboard-write', 'payment', ].join('; '), ) frame.setAttribute('allowtransparency', 'true') frame.setAttribute('tabindex', '0') frame.setAttribute('title', 'Tempo Wallet') frame.src = host Object.assign(frame.style, { backgroundColor: 'transparent', border: '0', colorScheme: scheme === 'light' || scheme === 'dark' ? scheme : 'light dark', height: '100%', left: '0', position: 'fixed', top: '0', width: '100%', }) const style = document.createElement('style') style.innerHTML = ` dialog[data-tempo-wallet-postmessage]::backdrop { background: transparent!important; } ` root.appendChild(style) root.appendChild(frame) document.body.appendChild(root) // Re-mount if removed (e.g. React hydration clears non-server-rendered // elements). Re-appending reloads the wallet page, so the session must // re-handshake through a fresh handle. const remountObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.removedNodes) { if (node !== root) continue document.body.appendChild(root) onInvalidate() return } } }) remountObserver.observe(document.body, { childList: true }) // 1Password extension adds `inert` to `dialog`, rendering it unusable. const inertObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== 'attributes') continue if (mutation.attributeName !== 'inert') continue root.removeAttribute('inert') } }) inertObserver.observe(root, { attributeOldValue: true, attributes: true }) const onCancel = (event: Event) => { event.preventDefault() onDismiss() } const onClick = () => onDismiss() let opener: HTMLElement | null = null let savedOverflow = '' let visible = false function show() { if (visible) return frame.focus() visible = true if (document.activeElement instanceof HTMLElement) opener = document.activeElement root.removeAttribute('hidden') root.removeAttribute('aria-closed') root.showModal() root.addEventListener('cancel', onCancel) root.addEventListener('click', onClick) root.style.pointerEvents = 'auto' frame.focus() savedOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' } function hide() { if (!visible) return visible = false root.removeEventListener('cancel', onCancel) root.removeEventListener('click', onClick) root.style.pointerEvents = 'none' root.setAttribute('hidden', 'true') root.setAttribute('aria-closed', 'true') root.close() opener?.focus() opener = null document.body.style.overflow = savedOverflow // 1Password sometimes adds `inert` to dialog siblings and does not // clean up when the dialog closes. for (const sibling of root.parentNode ? Array.from(root.parentNode.children) : []) { if (sibling === root) continue if (sibling.hasAttribute('inert')) sibling.removeAttribute('inert') } } return { close() { hide() }, destroy() { hide() remountObserver.disconnect() inertObserver.disconnect() root.remove() }, hide, mode: 'iframe', show, target: () => frame.contentWindow, } }, { mode: 'iframe' as const }, ) } /** Opens the wallet page in a centered popup window per request burst. */ export function popup(options: popup.Options = {}): Factory { const { size = defaultSize } = options return Object.assign( (parameters: Factory.Parameters): Mount => { const { host, onDismiss } = parameters const overlay = document.createElement('div') Object.assign(overlay.style, { alignItems: 'center', background: 'rgba(0, 0, 0, 0.5)', color: 'white', display: 'none', flexDirection: 'column', fontFamily: 'system-ui, sans-serif', fontSize: '16px', gap: '12px', inset: '0', justifyContent: 'center', position: 'fixed', zIndex: '2147483647', }) const message = document.createElement('p') Object.assign(message.style, { margin: '0' }) message.textContent = 'Continue in the popup window' const close = document.createElement('button') Object.assign(close.style, { background: 'none', border: 'none', color: 'white', cursor: 'pointer', font: 'inherit', padding: '0', textDecoration: 'underline', }) close.textContent = 'Close' close.addEventListener('click', () => onDismiss()) overlay.appendChild(message) overlay.appendChild(close) document.body.appendChild(overlay) let win: Window | null = null function hide() { overlay.style.display = 'none' win?.close() win = null } return { close() { hide() }, destroy() { hide() overlay.remove() }, hide, mode: 'popup', show() { if (win && !win.closed) win.focus() }, target: () => { if (win && !win.closed) return win const left = (window.innerWidth - size.width) / 2 + window.screenX const top = (window.innerHeight - size.height) / 2 + window.screenY const features = `width=${size.width},height=${size.height},left=${left},top=${top}` win = window.open(host, '_blank', features) if (win) overlay.style.display = 'flex' return win }, } }, { mode: 'popup' as const }, ) } export declare namespace popup { /** Options for {@link popup}. */ export type Options = { /** Popup window dimensions. @default `{ width: 404, height: 600 }` */ size?: { width: number; height: number } | undefined } }