Files
paskia/paskia-js/src/overlay.ts
T
LeoVasanko c5efa03908 profile(): resolve 'login' when login flow completes inside the dialog
The overlay now tracks which dialog kind is open: auth-success from a
profile dialog resolves 'login', auth-back only rejects
AuthCancelledError for auth dialogs.
2026-09-18 00:27:21 +00:00

242 lines
7.0 KiB
TypeScript

const AUTH_IFRAME_ID = 'paskia-iframe'
const STYLES_ID = 'paskia-dialog'
const STYLES_TEXT = `\
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#${AUTH_IFRAME_ID} {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
#${AUTH_IFRAME_ID}.paskia-dialog {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(36rem, 100%);
height: min(42rem, 100%);
}
`
type DialogResult = 'login' | 'logout' | 'back'
type DialogKind = 'auth' | 'profile'
let authIframe: HTMLIFrameElement | null = null
let authPromise: Promise<DialogResult | undefined> | null = null
let authResolve: ((result?: DialogResult) => void) | null = null
let authReject: ((error: Error) => void) | null = null
// Auth flows reject AuthCancelledError on auth-back (callers rely on it to
// abort request retries) and resolve void on auth-success. The profile dialog
// never rejects: auth-back resolves 'back', and auth-success (the user logged
// in while the profile dialog was open) resolves 'login'.
let dialogKind: DialogKind = 'auth'
let messageListenerInstalled = false
let backdropHolders = 0
function injectStyles(): void {
if (document.getElementById(STYLES_ID)) return
const style = document.createElement('style')
style.id = STYLES_ID
style.textContent = STYLES_TEXT
document.head.insertBefore(style, document.head.firstChild)
}
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
export function holdGlobalBackdrop(): void {
injectStyles()
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
export function releaseGlobalBackdrop(): void {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('paskia-backdrop')
}
}
export function isAuthIframeOpen(): boolean {
return !!document.getElementById(AUTH_IFRAME_ID)
}
export function hideAuthIframe(): void {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}
function handleAuthMessage(event: MessageEvent): void {
const data = event.data as { type?: string }
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve(dialogKind === 'profile' ? 'login' : undefined)
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-back':
hideAuthIframe()
if (dialogKind === 'auth' && authReject) {
authReject(new AuthCancelledError())
} else if (authResolve) {
authResolve('back')
}
authPromise = null
authResolve = null
authReject = null
break
case 'auth-logout':
hideAuthIframe()
if (authResolve) {
authResolve('logout')
authPromise = null
authResolve = null
authReject = null
}
break
}
}
function ensureMessageListener(): void {
if (messageListenerInstalled) return
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
messageListenerInstalled = true
}
}
function openIframe(iframeUrl: string, title: string, kind: DialogKind): Promise<DialogResult | undefined> {
injectStyles()
ensureMessageListener()
if (authPromise) return authPromise
dialogKind = kind
iframeUrl = withAppTheme(iframeUrl)
if (document.getElementById(AUTH_IFRAME_ID)) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
hideAuthIframe()
holdGlobalBackdrop()
authIframe = document.createElement('iframe')
authIframe.id = AUTH_IFRAME_ID
if (kind === 'profile') authIframe.classList.add('paskia-dialog')
authIframe.title = title
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
// Detect the host page's own color scheme (CSS color-scheme on body) as an
// implicit app-level default. Only an unambiguous 'light' or 'dark' counts;
// 'normal', 'light dark' etc. mean the page adapts, so no override is needed.
function detectColorScheme(): string {
if (typeof window === 'undefined' || !document.body) return ''
const scheme = getComputedStyle(document.body).colorScheme
return scheme === 'light' || scheme === 'dark' ? scheme : ''
}
// Apply the host page's own color scheme to the iframe URL hash — only when
// the URL has no theme parameter yet (a server-provided user theme override
// is authoritative). The restricted UI's precedence is: URL parameter (user
// override from the server, else host color scheme) > cached profile theme
// (localStorage) > browser/desktop default.
function withAppTheme(iframeUrl: string): string {
const theme = detectColorScheme()
if (!theme) return iframeUrl
const hashIndex = iframeUrl.indexOf('#')
const base = hashIndex === -1 ? iframeUrl : iframeUrl.slice(0, hashIndex)
const params = new URLSearchParams(hashIndex === -1 ? '' : iframeUrl.slice(hashIndex + 1))
if (params.has('theme')) return iframeUrl
params.set('theme', theme)
return `${base}#${params}`
}
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
return openIframe(iframeUrl, title, 'auth').then(() => undefined)
}
/**
* Show the minimal profile of the logged-in user in a compact dialog iframe.
*
* Unlike the auth flows, this always resolves — 'login' when the user was
* signed out and completed the login flow inside the frame, 'logout' when
* they signed out inside the frame, 'back' when they closed it otherwise.
* The caller decides from context how to react to each (e.g. whether to
* start a new login attempt with showAuthIframe).
*/
export function profile(): Promise<DialogResult> {
return openIframe('/auth/restricted/iframe#mode=profile', 'Profile', 'profile')
.then((result) => result ?? 'back')
}
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID)
if (existing) existing.remove()
const iframe = document.createElement('iframe')
iframe.id = AUTH_IFRAME_ID
iframe.title = title
iframe.src = iframeUrl
document.body.appendChild(iframe)
return iframe
}
export function removeAuthIframe(): void {
const iframe = document.getElementById(AUTH_IFRAME_ID)
if (iframe) iframe.remove()
}