From 6f9f4aefc1e3956ad2fb981f618224a97741b3bf Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 2 Dec 2025 17:36:37 +0000 Subject: [PATCH] Implement Forbidden view for API calls, cleanup and better UX. --- frontend/src/App.vue | 3 +- frontend/src/admin/AdminApp.vue | 25 ++++++----- ...thRequiredMessage.vue => AccessDenied.vue} | 17 +------ frontend/src/components/RestrictedAuth.vue | 45 ++++++++++++++----- .../src/restricted-api/RestrictedApiApp.vue | 34 -------------- 5 files changed, 50 insertions(+), 74 deletions(-) rename frontend/src/components/{AuthRequiredMessage.vue => AccessDenied.vue} (72%) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9e1ad47..b9f6493 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,7 +15,7 @@ import { useAuthStore } from '@/stores/auth' import StatusMessage from '@/components/StatusMessage.vue' import ProfileView from '@/components/ProfileView.vue' import LoadingView from '@/components/LoadingView.vue' -import AuthRequiredMessage from '@/components/AuthRequiredMessage.vue' +import AuthRequiredMessage from '@/components/AccessDenied.vue' const store = useAuthStore() const loading = ref(true) @@ -106,6 +106,7 @@ function handleAuthMessage(event) { hideAuthIframe() loading.value = false showBackMessage.value = true + store.showMessage('Authentication cancelled', 'info', 3000) break case 'auth-close-request': diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 23d3126..70980fb 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -6,7 +6,7 @@ import UserBasicInfo from '@/components/UserBasicInfo.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import StatusMessage from '@/components/StatusMessage.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 AdminOrgDetail from './AdminOrgDetail.vue' import AdminUserDetail from './AdminUserDetail.vue' @@ -192,6 +192,15 @@ async function load() { if (data.detail) throw new Error(data.detail) info.value = data 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)) { await Promise.all([loadOrgs(), loadPermissions()]) } @@ -360,6 +369,7 @@ function handleAuthMessage(event) { hideAuthIframe() loading.value = false showBackMessage.value = true + authStore.showMessage('Authentication cancelled', 'info', 3000) break case 'auth-close-request': @@ -576,10 +586,9 @@ async function submitDialog() { -
+

{{ pageHeading }}

@@ -588,14 +597,7 @@ async function submitDialog() {
{{ error }}
-
diff --git a/frontend/src/components/AuthRequiredMessage.vue b/frontend/src/components/AccessDenied.vue similarity index 72% rename from frontend/src/components/AuthRequiredMessage.vue rename to frontend/src/components/AccessDenied.vue index 78348af..f632e23 100644 --- a/frontend/src/components/AuthRequiredMessage.vue +++ b/frontend/src/components/AccessDenied.vue @@ -1,8 +1,7 @@ @@ -37,13 +29,8 @@ defineEmits(['reload']) } .message-content h2 { - margin: 0 0 1rem; - color: var(--color-heading); -} - -.message-content p { - color: var(--color-text-muted); margin: 0 0 1.5rem; + color: var(--color-heading); } .message-content .button-row { diff --git a/frontend/src/components/RestrictedAuth.vue b/frontend/src/components/RestrictedAuth.vue index e32430b..00e9688 100644 --- a/frontend/src/components/RestrictedAuth.vue +++ b/frontend/src/components/RestrictedAuth.vue @@ -30,7 +30,7 @@ {{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }} - + @@ -60,6 +60,7 @@ const initializing = ref(true) const loading = ref(false) const settings = ref(null) const userInfo = ref(null) +const currentView = ref('initial') // 'initial', 'login', 'forbidden' let statusTimer = null const isAuthenticated = computed(() => !!userInfo.value?.authenticated) @@ -68,24 +69,23 @@ const canAuthenticate = computed(() => { if (initializing.value) return false // In reauth mode, allow authentication even if already authenticated if (props.mode === 'reauth') return true - // In login mode, only allow if not authenticated - return !isAuthenticated.value + // In login view or initial state, allow if not authenticated + return currentView.value !== 'forbidden' }) const headingTitle = computed(() => { if (props.mode === 'reauth') { return `πŸ” Additional Verification Required` } - if (!isAuthenticated.value) return `πŸ” ${settings.value?.rp_name || location.origin}` - return '🚫 Forbidden' + if (currentView.value === 'forbidden') return '🚫 Forbidden' + return `πŸ” ${settings.value?.rp_name || location.origin}` }) const headerMessage = computed(() => { if (props.mode === 'reauth') { return 'Please verify your identity to continue with this action.' } - if (!isAuthenticated.value) return 'Please sign in to access this page.' - return 'You lack the permissions required to access this page.' + return currentView.value === 'forbidden' ? 'You lack the required permissions.' : 'Please sign in with your Passkey.' }) const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User') @@ -116,13 +116,22 @@ async function fetchSettings() { async function fetchUserInfo() { try { 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() - // In login mode, if the user is authenticated but still here, they lack permissions. - // In reauth mode, being authenticated is expected - we just need re-verification. - if (isAuthenticated.value && props.mode !== 'reauth') emit('forbidden', userInfo.value) + // Determine view based on authentication status + if (isAuthenticated.value && props.mode !== 'reauth') { + currentView.value = 'forbidden' + emit('forbidden', userInfo.value) + } else { + currentView.value = 'login' + } } catch (error) { console.error('Failed to load user info', error) + currentView.value = 'login' } } @@ -153,11 +162,23 @@ async function authenticateUser() { async function logoutUser() { if (loading.value) return 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 } 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) { const response = await fetch('/auth/api/set-session', { method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` } diff --git a/frontend/src/restricted-api/RestrictedApiApp.vue b/frontend/src/restricted-api/RestrictedApiApp.vue index 0d1c9cc..8c6e7c4 100644 --- a/frontend/src/restricted-api/RestrictedApiApp.vue +++ b/frontend/src/restricted-api/RestrictedApiApp.vue @@ -2,8 +2,6 @@ @@ -12,13 +10,11 @@ import { computed, onMounted } from 'vue' import RestrictedAuth from '@/components/RestrictedAuth.vue' -// Detect mode from URL parameters or postMessage const authMode = computed(() => { const params = new URLSearchParams(window.location.search) return params.get('mode') === 'reauth' ? 'reauth' : 'login' }) -// postMessage communication with parent window function postToParent(message) { if (window.parent && window.parent !== window) { window.parent.postMessage(message, '*') @@ -26,7 +22,6 @@ function postToParent(message) { } function handleAuthenticated(result) { - // Notify parent that authentication was successful postToParent({ type: 'auth-success', 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() { - console.log('[RestrictedApiApp] Back clicked') - // Notify parent that user wants to go back postToParent({ type: 'auth-back' }) } onMounted(() => { - // Notify parent that the iframe is ready postToParent({ 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) => { if (event.key === 'Escape') { handleBack()