From 2ec709905e0a68530ead6783fb14533114010251 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 17 Sep 2026 19:17:00 +0000 Subject: [PATCH] paskia-js: profile() dialog, auth-logout message, host color-scheme adoption New profile() function opens the minimal profile in a compact dialog iframe and always resolves ('logout' | 'back'), keeping the auth flow's resolve/reject contract separate and unchanged. The overlay now injects the host page's computed color-scheme into the iframe URL theme param when the server has not provided one. --- paskia-js/src/index.ts | 1 + paskia-js/src/overlay.ts | 78 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/paskia-js/src/index.ts b/paskia-js/src/index.ts index 13d5b16..1614662 100644 --- a/paskia-js/src/index.ts +++ b/paskia-js/src/index.ts @@ -20,6 +20,7 @@ export { isAuthIframeOpen, hideAuthIframe, showAuthIframe, + profile, } from './overlay' export { SessionValidator } from './validate' diff --git a/paskia-js/src/overlay.ts b/paskia-js/src/overlay.ts index 244e345..be53cad 100644 --- a/paskia-js/src/overlay.ts +++ b/paskia-js/src/overlay.ts @@ -32,12 +32,24 @@ body.paskia-backdrop { 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 = 'logout' | 'back' + let authIframe: HTMLIFrameElement | null = null -let authPromise: Promise | null = null -let authResolve: (() => void) | null = null +let authPromise: Promise | 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); the profile dialog resolves 'back' instead. +let cancelAsError = true let messageListenerInstalled = false let backdropHolders = 0 @@ -98,8 +110,20 @@ function handleAuthMessage(event: MessageEvent): void { case 'auth-back': hideAuthIframe() - if (authReject) { + if (cancelAsError && 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 @@ -116,12 +140,15 @@ function ensureMessageListener(): void { } } -export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise { +function openIframe(iframeUrl: string, title: string, dialog: boolean, cancelError: boolean): Promise { injectStyles() ensureMessageListener() if (authPromise) return authPromise + cancelAsError = cancelError + iframeUrl = withAppTheme(iframeUrl) + if (document.getElementById(AUTH_IFRAME_ID)) { authPromise = new Promise((resolve, reject) => { authResolve = resolve @@ -140,6 +167,7 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro authIframe = document.createElement('iframe') authIframe.id = AUTH_IFRAME_ID + if (dialog) authIframe.classList.add('paskia-dialog') authIframe.title = title authIframe.src = iframeUrl document.body.appendChild(authIframe) @@ -147,6 +175,48 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro 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 { + return openIframe(iframeUrl, title, false, true).then(() => undefined) +} + +/** + * Show the minimal profile of the logged-in user in a compact dialog iframe. + * + * Unlike the auth flows, this always resolves — 'logout' when the user + * signed out inside the frame, 'back' when they closed it without action. + * 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 { + return openIframe('/auth/restricted/iframe#mode=profile', 'Profile', true, false) + .then((result) => result ?? 'back') +} + export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement { injectStyles() const existing = document.getElementById(AUTH_IFRAME_ID)