accounts
Version:
Tempo Accounts SDK
331 lines (301 loc) • 11 kB
text/typescript
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
}
}