Simplified My Profile authentication flows, fixed some UX issues with reauth cancelled/accepted leading to incorrect states.
This commit is contained in:
+19
-72
@@ -13,8 +13,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
|
import { apiJson, SessionValidator } from 'paskia'
|
||||||
import { getAuthIframeUrl } from '@/utils/api'
|
|
||||||
import { updateThemeFromSession } from '@/utils/theme'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import ProfileView from '@/components/ProfileView.vue'
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
@@ -49,19 +48,29 @@ const isHostMode = computed(() => {
|
|||||||
return currentHost !== configuredHost
|
return currentHost !== configuredHost
|
||||||
})
|
})
|
||||||
|
|
||||||
function terminateSession() {
|
function onSessionLost(e) {
|
||||||
store.userInfo = null
|
store.userInfo = null
|
||||||
viewState.value = 'terminal'
|
store.ctx = null
|
||||||
|
if (e?.name === 'AuthCancelledError') {
|
||||||
|
viewState.value = 'terminal'
|
||||||
|
} else {
|
||||||
|
store.showMessage(e?.message || 'Session lost', 'error', 5000)
|
||||||
|
viewState.value = 'terminal'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userUuidGetter = () => store.ctx?.user.uuid
|
const userUuidGetter = () => store.ctx?.user.uuid
|
||||||
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
|
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||||
|
|
||||||
onMounted(() => sessionValidator.start())
|
onMounted(() => sessionValidator.start())
|
||||||
onUnmounted(() => sessionValidator.stop())
|
onUnmounted(() => sessionValidator.stop())
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
|
viewState.value = 'loading'
|
||||||
|
loadingMessage.value = 'Loading...'
|
||||||
try {
|
try {
|
||||||
|
// apiJson handles 401/403 with auth.iframe automatically:
|
||||||
|
// shows overlay iframe, waits for auth, retries the request.
|
||||||
const [validateData, userInfoData] = await Promise.all([
|
const [validateData, userInfoData] = await Promise.all([
|
||||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
@@ -73,67 +82,15 @@ async function loadUserInfo() {
|
|||||||
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||||
console.error('User UUID mismatch between user-info and validate responses')
|
console.error('User UUID mismatch between user-info and validate responses')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
viewState.value = 'profile'
|
viewState.value = 'profile'
|
||||||
return true
|
} catch (e) {
|
||||||
} catch {
|
onSessionLost(e)
|
||||||
store.userInfo = null
|
|
||||||
store.ctx = null
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showAuthIframe() {
|
|
||||||
const url = await getAuthIframeUrl('login')
|
|
||||||
createAuthIframe(url)
|
|
||||||
loadingMessage.value = 'Authentication required...'
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAuthMessage(event) {
|
|
||||||
const data = event.data
|
|
||||||
if (!data?.type) return
|
|
||||||
|
|
||||||
switch (data.type) {
|
|
||||||
case 'auth-success':
|
|
||||||
// Authentication successful - reload user info
|
|
||||||
removeAuthIframe()
|
|
||||||
viewState.value = 'loading'
|
|
||||||
loadingMessage.value = 'Loading user profile...'
|
|
||||||
loadUserInfo()
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-error':
|
|
||||||
// Authentication failed - keep iframe open so user can retry
|
|
||||||
if (data.cancelled) {
|
|
||||||
console.log('Authentication cancelled by user')
|
|
||||||
} else {
|
|
||||||
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-cancelled':
|
|
||||||
// Legacy support - treat as auth-error with cancelled flag
|
|
||||||
console.log('Authentication cancelled')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-back':
|
|
||||||
// User clicked Back - show terminal state
|
|
||||||
removeAuthIframe()
|
|
||||||
terminateSession()
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'auth-close-request':
|
|
||||||
// Legacy support - treat as back
|
|
||||||
removeAuthIframe()
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Listen for postMessage from auth iframe
|
|
||||||
window.addEventListener('message', handleAuthMessage)
|
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await store.loadSettings()
|
await store.loadSettings()
|
||||||
|
|
||||||
@@ -147,17 +104,7 @@ onMounted(async () => {
|
|||||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load user info
|
// Load user info (apiJson handles auth iframe if needed)
|
||||||
const success = await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// Need authentication - show login iframe
|
|
||||||
showAuthIframe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('message', handleAuthMessage)
|
|
||||||
removeAuthIframe()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||||
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -90,7 +90,9 @@ async function generateLink() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
if (!(e instanceof AuthCancelledError)) {
|
||||||
|
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// Cache for auth iframe URL by mode
|
|
||||||
const authIframeUrlCache = {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the auth iframe URL for a given mode.
|
|
||||||
* Fetches from /auth/api/forward which returns URL in the auth.iframe field.
|
|
||||||
* Results are cached per mode.
|
|
||||||
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
|
|
||||||
* @returns {Promise<string>} - The URL for the iframe
|
|
||||||
*/
|
|
||||||
export async function getAuthIframeUrl(mode = 'login') {
|
|
||||||
if (authIframeUrlCache[mode]) {
|
|
||||||
return authIframeUrlCache[mode]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
|
|
||||||
const response = await fetch('/auth/api/forward')
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.auth?.iframe) {
|
|
||||||
// The iframe field now contains a URL with hash fragment
|
|
||||||
// If mode differs, update the hash param
|
|
||||||
let url = data.auth.iframe
|
|
||||||
if (mode !== data.auth.mode) {
|
|
||||||
url = url.replace(/mode=[^&]*/, `mode=${mode}`)
|
|
||||||
}
|
|
||||||
authIframeUrlCache[mode] = url
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Unable to fetch auth iframe URL')
|
|
||||||
}
|
|
||||||
@@ -18,8 +18,6 @@ export {
|
|||||||
isAuthIframeOpen,
|
isAuthIframeOpen,
|
||||||
hideAuthIframe,
|
hideAuthIframe,
|
||||||
showAuthIframe,
|
showAuthIframe,
|
||||||
createAuthIframe,
|
|
||||||
removeAuthIframe,
|
|
||||||
} from './overlay'
|
} from './overlay'
|
||||||
|
|
||||||
export { SessionValidator } from './validate'
|
export { SessionValidator } from './validate'
|
||||||
|
|||||||
@@ -214,7 +214,12 @@ async def api_user_info(
|
|||||||
)
|
)
|
||||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(401, "Session expired")
|
raise authz.AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Session expired",
|
||||||
|
mode="login",
|
||||||
|
clear_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
await userinfo.build_user_info(
|
await userinfo.build_user_info(
|
||||||
|
|||||||
Reference in New Issue
Block a user