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.
This commit is contained in:
2026-09-17 19:17:00 +00:00
parent 6eb862278f
commit 2ec709905e
2 changed files with 75 additions and 4 deletions
+1
View File
@@ -20,6 +20,7 @@ export {
isAuthIframeOpen,
hideAuthIframe,
showAuthIframe,
profile,
} from './overlay'
export { SessionValidator } from './validate'
+74 -4
View File
@@ -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<void> | null = null
let authResolve: (() => void) | 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); 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<void> {
function openIframe(iframeUrl: string, title: string, dialog: boolean, cancelError: boolean): Promise<DialogResult | undefined> {
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<void> {
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<DialogResult> {
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)