Implement Forbidden view for API calls, cleanup and better UX.

This commit is contained in:
2025-12-02 17:36:37 +00:00
parent 5422845192
commit 2699aaa472
5 changed files with 50 additions and 74 deletions
+2 -1
View File
@@ -15,7 +15,7 @@ import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import LoadingView from '@/components/LoadingView.vue' import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AuthRequiredMessage.vue' import AuthRequiredMessage from '@/components/AccessDenied.vue'
const store = useAuthStore() const store = useAuthStore()
const loading = ref(true) const loading = ref(true)
@@ -106,6 +106,7 @@ function handleAuthMessage(event) {
hideAuthIframe() hideAuthIframe()
loading.value = false loading.value = false
showBackMessage.value = true showBackMessage.value = true
store.showMessage('Authentication cancelled', 'info', 3000)
break break
case 'auth-close-request': case 'auth-close-request':
+13 -12
View File
@@ -6,7 +6,7 @@ import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import LoadingView from '@/components/LoadingView.vue' import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AuthRequiredMessage.vue' import AuthRequiredMessage from '@/components/AccessDenied.vue'
import AdminOverview from './AdminOverview.vue' import AdminOverview from './AdminOverview.vue'
import AdminOrgDetail from './AdminOrgDetail.vue' import AdminOrgDetail from './AdminOrgDetail.vue'
import AdminUserDetail from './AdminUserDetail.vue' import AdminUserDetail from './AdminUserDetail.vue'
@@ -192,6 +192,15 @@ async function load() {
if (data.detail) throw new Error(data.detail) if (data.detail) throw new Error(data.detail)
info.value = data info.value = data
authenticated.value = true authenticated.value = true
// Check if user has required permissions
if (data.authenticated && !(data.is_global_admin || data.is_org_admin)) {
// User is authenticated but lacks required permissions - show auth iframe
authStore.authRequired = true
loading.value = true
return
}
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) { if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
await Promise.all([loadOrgs(), loadPermissions()]) await Promise.all([loadOrgs(), loadPermissions()])
} }
@@ -360,6 +369,7 @@ function handleAuthMessage(event) {
hideAuthIframe() hideAuthIframe()
loading.value = false loading.value = false
showBackMessage.value = true showBackMessage.value = true
authStore.showMessage('Authentication cancelled', 'info', 3000)
break break
case 'auth-close-request': case 'auth-close-request':
@@ -576,10 +586,9 @@ async function submitDialog() {
<LoadingView v-if="loading" :message="loadingMessage" /> <LoadingView v-if="loading" :message="loadingMessage" />
<AuthRequiredMessage <AuthRequiredMessage
v-else-if="showBackMessage" v-else-if="showBackMessage"
message="You need to authenticate to access the admin panel."
@reload="reloadPage" @reload="reloadPage"
/> />
<section v-else class="view-root view-root--wide view-admin"> <section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
<header class="view-header"> <header class="view-header">
<h1>{{ pageHeading }}</h1> <h1>{{ pageHeading }}</h1>
<Breadcrumbs :entries="breadcrumbEntries" /> <Breadcrumbs :entries="breadcrumbEntries" />
@@ -588,14 +597,7 @@ async function submitDialog() {
<section class="section-block admin-section"> <section class="section-block admin-section">
<div class="section-body admin-section-body"> <div class="section-body admin-section-body">
<div v-if="error" class="surface surface--tight error">{{ error }}</div> <div v-if="error" class="surface surface--tight error">{{ error }}</div>
<template v-else> <div v-else class="admin-panels">
<div v-if="!info?.authenticated" class="surface surface--tight">
<p>You must be authenticated.</p>
</div>
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight">
<p>Insufficient permissions.</p>
</div>
<div v-else class="admin-panels">
<AdminOverview <AdminOverview
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
:info="info" :info="info"
@@ -643,7 +645,6 @@ async function submitDialog() {
/> />
</div> </div>
</template>
</div> </div>
</section> </section>
</section> </section>
@@ -1,8 +1,7 @@
<template> <template>
<div class="message-container"> <div class="message-container">
<div class="message-content"> <div class="message-content">
<h2>🔒 Authentication Required</h2> <h2>🔒 Access Denied</h2>
<p>{{ message }}</p>
<div class="button-row"> <div class="button-row">
<button class="btn-primary" @click="$emit('reload')">Reload Page</button> <button class="btn-primary" @click="$emit('reload')">Reload Page</button>
</div> </div>
@@ -11,13 +10,6 @@
</template> </template>
<script setup> <script setup>
defineProps({
message: {
type: String,
default: 'You need to authenticate to access this page.'
}
})
defineEmits(['reload']) defineEmits(['reload'])
</script> </script>
@@ -37,13 +29,8 @@ defineEmits(['reload'])
} }
.message-content h2 { .message-content h2 {
margin: 0 0 1rem;
color: var(--color-heading);
}
.message-content p {
color: var(--color-text-muted);
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-heading);
} }
.message-content .button-row { .message-content .button-row {
+33 -12
View File
@@ -30,7 +30,7 @@
{{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }} {{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }}
</button> </button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button> <button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="$emit('home')">Profile</button> <button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
</slot> </slot>
</div> </div>
</div> </div>
@@ -60,6 +60,7 @@ const initializing = ref(true)
const loading = ref(false) const loading = ref(false)
const settings = ref(null) const settings = ref(null)
const userInfo = ref(null) const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
let statusTimer = null let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated) const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -68,24 +69,23 @@ const canAuthenticate = computed(() => {
if (initializing.value) return false if (initializing.value) return false
// In reauth mode, allow authentication even if already authenticated // In reauth mode, allow authentication even if already authenticated
if (props.mode === 'reauth') return true if (props.mode === 'reauth') return true
// In login mode, only allow if not authenticated // In login view or initial state, allow if not authenticated
return !isAuthenticated.value return currentView.value !== 'forbidden'
}) })
const headingTitle = computed(() => { const headingTitle = computed(() => {
if (props.mode === 'reauth') { if (props.mode === 'reauth') {
return `🔐 Additional Verification Required` return `🔐 Additional Verification Required`
} }
if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}` if (currentView.value === 'forbidden') return '🚫 Forbidden'
return '🚫 Forbidden' return `🔐 ${settings.value?.rp_name || location.origin}`
}) })
const headerMessage = computed(() => { const headerMessage = computed(() => {
if (props.mode === 'reauth') { if (props.mode === 'reauth') {
return 'Please verify your identity to continue with this action.' return 'Please verify your identity to continue with this action.'
} }
if (!isAuthenticated.value) return 'Please sign in to access this page.' return currentView.value === 'forbidden' ? 'You lack the required permissions.' : 'Please sign in with your Passkey.'
return 'You lack the permissions required to access this page.'
}) })
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User') const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
@@ -116,13 +116,22 @@ async function fetchSettings() {
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
const res = await fetch('/auth/api/user-info', { method: 'POST' }) const res = await fetch('/auth/api/user-info', { method: 'POST' })
if (!res.ok) return if (!res.ok) {
userInfo.value = null
currentView.value = 'login'
return
}
userInfo.value = await res.json() userInfo.value = await res.json()
// In login mode, if the user is authenticated but still here, they lack permissions. // Determine view based on authentication status
// In reauth mode, being authenticated is expected - we just need re-verification. if (isAuthenticated.value && props.mode !== 'reauth') {
if (isAuthenticated.value && props.mode !== 'reauth') emit('forbidden', userInfo.value) currentView.value = 'forbidden'
emit('forbidden', userInfo.value)
} else {
currentView.value = 'login'
}
} catch (error) { } catch (error) {
console.error('Failed to load user info', error) console.error('Failed to load user info', error)
currentView.value = 'login'
} }
} }
@@ -153,11 +162,23 @@ async function authenticateUser() {
async function logoutUser() { async function logoutUser() {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ } try {
await fetch('/auth/api/logout', { method: 'POST' })
userInfo.value = null
// Switch to login view after logout
currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
} catch (_) { /* ignore */ }
finally { loading.value = false } finally { loading.value = false }
emit('logout') emit('logout')
} }
function openProfile() {
// Open profile in a new window with a specific name to reuse the same tab
const profileWindow = window.open('/auth/', 'passkey_auth_profile')
if (profileWindow) profileWindow.focus()
}
async function setSessionCookie(sessionToken) { async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', { const response = await fetch('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` } method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }
@@ -2,8 +2,6 @@
<RestrictedAuth <RestrictedAuth
:mode="authMode" :mode="authMode"
@authenticated="handleAuthenticated" @authenticated="handleAuthenticated"
@forbidden="handleForbidden"
@logout="handleLogout"
@back="handleBack" @back="handleBack"
/> />
</template> </template>
@@ -12,13 +10,11 @@
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Detect mode from URL parameters or postMessage
const authMode = computed(() => { const authMode = computed(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
return params.get('mode') === 'reauth' ? 'reauth' : 'login' return params.get('mode') === 'reauth' ? 'reauth' : 'login'
}) })
// postMessage communication with parent window
function postToParent(message) { function postToParent(message) {
if (window.parent && window.parent !== window) { if (window.parent && window.parent !== window) {
window.parent.postMessage(message, '*') window.parent.postMessage(message, '*')
@@ -26,7 +22,6 @@ function postToParent(message) {
} }
function handleAuthenticated(result) { function handleAuthenticated(result) {
// Notify parent that authentication was successful
postToParent({ postToParent({
type: 'auth-success', type: 'auth-success',
authenticated: true, authenticated: true,
@@ -34,46 +29,17 @@ function handleAuthenticated(result) {
}) })
} }
function handleForbidden(userInfo) {
// Notify parent that user is authenticated but lacks permissions
postToParent({
type: 'auth-forbidden',
authenticated: true,
userInfo
})
}
function handleLogout() {
// Notify parent that logout occurred
postToParent({
type: 'auth-logout'
})
}
function handleBack() { function handleBack() {
console.log('[RestrictedApiApp] Back clicked')
// Notify parent that user wants to go back
postToParent({ postToParent({
type: 'auth-back' type: 'auth-back'
}) })
} }
onMounted(() => { onMounted(() => {
// Notify parent that the iframe is ready
postToParent({ postToParent({
type: 'auth-ready' type: 'auth-ready'
}) })
// Listen for messages from parent
window.addEventListener('message', (event) => {
// In production, you should validate event.origin
if (event.data?.type === 'auth-check') {
// Parent is requesting current auth status - could add this functionality
// by exposing more state from RestrictedAuth component
}
})
// Handle Escape key to trigger back navigation
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
handleBack() handleBack()