From 7e568dbd10be05409a1c8251c06b34591f8540fc Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 26 Jan 2026 19:40:48 +0000 Subject: [PATCH] Refactor validate endpoint to return session context, leaving user-info only for extra profile data. Completely separate token-info for reset tokens. Simplified by reusing same data structures in various places and mandating fields to have values not needing fallbacks. Implemented consistent AccessDenied view in profile and admin apps. --- frontend/auth/App.vue | 58 +++++-------- frontend/auth/admin/AdminApp.vue | 63 +++++++-------- frontend/int/reset/ResetApp.vue | 19 ++--- frontend/src/admin/AdminOverview.vue | 24 +++--- frontend/src/components/AccessDenied.vue | 22 ++++- frontend/src/components/HostProfileView.vue | 16 ++-- frontend/src/components/ProfileView.vue | 16 ++-- frontend/src/components/RestrictedAuth.vue | 21 +++-- paskia/db/operations.py | 18 +++-- paskia/db/structs.py | 4 +- paskia/fastapi/api.py | 70 +++++++--------- paskia/util/userinfo.py | 90 ++++++--------------- 12 files changed, 183 insertions(+), 238 deletions(-) diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 7822e23..a4a54d1 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -2,10 +2,10 @@
- - - - + + + +
@@ -18,13 +18,11 @@ import StatusMessage from '@/components/StatusMessage.vue' import ProfileView from '@/components/ProfileView.vue' import HostProfileView from '@/components/HostProfileView.vue' import LoadingView from '@/components/LoadingView.vue' -import AuthRequiredMessage from '@/components/AccessDenied.vue' +import AccessDenied from '@/components/AccessDenied.vue' const store = useAuthStore() -const loading = ref(true) +const viewState = ref('loading') // 'loading' | 'profile' | 'terminal' const loadingMessage = ref('Loading...') -const authenticated = ref(false) -const showBackMessage = ref(false) /** * Normalize a host string for comparison (lowercase, strip default ports). @@ -51,14 +49,19 @@ const isHostMode = computed(() => { let validationTimer = null let authIframe = null +function terminateSession() { + store.userInfo = null + viewState.value = 'terminal' +} + async function loadUserInfo() { try { store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' }) - authenticated.value = true - loading.value = false + viewState.value = 'profile' startSessionValidation() return true - } catch (e) { + } catch { + store.userInfo = null return false } } @@ -85,10 +88,6 @@ function hideAuthIframe() { } } -function reloadPage() { - window.location.reload() -} - function handleAuthMessage(event) { const data = event.data if (!data?.type) return @@ -97,7 +96,7 @@ function handleAuthMessage(event) { case 'auth-success': // Authentication successful - reload user info hideAuthIframe() - loading.value = true + viewState.value = 'loading' loadingMessage.value = 'Loading user profile...' loadUserInfo() break @@ -117,11 +116,9 @@ function handleAuthMessage(event) { break case 'auth-back': - // User clicked Back - show message with reload option + // User clicked Back - show terminal state hideAuthIframe() - loading.value = false - showBackMessage.value = true - store.showMessage('Authentication cancelled', 'info', 3000) + terminateSession() break case 'auth-close-request': @@ -133,23 +130,10 @@ function handleAuthMessage(event) { async function validateSession() { try { - await apiJson('/auth/api/validate', { - method: 'POST', - credentials: 'include' - }) - // If successful, session was renewed automatically - } catch (error) { - if (error.status === 401) { - // Session expired - need to re-authenticate - console.log('Session expired, requiring re-authentication') - authenticated.value = false - loading.value = true - stopSessionValidation() - showAuthIframe() - } else { - console.error('Session validation error:', error) - // Don't treat network errors as session expiry - } + await apiJson('/auth/api/validate', { method: 'POST' }) + } catch { + stopSessionValidation() + terminateSession() } } diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 68a8f6b..4a2b802 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue' import StatusMessage from '@/components/StatusMessage.vue' import LoadingView from '@/components/LoadingView.vue' -import AuthRequiredMessage from '@/components/AccessDenied.vue' +import AccessDenied from '@/components/AccessDenied.vue' import AdminOverview from '@/admin/AdminOverview.vue' import AdminOrgDetail from '@/admin/AdminOrgDetail.vue' import AdminUserDetail from '@/admin/AdminUserDetail.vue' @@ -48,8 +48,8 @@ const adminUserDetailRef = ref(null) const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value) // Derive admin status from permissions -const isGlobalAdmin = computed(() => info.value?.permissions?.includes('auth:admin') ?? false) -const isOrgAdmin = computed(() => info.value?.permissions?.includes('auth:org:admin') ?? false) +const isMasterAdmin = computed(() => info.value?.ctx.permissions.includes('auth:admin')) +const isOrgAdmin = computed(() => info.value?.ctx.permissions.includes('auth:org:admin')) function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } @@ -144,10 +144,19 @@ async function loadPermissions() { } async function loadUserInfo() { - info.value = await apiJson('/auth/api/user-info', { method: 'POST' }) + const data = await apiJson('/auth/api/validate', { method: 'POST' }) + info.value = data authenticated.value = true } +function clearSensitiveState() { + info.value = null + orgs.value = [] + permissions.value = [] + userDetail.value = null + authenticated.value = false +} + async function load() { loading.value = true loadingMessage.value = 'Loading...' @@ -158,7 +167,7 @@ async function load() { // If we get here, user has admin access - now fetch user info for display await loadUserInfo() - if (!isGlobalAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { + if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { currentOrgId.value = orgs.value[0].uuid window.location.hash = `#org/${currentOrgId.value}` @@ -168,6 +177,7 @@ async function load() { } } else parseHash() } catch (e) { + clearSensitiveState() if (e.name === 'AuthCancelledError') { showBackMessage.value = true } else { @@ -191,8 +201,6 @@ async function performOrgDeletion(orgUuid) { } function deleteOrg(org) { - if (!isGlobalAdmin.value) { authStore.showMessage('Global admin only'); return } - const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0) if (userCount === 0) { @@ -333,10 +341,6 @@ function deletePermission(p) { } }) } -function reloadPage() { - window.location.reload() -} - const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) function openOrg(o) { @@ -384,7 +388,7 @@ const breadcrumbEntries = computed(() => { entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` }) } if (selectedUser.value) { - entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` }) + entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` }) } return entries }) @@ -703,23 +707,19 @@ async function submitDialog() {
- + - -
-
-

⛔ Access Denied

-

{{ error }}

-

You do not have admin permissions for this application.

-
- - -
-
-
-
+ +

{{ pageHeading }}

@@ -729,7 +729,7 @@ async function submitDialog() {
diff --git a/frontend/int/reset/ResetApp.vue b/frontend/int/reset/ResetApp.vue index 9b2ad53..ff2048c 100644 --- a/frontend/int/reset/ResetApp.vue +++ b/frontend/int/reset/ResetApp.vue @@ -71,12 +71,12 @@ const initializing = ref(true) const loading = ref(false) const token = ref('') const settings = ref(null) -const userInfo = ref(null) +const tokenInfo = ref(null) const displayName = ref('') const errorMessage = ref('') let statusTimer = null -const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment') +const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your enrollment') const subtitleMessage = computed(() => { if (initializing.value) return 'Preparing your secure enrollment…' if (!canRegister.value) return 'This authentication link is no longer valid.' @@ -85,7 +85,7 @@ const subtitleMessage = computed(() => { const basePath = computed(() => uiBasePath()) -const canRegister = computed(() => !!(token.value && userInfo.value)) +const canRegister = computed(() => !!(token.value && tokenInfo.value)) function showMessage(message, type = 'info', duration = 3000) { status.show = true @@ -109,15 +109,16 @@ async function fetchSettings() { } } -async function fetchUserInfo() { +async function fetchTokenInfo() { if (!token.value) return try { - userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, { - method: 'POST' + tokenInfo.value = await apiJson('/auth/api/token-info', { + method: 'GET', + headers: { 'Authorization': `Bearer ${token.value}` }, }) - displayName.value = userInfo.value?.user?.user_name || '' + displayName.value = tokenInfo.value.display_name } catch (error) { - console.error('Failed to load user info', error) + console.error('Failed to load token info', error) const message = error instanceof ApiError ? (error.data?.detail || 'The authentication link is invalid or expired.') : getUserFriendlyErrorMessage(error) @@ -196,7 +197,7 @@ onMounted(async () => { initializing.value = false return } - await fetchUserInfo() + await fetchTokenInfo() initializing.value = false }) diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue index e8ec2af..143da8b 100644 --- a/frontend/src/admin/AdminOverview.vue +++ b/frontend/src/admin/AdminOverview.vue @@ -26,9 +26,9 @@ const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { })) const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope))) -// Derive admin status from permissions -const isGlobalAdmin = computed(() => props.info?.permissions?.includes('auth:admin') ?? false) -const isOrgAdmin = computed(() => props.info?.permissions?.includes('auth:org:admin') ?? false) +// Derive admin status from permissions (info contains ctx from validate response) +const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin')) +const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin')) function permissionDisplayName(scope) { return props.permissions.find(p => p.scope === scope)?.display_name || scope @@ -93,7 +93,7 @@ function handleTableKeydown(event, tableType) { } else if (direction === 'down' && currentIndex === rows.length - 1) { // At bottom of org table, navigate to permissions section event.preventDefault() - if (tableType === 'org' && isGlobalAdmin.value) { + if (tableType === 'org' && isMasterAdmin.value) { // Navigate to permissions matrix or actions if (permMatrixRef.value) { const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]') @@ -236,7 +236,7 @@ function handlePermActionsKeydown(event) { // Focus helper for external navigation function focusFirstElement() { - if (isGlobalAdmin.value) { + if (isMasterAdmin.value) { focusPreferred(orgActionsRef.value, { itemSelector: 'button' }) } else { const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])') @@ -249,9 +249,9 @@ defineExpose({ focusFirstElement })