Improved auth profile UX, consistent transparent-blur dialog background everywhere.
This commit is contained in:
+2
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user