Improved auth profile UX, consistent transparent-blur dialog background everywhere.

This commit is contained in:
Leo Vasanko
2025-12-03 12:30:23 -06:00
parent ad374f5dda
commit ceb99de738
9 changed files with 126 additions and 56 deletions
+2 -2
View File
@@ -16,6 +16,6 @@ body:has(#auth-iframe) {
z-index: 9999; z-index: 9999;
color-scheme: auto; color-scheme: auto;
background: transparent; background: transparent;
backdrop-filter: blur(4px) brightness(0.7); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(4px) brightness(0.7); -webkit-backdrop-filter: blur(.1rem) brightness(0.7);
} }
+8 -6
View File
@@ -389,8 +389,9 @@ th {
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: #1e293b; background: transparent;
backdrop-filter: blur(6px); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
z-index: 1100; z-index: 1100;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -670,8 +671,9 @@ th {
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: #334155; background: transparent;
backdrop-filter: blur(4px); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -724,6 +726,6 @@ body:has(#auth-iframe) {
z-index: 9999; z-index: 9999;
color-scheme: auto; color-scheme: auto;
background: transparent; background: transparent;
backdrop-filter: blur(4px) brightness(0.7); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(4px) brightness(0.7); -webkit-backdrop-filter: blur(.1rem) brightness(0.7);
} }
+3 -2
View File
@@ -17,8 +17,9 @@ defineEmits(['close'])
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: #334155; background: transparent;
backdrop-filter: blur(.1rem); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+4 -4
View File
@@ -120,15 +120,15 @@ onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value
const addNewCredential = async () => { const addNewCredential = async () => {
try { try {
authStore.isLoading = true await passkey.register(null, null, () => {
authStore.showMessage('Adding new passkey...', 'info') authStore.showMessage('Adding new passkey...', 'info')
await passkey.register() })
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('New passkey added successfully!', 'success', 3000) authStore.showMessage('New passkey added successfully!', 'success', 3000)
} catch (error) { } catch (error) {
console.error('Failed to add new passkey:', error) console.error('Failed to add new passkey:', error)
authStore.showMessage(error.message, 'error') authStore.showMessage(error.message, 'error')
} finally { authStore.isLoading = false } }
} }
const handleDelete = async (credential) => { const handleDelete = async (credential) => {
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="!inline" class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> <div v-if="!inline && url" class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row"> <div class="reg-header-row">
<h2 id="regTitle" class="reg-title"> <h2 id="regTitle" class="reg-title">
@@ -9,13 +9,10 @@
</div> </div>
<div class="device-link-section"> <div class="device-link-section">
<div class="qr-container"> <div class="qr-container">
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link"> <a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas> <canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p> <p>{{ displayUrl }}</p>
</a> </a>
<div v-else>
<em>Generating link...</em>
</div>
<p class="reg-help"> <p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span> <span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open or scan this link on the device you wish to register to your account.</span> <span v-else>Open or scan this link on the device you wish to register to your account.</span>
@@ -25,11 +22,11 @@
</div> </div>
<div class="reg-actions"> <div class="reg-actions">
<button class="btn-secondary" @click="$emit('close')">Close</button> <button class="btn-secondary" @click="$emit('close')">Close</button>
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> <button class="btn-primary" @click="copy">Copy Link</button>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="registration-inline-wrapper"> <div v-else-if="inline && url" class="registration-inline-wrapper">
<div class="registration-inline-block section-block"> <div class="registration-inline-block section-block">
<div class="section-header"> <div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2> <h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
@@ -37,13 +34,10 @@
<div class="section-body"> <div class="section-body">
<div class="device-link-section"> <div class="device-link-section">
<div class="qr-container"> <div class="qr-container">
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link"> <a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas> <canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p> <p>{{ displayUrl }}</p>
</a> </a>
<div v-else>
<em>Generating link...</em>
</div>
<p class="reg-help"> <p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span> <span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open this link on the device you wish to connect with.</span> <span v-else>Open this link on the device you wish to connect with.</span>
@@ -52,7 +46,7 @@
</div> </div>
</div> </div>
<div class="button-row" style="margin-top:1rem;"> <div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> <button class="btn-primary" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button> <button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div> </div>
</div> </div>
@@ -103,9 +97,9 @@ async function fetchLink() {
drawQR() drawQR()
if (props.autoCopy) copy() if (props.autoCopy) copy()
} catch (e) { } catch (e) {
url.value = null
expires.value = null
console.error('Failed to create link', e) console.error('Failed to create link', e)
// Close the dialog on any error (auth cancelled, network error, etc.)
emit('close')
} }
} }
+27 -2
View File
@@ -35,15 +35,35 @@ let authPromise = null
let authResolve = null let authResolve = null
let authReject = null let authReject = null
/**
* Check if an auth iframe is already open (from any source).
* @returns {boolean}
*/
export function isAuthIframeOpen() {
return !!document.getElementById('auth-iframe')
}
/** /**
* Show the authentication iframe and return a promise that resolves on success. * Show the authentication iframe and return a promise that resolves on success.
* If an auth iframe is already open (from any source), hooks into its completion.
* @param {string} iframeSrc - The URL for the iframe src * @param {string} iframeSrc - The URL for the iframe src
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/ */
function showAuthIframe(iframeSrc) { export function showAuthIframe(iframeSrc) {
// If already showing auth, return existing promise // If we already have a promise (from us), return it
if (authPromise) return authPromise if (authPromise) return authPromise
// If there's already an iframe in the DOM (from App.vue or elsewhere),
// create a promise that hooks into the message handler
if (document.getElementById('auth-iframe')) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => { authPromise = new Promise((resolve, reject) => {
authResolve = resolve authResolve = resolve
authReject = reject authReject = reject
@@ -144,6 +164,11 @@ export async function apiFetch(url, options = {}) {
} }
if (authInfo?.iframe) { if (authInfo?.iframe) {
// If an auth iframe is already open (from app or another request), don't open another
// Just return the response so the caller can handle it
if (isAuthIframeOpen()) {
return response
}
// Show auth iframe and wait for success (throws AuthCancelledError on cancel) // Show auth iframe and wait for success (throws AuthCancelledError on cancel)
await showAuthIframe(authInfo.iframe) await showAuthIframe(authInfo.iframe)
// Loop to retry the original request // Loop to retry the original request
+7 -3
View File
@@ -58,10 +58,14 @@ class AwaitableWebSocket extends WebSocket {
console.error("Failed to parse JSON from WebSocket message", data, err) console.error("Failed to parse JSON from WebSocket message", data, err)
throw new Error("Failed to parse JSON from WebSocket message") throw new Error("Failed to parse JSON from WebSocket message")
} }
if (parsed.detail) { // Wrap in response-like object with ok based on status field
throw new Error(parsed.detail) // Status 2xx = ok, 4xx/5xx = not ok, no status = ok (normal response)
const status = parsed.status || 200
return {
ok: status >= 200 && status < 300,
status,
data: parsed,
} }
return parsed
} }
send_json(data) { send_json(data) {
+48 -16
View File
@@ -1,6 +1,7 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { showAuthIframe } from '@/utils/api'
// Generic path normalizer: if an auth_host is configured and differs from current // Generic path normalizer: if an auth_host is configured and differs from current
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is. // host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
@@ -10,34 +11,65 @@ async function makeUrl(path) {
return h && location.host !== h ? `//${h}${path}` : path return h && location.host !== h ? `//${h}${path}` : path
} }
export async function register(resetToken = null, displayName = null) { export async function register(resetToken = null, displayName = null, onstartreg = null) {
let params = [] let params = []
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
const qs = params.length ? `?${params.join('&')}` : '' const qs = params.length ? `?${params.join('&')}` : ''
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
try { while (true) {
const optionsJSON = await ws.receive_json() const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
const registrationResponse = await startRegistration({ optionsJSON }) try {
ws.send_json(registrationResponse) const res = await ws.receive_json()
return await ws.receive_json()
} catch (error) { // Handle auth errors (401/403) with iframe
console.error('Registration error:', error) if ((res.status === 401 || res.status === 403) && res.data.auth?.iframe) {
// Replace useless and ugly error message from startRegistration ws.close()
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message) await showAuthIframe(res.data.auth.iframe)
} finally { continue
ws.close() }
// Handle other errors
if (!res.ok) {
throw new Error(res.data.detail || `Registration failed: ${res.status}`)
}
// Notify caller that we're about to show the browser prompt
if (onstartreg) onstartreg()
const registrationResponse = await startRegistration({ optionsJSON: res.data })
ws.send_json(registrationResponse)
const result = await ws.receive_json()
if (!result.ok) {
throw new Error(result.data.detail || `Registration failed: ${result.status}`)
}
return result.data
} catch (error) {
ws.close()
console.error('Registration error:', error)
// Replace useless and ugly error message from startRegistration
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message)
}
} }
} }
export async function authenticate() { export async function authenticate() {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
try { try {
const optionsJSON = await ws.receive_json() const res = await ws.receive_json()
const authResponse = await startAuthentication({ optionsJSON }) if (!res.ok) {
throw new Error(res.data.detail || `Authentication failed: ${res.status}`)
}
const authResponse = await startAuthentication({ optionsJSON: res.data })
ws.send_json(authResponse) ws.send_json(authResponse)
const result = await ws.receive_json() const result = await ws.receive_json()
return result if (!result.ok) {
throw new Error(result.data.detail || `Authentication failed: ${result.status}`)
}
return result.data
} catch (error) { } catch (error) {
console.error('Authentication error:', error) console.error('Authentication error:', error)
throw Error(error.name === "NotAllowedError" ? 'Passkey authentication cancelled' : error.message) throw Error(error.name === "NotAllowedError" ? 'Passkey authentication cancelled' : error.message)
+19 -7
View File
@@ -9,6 +9,7 @@ from ..authsession import create_session, get_reset, get_session
from ..globals import db, passkey from ..globals import db, passkey
from ..util import passphrase from ..util import passphrase
from ..util.tokens import create_token, session_key from ..util.tokens import create_token, session_key
from . import authz
from .session import AUTH_COOKIE, infodict from .session import AUTH_COOKIE, infodict
@@ -21,6 +22,18 @@ def websocket_error_handler(func):
return await func(ws, *args, **kwargs) return await func(ws, *args, **kwargs)
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except authz.AuthException as e:
await ws.send_json(
{
"status": e.status_code,
"detail": e.detail,
"auth": {
"mode": e.mode,
"iframe": f"/auth/restricted/?mode={e.mode}",
**e.metadata,
},
}
)
except (ValueError, InvalidAuthenticationResponse) as e: except (ValueError, InvalidAuthenticationResponse) as e:
await ws.send_json({"detail": str(e)}) await ws.send_json({"detail": str(e)})
except Exception: except Exception:
@@ -64,7 +77,7 @@ async def websocket_register_add(
"""Register a new credential for an existing user. """Register a new credential for an existing user.
Supports either: Supports either:
- Normal session via auth cookie - Normal session via auth cookie (requires recent authentication)
- Reset token supplied as ?reset=... (auth cookie ignored) - Reset token supplied as ?reset=... (auth cookie ignored)
""" """
origin = ws.headers["origin"] origin = ws.headers["origin"]
@@ -75,13 +88,12 @@ async def websocket_register_add(
f"The reset link for {passkey.instance.rp_name} is invalid or has expired" f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
) )
s = await get_reset(reset) s = await get_reset(reset)
user_uuid = s.user_uuid
else: else:
if not auth: # Require recent authentication for adding a new passkey
raise ValueError( ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
f"You must be signed in to {passkey.instance.rp_name} to add a new passkey" user_uuid = ctx.session.user_uuid
) s = ctx.session
s = await get_session(auth, host=host)
user_uuid = s.user_uuid
# Get user information and determine effective user_name for this registration # Get user information and determine effective user_name for this registration
user = await db.instance.get_user_by_uuid(user_uuid) user = await db.instance.get_user_by_uuid(user_uuid)