Refactor user-profile, restricted access and reset token registration as separate apps so the frontend does not need to guess which context it is running in.

Support user-navigable URLs at / as well as /auth/, allowing for a dedicated authentication site with pretty URLs.
This commit is contained in:
2025-10-03 03:42:01 +00:00
parent b4871c671f
commit 2f1578c4bc
23 changed files with 668 additions and 295 deletions
+1 -26
View File
@@ -7,8 +7,6 @@
<LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</template>
<!-- Show loading state while determining auth status -->
<div v-else class="loading-container">
@@ -26,21 +24,10 @@ import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore()
const initialized = ref(false)
onMounted(async () => {
// Detect restricted mode:
// We only allow full functionality on the exact /auth/ (or /auth) path.
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
// so the app will only show login or permission denied views.
const path = location.pathname
if (!(path === '/auth/' || path === '/auth')) {
store.setRestrictedMode(true)
}
// Load branding / settings first (non-blocking for auth flow)
await store.loadSettings()
// Was an error message passed in the URL hash?
@@ -49,23 +36,11 @@ onMounted(async () => {
store.showMessage(decodeURIComponent(message), 'error')
history.replaceState(null, '', location.pathname)
}
// Capture reset token from query parameter and then remove it
const params = new URLSearchParams(location.search)
const reset = params.get('reset')
if (reset) {
store.resetToken = reset
// Remove query param to avoid lingering in history / clipboard
const targetPath = '/auth/'
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
}
try {
await store.loadUserInfo()
initialized.value = true
store.selectView()
} catch (error) {
console.log('Failed to load user info:', error)
store.currentView = 'login'
} finally {
initialized.value = true
store.selectView()
}
+2 -2
View File
@@ -330,8 +330,8 @@ const pageHeading = computed(() => {
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: '/auth/' },
{ label: 'Admin', href: '/auth/admin/' }
{ label: 'Auth', href: authStore.uiHref() },
{ label: 'Admin', href: authStore.adminHomeHref() }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
-1
View File
@@ -162,7 +162,6 @@ a:focus-visible {
.view-header h1 {
margin: 0;
font-size: clamp(1.85rem, 2.5vw + 1rem, 2.6rem);
font-weight: 600;
color: var(--color-heading);
}
+1 -7
View File
@@ -32,13 +32,7 @@ const handleLogin = async () => {
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) {
location.reload()
} else if (location.pathname === '/auth/') {
authStore.currentView = 'profile'
} else {
location.reload()
}
authStore.currentView = 'profile'
} catch (error) {
authStore.showMessage(error.message, 'error')
}
@@ -1,94 +0,0 @@
<template>
<div class="dialog-backdrop">
<div class="dialog-container">
<div class="dialog-content dialog-content--wide">
<header class="view-header">
<h1>🚫 Forbidden</h1>
</header>
<section class="section-block">
<div class="section-body">
<div v-if="authStore.userInfo?.authenticated" class="user-header">
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
<span class="user-name">{{ displayName }}</span>
</div>
<p>You lack the permissions required for this page.</p>
<div class="button-row">
<button class="btn-secondary" @click="back">Back</button>
<button class="btn-primary" @click="goAuth">Account</button>
<button class="btn-danger" @click="logout">Logout</button>
</div>
<p class="hint">If you believe this is an error, contact your administrator.</p>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
const displayName = authStore.userInfo?.user?.user_name || 'User'
function goAuth() {
location.href = '/auth/'
}
function back() {
if (history.length > 1) history.back()
else authStore.currentView = 'login'
}
async function logout() {
await authStore.logout()
}
</script>
<style scoped>
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.user-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem;
}
.user-emoji {
font-size: 1.5rem;
line-height: 1;
}
.user-name {
font-weight: 600;
color: var(--color-heading);
}
.button-row {
width: 100%;
justify-content: stretch;
}
.button-row button {
flex: 1 1 0;
}
.hint {
font-size: 0.9rem;
color: var(--color-text-muted);
margin: 0;
}
@media (max-width: 720px) {
.button-row {
flex-direction: column;
}
.button-row button {
width: 100%;
flex: 1 1 auto;
}
}
</style>
+7 -1
View File
@@ -3,7 +3,7 @@
<div class="view-content">
<header class="view-header">
<h1>👋 Welcome!</h1>
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
<Breadcrumbs :entries="breadcrumbEntries" />
<p class="view-lede">Manage your account details and passkeys.</p>
</header>
@@ -144,6 +144,12 @@ const openNameDialog = () => {
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
const breadcrumbEntries = computed(() => {
const entries = [{ label: 'Auth', href: authStore.uiHref() }]
if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() })
return entries
})
const saveName = async () => {
const name = newName.value.trim()
if (!name) {
-94
View File
@@ -1,94 +0,0 @@
<template>
<div class="dialog-backdrop">
<div class="dialog-container">
<div class="dialog-content">
<header class="view-header">
<h1>🔑 Add New Credential</h1>
<p class="view-lede">
Finish setting up your passkey to complete {{ authStore.userInfo?.session_type }}.
</p>
</header>
<section class="section-block">
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="user_name"
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
:disabled="authStore.isLoading"
maxlength="64"
@keyup.enter="register"
/>
</label>
<p>Proceed to complete {{ authStore.userInfo?.session_type }}:</p>
<button
class="btn-primary"
:disabled="authStore.isLoading"
@click="register"
>
{{ authStore.isLoading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import passkey from '@/utils/passkey'
import { ref } from 'vue'
const authStore = useAuthStore()
const user_name = ref('')
async function register() {
authStore.isLoading = true
authStore.showMessage('Starting registration...', 'info')
try {
const result = await passkey.register(authStore.resetToken, user_name.value)
console.log('Result', result)
await authStore.setSessionCookie(result.session_token)
authStore.resetToken = null
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
await authStore.loadUserInfo()
authStore.selectView()
} catch (error) {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
} finally {
authStore.isLoading = false
}
}
</script>
<style scoped>
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.name-edit {
display: flex;
flex-direction: column;
gap: 0.45rem;
font-weight: 600;
}
.name-edit span {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.section-body {
gap: 1.5rem;
}
@media (max-width: 720px) {
button {
width: 100%;
}
}
</style>
+258
View File
@@ -0,0 +1,258 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="view-content">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<h1>🔑 Complete Your Passkey Setup</h1>
<p class="view-lede">
{{ subtitleMessage }}
</p>
</header>
<section class="section-block" v-if="initializing">
<div class="section-body center">
<p>Loading reset details</p>
</div>
</section>
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<p>{{ errorMessage }}</p>
<div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
</section>
<section class="section-block" v-else>
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="displayName"
:placeholder="namePlaceholder"
:disabled="loading"
maxlength="64"
@keyup.enter="registerPasskey"
/>
</label>
<p>Click below to finish {{ sessionDescriptor }}.</p>
<button
class="btn-primary"
:disabled="loading"
@click="registerPasskey"
>
{{ loading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
const status = reactive({
show: false,
message: '',
type: 'info'
})
const initializing = ref(true)
const loading = ref(false)
const token = ref('')
const settings = ref(null)
const userInfo = ref(null)
const displayName = ref('')
const errorMessage = ref('')
let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const namePlaceholder = computed(() => userInfo.value?.user?.user_name || 'Your name')
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This reset link is no longer valid.'
return `Finish setting up a passkey for ${userInfo.value?.user?.user_name || 'your account'}.`
})
const uiBasePath = computed(() => {
const base = settings.value?.ui_base_path || '/auth/'
if (base === '/') return '/'
return base.endsWith('/') ? base : `${base}/`
})
const canRegister = computed(() => !!(token.value && userInfo.value))
function showMessage(message, type = 'info', duration = 3000) {
status.show = true
status.message = message
status.type = type
if (statusTimer) clearTimeout(statusTimer)
if (duration > 0) {
statusTimer = setTimeout(() => {
status.show = false
}, duration)
}
}
async function fetchSettings() {
try {
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
settings.value = data
if (data?.rp_name) {
document.title = `${data.rp_name} · Passkey Setup`
}
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
if (!token.value) return
try {
const res = await fetch(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
method: 'POST'
})
if (!res.ok) {
const payload = await safeParseJson(res)
const detail = payload?.detail || 'Reset link is invalid or expired.'
errorMessage.value = detail
showMessage(detail, 'error', 0)
return
}
userInfo.value = await res.json()
} catch (error) {
console.error('Failed to load user info', error)
const message = 'We could not load your reset details. Try refreshing the page.'
errorMessage.value = message
showMessage(message, 'error', 0)
}
}
async function registerPasskey() {
if (!canRegister.value || loading.value) return
loading.value = true
showMessage('Starting passkey registration…', 'info')
let result
try {
const nameValue = displayName.value.trim() || null
result = await passkey.register(token.value, nameValue)
} catch (error) {
loading.value = false
const message = error?.message || 'Passkey registration cancelled'
const cancelled = message === 'Passkey registration cancelled'
showMessage(cancelled ? message : `Registration failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return
}
try {
await setSessionCookie(result.session_token)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
showMessage('Passkey registered successfully!', 'success', 2000)
setTimeout(() => {
loading.value = false
redirectHome()
}, 800)
}
async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${sessionToken}`
}
})
const payload = await safeParseJson(response)
if (!response.ok || payload?.detail) {
const detail = payload?.detail || 'Session could not be established.'
throw new Error(detail)
}
return payload
}
function redirectHome() {
const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) {
history.replaceState(null, '', target)
}
window.location.reload()
}
function goHome() {
redirectHome()
}
function extractTokenFromPath() {
const segments = window.location.pathname.split('/').filter(Boolean)
if (!segments.length) return ''
const candidate = segments[segments.length - 1]
const prefix = segments.slice(0, -1)
if (prefix.length > 1) return ''
if (prefix.length === 1 && prefix[0] !== 'auth') return ''
if (!candidate.includes('.')) return ''
return candidate
}
async function safeParseJson(response) {
try {
return await response.json()
} catch (error) {
return null
}
}
onMounted(async () => {
token.value = extractTokenFromPath()
await fetchSettings()
if (!token.value) {
const message = 'Reset link is missing or malformed.'
errorMessage.value = message
showMessage(message, 'error', 0)
initializing.value = false
return
}
await fetchUserInfo()
initializing.value = false
})
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
display: flex;
justify-content: center;
}
.section-body {
gap: 1.25rem;
}
.name-edit span {
color: var(--color-text-muted);
font-size: 0.9rem;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import ResetApp from './ResetApp.vue'
import '@/assets/style.css'
createApp(ResetApp).mount('#app')
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="view-content">
<div class="surface surface--tight" style="max-width: 520px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<h1>🚫 Access Restricted</h1>
<p class="view-lede">{{ headerMessage }}</p>
</header>
<section class="section-block" v-if="initializing">
<div class="section-body center">
<p>Checking your session</p>
</div>
</section>
<section class="section-block" v-else>
<div class="section-body center" style="gap: 1.75rem;">
<p>{{ detailText }}</p>
<div class="button-row center" style="justify-content: center;">
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? 'Signing in' : 'Sign in with Passkey' }}
</button>
<button class="btn-secondary" :disabled="loading" @click="returnHome">
Go back to Auth Home
</button>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
const status = reactive({
show: false,
message: '',
type: 'info'
})
const initializing = ref(true)
const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
const fallbackDetail = ref('')
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value)
const uiBasePath = computed(() => {
const base = settings.value?.ui_base_path || '/auth/'
if (base === '/') return '/'
return base.endsWith('/') ? base : `${base}/`
})
const headerMessage = computed(() => {
if (initializing.value) return 'Checking your access permissions…'
if (isAuthenticated.value) {
return 'Your account is signed in, but this resource needs extra permissions.'
}
return 'Sign in to continue to the requested resource.'
})
const detailText = computed(() => {
if (isAuthenticated.value) {
return fallbackDetail.value || 'You do not have the required permissions to view this page.'
}
return fallbackDetail.value || 'Use your registered passkey to sign in securely.'
})
function showMessage(message, type = 'info', duration = 3000) {
status.show = true
status.message = message
status.type = type
if (statusTimer) clearTimeout(statusTimer)
if (duration > 0) {
statusTimer = setTimeout(() => {
status.show = false
}, duration)
}
}
async function fetchSettings() {
try {
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
settings.value = data
if (data?.rp_name) {
document.title = `${data.rp_name} · Access Restricted`
}
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
try {
const res = await fetch('/auth/api/user-info', { method: 'POST' })
if (!res.ok) {
const payload = await safeParseJson(res)
fallbackDetail.value = payload?.detail || 'Please sign in to continue.'
return
}
userInfo.value = await res.json()
} catch (error) {
console.error('Failed to load user info', error)
fallbackDetail.value = 'We were unable to verify your session. Try again shortly.'
}
}
async function authenticateUser() {
if (!canAuthenticate.value || loading.value) return
loading.value = true
showMessage('Starting authentication…', 'info')
let result
try {
result = await passkey.authenticate()
} catch (error) {
loading.value = false
const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled'
showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return
}
try {
await setSessionCookie(result.session_token)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
showMessage('Signed in successfully!', 'success', 2000)
setTimeout(() => {
loading.value = false
window.location.reload()
}, 800)
}
async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${sessionToken}`
}
})
const payload = await safeParseJson(response)
if (!response.ok || payload?.detail) {
const detail = payload?.detail || 'Session could not be established.'
throw new Error(detail)
}
return payload
}
function returnHome() {
const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) {
history.replaceState(null, '', target)
}
window.location.href = target
}
async function safeParseJson(response) {
try {
return await response.json()
} catch (error) {
return null
}
}
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
if (!canAuthenticate.value && !isAuthenticated.value && !fallbackDetail.value) {
fallbackDetail.value = 'Please try signing in again.'
}
initializing.value = false
})
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
display: flex;
justify-content: center;
gap: 0.75rem;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import RestrictedApp from './RestrictedApp.vue'
import '@/assets/style.css'
createApp(RestrictedApp).mount('#app')
+28 -21
View File
@@ -7,8 +7,6 @@ export const useAuthStore = defineStore('auth', {
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
settings: null, // Server provided settings (/auth/settings)
isLoading: false,
resetToken: null, // transient reset token
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
// UI State
currentView: 'login',
@@ -18,7 +16,21 @@ export const useAuthStore = defineStore('auth', {
show: false
},
}),
getters: {
uiBasePath(state) {
const configured = state.settings?.ui_base_path || '/auth/'
if (!configured.endsWith('/')) return `${configured}/`
return configured
},
adminUiPath() {
const base = this.uiBasePath
return base === '/' ? '/admin/' : `${base}admin/`
},
},
actions: {
setLoading(flag) {
this.isLoading = !!flag
},
showMessage(message, type = 'info', duration = 3000) {
this.status = {
message,
@@ -31,8 +43,17 @@ export const useAuthStore = defineStore('auth', {
}, duration)
}
},
uiHref(suffix = '') {
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
if (!trimmed) return this.uiBasePath
if (this.uiBasePath === '/') return `/${trimmed}`
return `${this.uiBasePath}${trimmed}`
},
adminHomeHref() {
return this.adminUiPath
},
async setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${sessionToken}`},
})
@@ -40,9 +61,6 @@ export const useAuthStore = defineStore('auth', {
if (result.detail) {
throw new Error(result.detail)
}
// On successful session establishment, discard any reset token to avoid
// sending stale Authorization headers on subsequent API calls.
this.resetToken = null
return result
},
async register() {
@@ -51,6 +69,7 @@ export const useAuthStore = defineStore('auth', {
const result = await register()
await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
this.selectView()
return result
} finally {
this.isLoading = false
@@ -63,6 +82,7 @@ export const useAuthStore = defineStore('auth', {
await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
this.selectView()
return result
} finally {
@@ -70,25 +90,12 @@ export const useAuthStore = defineStore('auth', {
}
},
selectView() {
if (this.restrictedMode) {
// In restricted mode only allow login or show permission denied if already authenticated
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
return
}
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile'
else this.currentView = 'reset'
},
setRestrictedMode(flag) {
this.restrictedMode = !!flag
else this.currentView = 'login'
},
async loadUserInfo() {
const headers = {}
// Reset tokens are only passed via query param now, not Authorization header
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
const response = await fetch(url, { method: 'POST', headers })
const response = await fetch('/auth/api/user-info', { method: 'POST' })
let result = null
try {
result = await response.json()