Implement Forbidden view for API calls, cleanup and better UX.
This commit is contained in:
@@ -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':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+2
-15
@@ -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 {
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user