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.
This commit is contained in:
+21
-37
@@ -2,10 +2,10 @@
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<HostProfileView v-if="authenticated && isHostMode" :initializing="loading" />
|
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
|
||||||
<ProfileView v-else-if="authenticated" />
|
<ProfileView v-else-if="viewState === 'profile'" />
|
||||||
<LoadingView v-else-if="loading" :message="loadingMessage" />
|
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
|
||||||
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
|
<AccessDenied v-else-if="viewState === 'terminal'" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -18,13 +18,11 @@ import StatusMessage from '@/components/StatusMessage.vue'
|
|||||||
import ProfileView from '@/components/ProfileView.vue'
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
import HostProfileView from '@/components/HostProfileView.vue'
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
import LoadingView from '@/components/LoadingView.vue'
|
import LoadingView from '@/components/LoadingView.vue'
|
||||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
import AccessDenied from '@/components/AccessDenied.vue'
|
||||||
|
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
const loading = ref(true)
|
const viewState = ref('loading') // 'loading' | 'profile' | 'terminal'
|
||||||
const loadingMessage = ref('Loading...')
|
const loadingMessage = ref('Loading...')
|
||||||
const authenticated = ref(false)
|
|
||||||
const showBackMessage = ref(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a host string for comparison (lowercase, strip default ports).
|
* Normalize a host string for comparison (lowercase, strip default ports).
|
||||||
@@ -51,14 +49,19 @@ const isHostMode = computed(() => {
|
|||||||
let validationTimer = null
|
let validationTimer = null
|
||||||
let authIframe = null
|
let authIframe = null
|
||||||
|
|
||||||
|
function terminateSession() {
|
||||||
|
store.userInfo = null
|
||||||
|
viewState.value = 'terminal'
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
authenticated.value = true
|
viewState.value = 'profile'
|
||||||
loading.value = false
|
|
||||||
startSessionValidation()
|
startSessionValidation()
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
|
store.userInfo = null
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,10 +88,6 @@ function hideAuthIframe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadPage() {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAuthMessage(event) {
|
function handleAuthMessage(event) {
|
||||||
const data = event.data
|
const data = event.data
|
||||||
if (!data?.type) return
|
if (!data?.type) return
|
||||||
@@ -97,7 +96,7 @@ function handleAuthMessage(event) {
|
|||||||
case 'auth-success':
|
case 'auth-success':
|
||||||
// Authentication successful - reload user info
|
// Authentication successful - reload user info
|
||||||
hideAuthIframe()
|
hideAuthIframe()
|
||||||
loading.value = true
|
viewState.value = 'loading'
|
||||||
loadingMessage.value = 'Loading user profile...'
|
loadingMessage.value = 'Loading user profile...'
|
||||||
loadUserInfo()
|
loadUserInfo()
|
||||||
break
|
break
|
||||||
@@ -117,11 +116,9 @@ function handleAuthMessage(event) {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'auth-back':
|
case 'auth-back':
|
||||||
// User clicked Back - show message with reload option
|
// User clicked Back - show terminal state
|
||||||
hideAuthIframe()
|
hideAuthIframe()
|
||||||
loading.value = false
|
terminateSession()
|
||||||
showBackMessage.value = true
|
|
||||||
store.showMessage('Authentication cancelled', 'info', 3000)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'auth-close-request':
|
case 'auth-close-request':
|
||||||
@@ -133,23 +130,10 @@ function handleAuthMessage(event) {
|
|||||||
|
|
||||||
async function validateSession() {
|
async function validateSession() {
|
||||||
try {
|
try {
|
||||||
await apiJson('/auth/api/validate', {
|
await apiJson('/auth/api/validate', { method: 'POST' })
|
||||||
method: 'POST',
|
} catch {
|
||||||
credentials: 'include'
|
stopSessionValidation()
|
||||||
})
|
terminateSession()
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
|
|||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.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/AccessDenied.vue'
|
import AccessDenied from '@/components/AccessDenied.vue'
|
||||||
import AdminOverview from '@/admin/AdminOverview.vue'
|
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||||
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||||
@@ -48,8 +48,8 @@ const adminUserDetailRef = ref(null)
|
|||||||
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
||||||
|
|
||||||
// Derive admin status from permissions
|
// Derive admin status from permissions
|
||||||
const isGlobalAdmin = computed(() => info.value?.permissions?.includes('auth:admin') ?? false)
|
const isMasterAdmin = computed(() => info.value?.ctx.permissions.includes('auth:admin'))
|
||||||
const isOrgAdmin = computed(() => info.value?.permissions?.includes('auth:org:admin') ?? false)
|
const isOrgAdmin = computed(() => info.value?.ctx.permissions.includes('auth:org:admin'))
|
||||||
|
|
||||||
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||||
|
|
||||||
@@ -144,10 +144,19 @@ async function loadPermissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserInfo() {
|
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
|
authenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSensitiveState() {
|
||||||
|
info.value = null
|
||||||
|
orgs.value = []
|
||||||
|
permissions.value = []
|
||||||
|
userDetail.value = null
|
||||||
|
authenticated.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadingMessage.value = 'Loading...'
|
loadingMessage.value = 'Loading...'
|
||||||
@@ -158,7 +167,7 @@ async function load() {
|
|||||||
// If we get here, user has admin access - now fetch user info for display
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
await loadUserInfo()
|
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') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
currentOrgId.value = orgs.value[0].uuid
|
currentOrgId.value = orgs.value[0].uuid
|
||||||
window.location.hash = `#org/${currentOrgId.value}`
|
window.location.hash = `#org/${currentOrgId.value}`
|
||||||
@@ -168,6 +177,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
} else parseHash()
|
} else parseHash()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
clearSensitiveState()
|
||||||
if (e.name === 'AuthCancelledError') {
|
if (e.name === 'AuthCancelledError') {
|
||||||
showBackMessage.value = true
|
showBackMessage.value = true
|
||||||
} else {
|
} else {
|
||||||
@@ -191,8 +201,6 @@ async function performOrgDeletion(orgUuid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteOrg(org) {
|
function deleteOrg(org) {
|
||||||
if (!isGlobalAdmin.value) { authStore.showMessage('Global admin only'); return }
|
|
||||||
|
|
||||||
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
|
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
|
||||||
|
|
||||||
if (userCount === 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)
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||||
|
|
||||||
function openOrg(o) {
|
function openOrg(o) {
|
||||||
@@ -384,7 +388,7 @@ const breadcrumbEntries = computed(() => {
|
|||||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
||||||
}
|
}
|
||||||
if (selectedUser.value) {
|
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
|
return entries
|
||||||
})
|
})
|
||||||
@@ -703,23 +707,19 @@ async function submitDialog() {
|
|||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<LoadingView v-if="loading" :message="loadingMessage" />
|
<LoadingView v-if="loading" :message="loadingMessage" />
|
||||||
<AuthRequiredMessage
|
<AccessDenied v-else-if="showBackMessage" />
|
||||||
v-else-if="showBackMessage"
|
<AccessDenied
|
||||||
@reload="reloadPage"
|
v-else-if="error"
|
||||||
|
icon="⚠️"
|
||||||
|
title="Error"
|
||||||
|
:message="error"
|
||||||
/>
|
/>
|
||||||
<!-- Access denied: authenticated but not admin, or error occurred -->
|
<AccessDenied
|
||||||
<div v-else-if="error || (authenticated && !isGlobalAdmin && !isOrgAdmin)" class="access-denied-container">
|
v-else-if="authenticated && !isMasterAdmin && !isOrgAdmin"
|
||||||
<div class="access-denied-content">
|
icon="⛔"
|
||||||
<h2>⛔ Access Denied</h2>
|
message="You do not have admin permissions for this application."
|
||||||
<p v-if="error" class="error-detail">{{ error }}</p>
|
/>
|
||||||
<p v-else class="error-detail">You do not have admin permissions for this application.</p>
|
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||||
<div class="button-row">
|
|
||||||
<button class="btn-secondary" @click="goBack">Back</button>
|
|
||||||
<button class="btn-primary" @click="reloadPage">Reload Page</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ pageHeading }}</h1>
|
<h1>{{ pageHeading }}</h1>
|
||||||
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
@@ -729,7 +729,7 @@ async function submitDialog() {
|
|||||||
<div class="section-body admin-section-body">
|
<div class="section-body admin-section-body">
|
||||||
<div class="admin-panels">
|
<div class="admin-panels">
|
||||||
<AdminOverview
|
<AdminOverview
|
||||||
v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)"
|
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
|
||||||
ref="adminOverviewRef"
|
ref="adminOverviewRef"
|
||||||
:info="info"
|
:info="info"
|
||||||
:orgs="orgs"
|
:orgs="orgs"
|
||||||
@@ -805,9 +805,4 @@ async function submitDialog() {
|
|||||||
.admin-section { margin-top: var(--space-xl); }
|
.admin-section { margin-top: var(--space-xl); }
|
||||||
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
.access-denied-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 2rem; }
|
|
||||||
.access-denied-content { text-align: center; max-width: 480px; }
|
|
||||||
.access-denied-content h2 { margin: 0 0 1rem; color: var(--color-heading); font-size: 1.5rem; }
|
|
||||||
.access-denied-content .error-detail { margin: 0 0 1.5rem; color: var(--color-text-muted); }
|
|
||||||
.access-denied-content .button-row { display: flex; gap: 0.75rem; justify-content: center; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -71,12 +71,12 @@ const initializing = ref(true)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const settings = ref(null)
|
const settings = ref(null)
|
||||||
const userInfo = ref(null)
|
const tokenInfo = ref(null)
|
||||||
const displayName = ref('')
|
const displayName = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
let statusTimer = null
|
let statusTimer = null
|
||||||
|
|
||||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your enrollment')
|
||||||
const subtitleMessage = computed(() => {
|
const subtitleMessage = computed(() => {
|
||||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||||
@@ -85,7 +85,7 @@ const subtitleMessage = computed(() => {
|
|||||||
|
|
||||||
const basePath = computed(() => uiBasePath())
|
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) {
|
function showMessage(message, type = 'info', duration = 3000) {
|
||||||
status.show = true
|
status.show = true
|
||||||
@@ -109,15 +109,16 @@ async function fetchSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchTokenInfo() {
|
||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
try {
|
try {
|
||||||
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
tokenInfo.value = await apiJson('/auth/api/token-info', {
|
||||||
method: 'POST'
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||||
})
|
})
|
||||||
displayName.value = userInfo.value?.user?.user_name || ''
|
displayName.value = tokenInfo.value.display_name
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info', error)
|
console.error('Failed to load token info', error)
|
||||||
const message = error instanceof ApiError
|
const message = error instanceof ApiError
|
||||||
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
||||||
: getUserFriendlyErrorMessage(error)
|
: getUserFriendlyErrorMessage(error)
|
||||||
@@ -196,7 +197,7 @@ onMounted(async () => {
|
|||||||
initializing.value = false
|
initializing.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await fetchUserInfo()
|
await fetchTokenInfo()
|
||||||
initializing.value = false
|
initializing.value = false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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)))
|
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
|
||||||
|
|
||||||
// Derive admin status from permissions
|
// Derive admin status from permissions (info contains ctx from validate response)
|
||||||
const isGlobalAdmin = computed(() => props.info?.permissions?.includes('auth:admin') ?? false)
|
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
||||||
const isOrgAdmin = computed(() => props.info?.permissions?.includes('auth:org:admin') ?? false)
|
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
||||||
|
|
||||||
function permissionDisplayName(scope) {
|
function permissionDisplayName(scope) {
|
||||||
return props.permissions.find(p => p.scope === scope)?.display_name || 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) {
|
} else if (direction === 'down' && currentIndex === rows.length - 1) {
|
||||||
// At bottom of org table, navigate to permissions section
|
// At bottom of org table, navigate to permissions section
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (tableType === 'org' && isGlobalAdmin.value) {
|
if (tableType === 'org' && isMasterAdmin.value) {
|
||||||
// Navigate to permissions matrix or actions
|
// Navigate to permissions matrix or actions
|
||||||
if (permMatrixRef.value) {
|
if (permMatrixRef.value) {
|
||||||
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
|
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
|
||||||
@@ -236,7 +236,7 @@ function handlePermActionsKeydown(event) {
|
|||||||
|
|
||||||
// Focus helper for external navigation
|
// Focus helper for external navigation
|
||||||
function focusFirstElement() {
|
function focusFirstElement() {
|
||||||
if (isGlobalAdmin.value) {
|
if (isMasterAdmin.value) {
|
||||||
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
|
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
|
||||||
} else {
|
} else {
|
||||||
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
|
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
|
||||||
@@ -249,9 +249,9 @@ defineExpose({ focusFirstElement })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="permissions-section" ref="orgSection">
|
<div class="permissions-section" ref="orgSection">
|
||||||
<h2>{{ isGlobalAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
|
<h2>{{ isMasterAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||||
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
|
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
|
||||||
<button v-if="isGlobalAdmin" @click="$emit('createOrg')">+ Create Org</button>
|
<button v-if="isMasterAdmin" @click="$emit('createOrg')">+ Create Org</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
|
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -259,18 +259,18 @@ defineExpose({ focusFirstElement })
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Roles</th>
|
<th>Roles</th>
|
||||||
<th>Members</th>
|
<th>Members</th>
|
||||||
<th v-if="isGlobalAdmin">Actions</th>
|
<th v-if="isMasterAdmin">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
||||||
<td>
|
<td>
|
||||||
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
|
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
|
||||||
<button v-if="isGlobalAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="role-names">{{ getRoleNames(o) }}</td>
|
<td class="role-names">{{ getRoleNames(o) }}</td>
|
||||||
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||||
<td v-if="isGlobalAdmin" class="center">
|
<td v-if="isMasterAdmin" class="center">
|
||||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -278,7 +278,7 @@ defineExpose({ focusFirstElement })
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isGlobalAdmin" class="permissions-section">
|
<div v-if="isMasterAdmin" class="permissions-section">
|
||||||
<h2>Permissions</h2>
|
<h2>Permissions</h2>
|
||||||
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
|
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
|
||||||
<div class="matrix-scroll">
|
<div class="matrix-scroll">
|
||||||
@@ -317,7 +317,7 @@ defineExpose({ focusFirstElement })
|
|||||||
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
|
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
|
||||||
<button v-if="isGlobalAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
|
<button v-if="isMasterAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
|
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<h2>🔒 Access Denied</h2>
|
<h2>{{ icon }} {{ title }}</h2>
|
||||||
|
<p v-if="message" class="error-detail">{{ message }}</p>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn-secondary" @click="goBack">Back</button>
|
<button class="btn-secondary" @click="goBack">Back</button>
|
||||||
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
<button class="btn-primary" @click="reload">Reload Page</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +14,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
defineEmits(['reload'])
|
const props = defineProps({
|
||||||
|
title: { type: String, default: 'Access Denied' },
|
||||||
|
icon: { type: String, default: '🔒' },
|
||||||
|
message: { type: String, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -32,10 +41,15 @@ defineEmits(['reload'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-content h2 {
|
.message-content h2 {
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-content .error-detail {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.message-content .button-row {
|
.message-content .button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block" ref="userInfoSection">
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="user"
|
v-if="ctx"
|
||||||
:name="user.user_name"
|
:name="ctx.user.display_name"
|
||||||
:visits="user.visits || 0"
|
:visits="authStore.userInfo?.visits || 0"
|
||||||
:created-at="user.created_at"
|
:created-at="authStore.userInfo?.created_at"
|
||||||
:last-seen="user.last_seen"
|
:last-seen="authStore.userInfo?.last_seen"
|
||||||
:org-display-name="orgDisplayName"
|
:org-display-name="orgDisplayName"
|
||||||
:role-name="roleDisplayName"
|
:role-name="roleDisplayName"
|
||||||
:can-edit="false"
|
:can-edit="false"
|
||||||
@@ -78,9 +78,9 @@ const currentHost = window.location.host
|
|||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
const buttonRow = ref(null)
|
const buttonRow = ref(null)
|
||||||
|
|
||||||
const user = computed(() => authStore.userInfo?.user || null)
|
const ctx = computed(() => authStore.userInfo?.ctx || null)
|
||||||
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
|
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
|
||||||
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
|
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
|
||||||
|
|
||||||
const headingTitle = computed(() => {
|
const headingTitle = computed(() => {
|
||||||
const service = authStore.settings?.rp_name
|
const service = authStore.settings?.rp_name
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
|
|
||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block" ref="userInfoSection">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="authStore.userInfo?.user"
|
v-if="authStore.userInfo?.ctx"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
:name="authStore.userInfo.user.user_name"
|
:name="authStore.userInfo.ctx.user.display_name"
|
||||||
:visits="authStore.userInfo.user.visits || 0"
|
:visits="authStore.userInfo.visits"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.last_seen"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/api/user/display-name"
|
update-endpoint="/auth/api/user/display-name"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
@@ -151,7 +151,7 @@ const userInfoSection = ref(null)
|
|||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
||||||
|
|
||||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||||
@@ -323,9 +323,9 @@ const terminateSession = async (session) => {
|
|||||||
|
|
||||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||||
const logout = async () => { await authStore.logout() }
|
const logout = async () => { await authStore.logout() }
|
||||||
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
|
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
const perms = authStore.userInfo?.permissions ?? []
|
const perms = authStore.userInfo?.ctx.permissions
|
||||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||||
})
|
})
|
||||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ const status = reactive({ show: false, message: '', type: 'info' })
|
|||||||
const initializing = ref(true)
|
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 session = ref(null)
|
||||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||||
const authView = ref('local') // 'local' or 'remote'
|
const authView = ref('local') // 'local' or 'remote'
|
||||||
const buttonRow = ref(null)
|
const buttonRow = ref(null)
|
||||||
let statusTimer = null
|
let statusTimer = null
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
const isAuthenticated = computed(() => !!session.value)
|
||||||
|
|
||||||
const canAuthenticate = computed(() => {
|
const canAuthenticate = computed(() => {
|
||||||
if (initializing.value) return false
|
if (initializing.value) return false
|
||||||
@@ -115,7 +115,7 @@ const headerMessage = computed(() => {
|
|||||||
return 'Please sign in with your passkey.'
|
return 'Please sign in with your passkey.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
const userDisplayName = computed(() => session.value?.ctx.user.display_name || 'User')
|
||||||
|
|
||||||
function showMessage(message, type = 'info', duration = 3000) {
|
function showMessage(message, type = 'info', duration = 3000) {
|
||||||
status.show = true
|
status.show = true
|
||||||
@@ -140,22 +140,21 @@ async function fetchSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function validateSession() {
|
||||||
try {
|
try {
|
||||||
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||||
currentView.value = 'forbidden'
|
currentView.value = 'forbidden'
|
||||||
emit('forbidden', userInfo.value)
|
emit('forbidden', session.value)
|
||||||
} else {
|
} else {
|
||||||
currentView.value = 'login'
|
currentView.value = 'login'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info', error)
|
session.value = null
|
||||||
|
currentView.value = 'login'
|
||||||
if (error.status !== 401 && error.status !== 403) {
|
if (error.status !== 401 && error.status !== 403) {
|
||||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||||
}
|
}
|
||||||
userInfo.value = null
|
|
||||||
currentView.value = 'login'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +187,7 @@ async function logoutUser() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||||
userInfo.value = null
|
session.value = null
|
||||||
currentView.value = 'login'
|
currentView.value = 'login'
|
||||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -266,7 +265,7 @@ watch(initializing, (newVal) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
await fetchUserInfo()
|
await validateSession()
|
||||||
initializing.value = false
|
initializing.value = false
|
||||||
|
|
||||||
// Add click handler for inline links
|
// Add click handler for inline links
|
||||||
|
|||||||
+11
-7
@@ -485,11 +485,11 @@ def get_session_context(
|
|||||||
user = build_user(s.user)
|
user = build_user(s.user)
|
||||||
role = build_role(role_uuid)
|
role = build_role(role_uuid)
|
||||||
org = build_org(org_uuid)
|
org = build_org(org_uuid)
|
||||||
credential = (
|
|
||||||
build_credential(s.credential)
|
# Credential must exist (sessions are cascade-deleted when credential is deleted)
|
||||||
if s.credential in _db._data.credentials
|
if s.credential not in _db._data.credentials:
|
||||||
else None
|
return None
|
||||||
)
|
credential = build_credential(s.credential)
|
||||||
|
|
||||||
# Effective permissions: role's permissions that the org can grant
|
# Effective permissions: role's permissions that the org can grant
|
||||||
# Also filter by domain if host is provided
|
# Also filter by domain if host is provided
|
||||||
@@ -516,7 +516,7 @@ def get_session_context(
|
|||||||
org=org,
|
org=org,
|
||||||
role=role,
|
role=role,
|
||||||
credential=credential,
|
credential=credential,
|
||||||
permissions=effective_perms or None,
|
permissions=effective_perms,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -956,7 +956,7 @@ def delete_credential(
|
|||||||
*,
|
*,
|
||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a credential.
|
"""Delete a credential and all sessions using it.
|
||||||
|
|
||||||
If user_uuid is provided, validates that the credential belongs to that user.
|
If user_uuid is provided, validates that the credential belongs to that user.
|
||||||
"""
|
"""
|
||||||
@@ -971,6 +971,10 @@ def delete_credential(
|
|||||||
if cred_user != user_uuid:
|
if cred_user != user_uuid:
|
||||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||||
with _db.transaction("Deleted credential", ctx):
|
with _db.transaction("Deleted credential", ctx):
|
||||||
|
# Delete all sessions using this credential
|
||||||
|
keys = [k for k, s in _db._data.sessions.items() if s.credential == uuid]
|
||||||
|
for k in keys:
|
||||||
|
del _db._data.sessions[k]
|
||||||
del _db._data.credentials[uuid]
|
del _db._data.credentials[uuid]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ class SessionContext(msgspec.Struct):
|
|||||||
user: User
|
user: User
|
||||||
org: Org
|
org: Org
|
||||||
role: Role
|
role: Role
|
||||||
credential: Credential | None = None
|
credential: Credential
|
||||||
permissions: list[Permission] | None = None
|
permissions: list[Permission] = []
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
+29
-41
@@ -78,12 +78,7 @@ async def validate_token(
|
|||||||
max_age: str | None = Query(None),
|
max_age: str | None = Query(None),
|
||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
"""Validate the current session and extend its expiry.
|
"""Validate session and return context. Refreshes session expiry."""
|
||||||
|
|
||||||
Always refreshes the session (sliding expiration) and re-sets the cookie with a
|
|
||||||
renewed max-age. This keeps active users logged in without needing a separate
|
|
||||||
refresh endpoint.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -113,8 +108,26 @@ async def validate_token(
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"user_uuid": str(ctx.session.user_uuid),
|
|
||||||
"renewed": renewed,
|
"renewed": renewed,
|
||||||
|
"ctx": userinfo.format_session_context(ctx),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/token-info")
|
||||||
|
async def token_info(credentials=Depends(bearer_auth)):
|
||||||
|
"""Get reset/device-add token info. Pass token via Bearer header."""
|
||||||
|
token = credentials.credentials
|
||||||
|
if not passphrase.is_well_formed(token):
|
||||||
|
raise HTTPException(400, "Invalid token format")
|
||||||
|
try:
|
||||||
|
reset_token = await get_reset(token)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
|
u = db.get_user_by_uuid(reset_token.user_uuid)
|
||||||
|
return {
|
||||||
|
"token_type": reset_token.token_type,
|
||||||
|
"display_name": u.display_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -236,47 +249,22 @@ async def api_token_info(token: str):
|
|||||||
async def api_user_info(
|
async def api_user_info(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
reset: str | None = None,
|
|
||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
"""Get user information including credentials, sessions, and permissions.
|
"""Get full user profile including credentials and sessions."""
|
||||||
|
if auth is None:
|
||||||
Can be called with either:
|
raise authz.AuthException(
|
||||||
- A session cookie (auth) for authenticated users
|
status_code=401,
|
||||||
- A reset token for users in password reset flow
|
detail="Authentication required",
|
||||||
"""
|
mode="login",
|
||||||
authenticated = False
|
)
|
||||||
session_record = None
|
|
||||||
reset_token = None
|
|
||||||
try:
|
try:
|
||||||
if reset:
|
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||||
if not passphrase.is_well_formed(reset):
|
|
||||||
raise ValueError("Invalid reset token")
|
|
||||||
reset_token = await get_reset(reset)
|
|
||||||
target_user_uuid = reset_token.user_uuid
|
|
||||||
else:
|
|
||||||
if auth is None:
|
|
||||||
raise authz.AuthException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Authentication required",
|
|
||||||
mode="login",
|
|
||||||
)
|
|
||||||
session_record = await get_session(auth, host=request.headers.get("host"))
|
|
||||||
authenticated = True
|
|
||||||
target_user_uuid = session_record.user_uuid
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(401, str(e))
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
# Return minimal response for reset tokens
|
|
||||||
if not authenticated and reset_token:
|
|
||||||
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
|
||||||
|
|
||||||
# Return full user info for authenticated users
|
|
||||||
assert auth is not None
|
|
||||||
assert session_record is not None
|
|
||||||
|
|
||||||
return await userinfo.format_user_info(
|
return await userinfo.format_user_info(
|
||||||
user_uuid=target_user_uuid,
|
user_uuid=session_record.user_uuid,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
session_record=session_record,
|
session_record=session_record,
|
||||||
request_host=request.headers.get("host"),
|
request_host=request.headers.get("host"),
|
||||||
|
|||||||
+25
-65
@@ -4,6 +4,7 @@ from datetime import timezone
|
|||||||
|
|
||||||
from paskia import aaguid, db
|
from paskia import aaguid, db
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
|
from paskia.db import SessionContext
|
||||||
from paskia.util import hostutil, permutil, useragent
|
from paskia.util import hostutil, permutil, useragent
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +18,25 @@ def _format_datetime(dt):
|
|||||||
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def format_session_context(ctx: SessionContext) -> dict:
|
||||||
|
"""Format SessionContext for JSON response."""
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"uuid": str(ctx.user.uuid),
|
||||||
|
"display_name": ctx.user.display_name,
|
||||||
|
},
|
||||||
|
"org": {
|
||||||
|
"uuid": str(ctx.org.uuid),
|
||||||
|
"display_name": ctx.org.display_name,
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"uuid": str(ctx.role.uuid),
|
||||||
|
"display_name": ctx.role.display_name,
|
||||||
|
},
|
||||||
|
"permissions": [p.scope for p in ctx.permissions],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def format_user_info(
|
async def format_user_info(
|
||||||
*,
|
*,
|
||||||
user_uuid,
|
user_uuid,
|
||||||
@@ -24,23 +44,7 @@ async def format_user_info(
|
|||||||
session_record,
|
session_record,
|
||||||
request_host: str | None,
|
request_host: str | None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Format complete user information for authenticated users.
|
"""Format complete user information for authenticated users."""
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: UUID of the user to fetch information for
|
|
||||||
auth: Authentication token
|
|
||||||
session_record: Current session record
|
|
||||||
request_host: Host header from the request
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing formatted user information including:
|
|
||||||
- User details
|
|
||||||
- Organization and role information
|
|
||||||
- Credentials list
|
|
||||||
- Sessions list
|
|
||||||
- Permissions
|
|
||||||
"""
|
|
||||||
u = db.get_user_by_uuid(user_uuid)
|
|
||||||
ctx = await permutil.session_context(auth, request_host)
|
ctx = await permutil.session_context(auth, request_host)
|
||||||
|
|
||||||
# Fetch and format credentials
|
# Fetch and format credentials
|
||||||
@@ -66,24 +70,6 @@ async def format_user_info(
|
|||||||
credentials.sort(key=lambda cred: cred["created_at"])
|
credentials.sort(key=lambda cred: cred["created_at"])
|
||||||
aaguid_info = aaguid.filter(user_aaguids)
|
aaguid_info = aaguid.filter(user_aaguids)
|
||||||
|
|
||||||
# Format role and org information
|
|
||||||
role_info = None
|
|
||||||
org_info = None
|
|
||||||
effective_permissions: list[str] = []
|
|
||||||
|
|
||||||
if ctx:
|
|
||||||
role_info = {
|
|
||||||
"uuid": str(ctx.role.uuid),
|
|
||||||
"display_name": ctx.role.display_name,
|
|
||||||
"permissions": ctx.role.permissions,
|
|
||||||
}
|
|
||||||
org_info = {
|
|
||||||
"uuid": str(ctx.org.uuid),
|
|
||||||
"display_name": ctx.org.display_name,
|
|
||||||
"permissions": ctx.org.permissions,
|
|
||||||
}
|
|
||||||
effective_permissions = [p.scope for p in (ctx.permissions or [])]
|
|
||||||
|
|
||||||
# Format sessions
|
# Format sessions
|
||||||
normalized_request_host = hostutil.normalize_host(request_host)
|
normalized_request_host = hostutil.normalize_host(request_host)
|
||||||
session_records = db.list_sessions_for_user(user_uuid)
|
session_records = db.list_sessions_for_user(user_uuid)
|
||||||
@@ -109,37 +95,11 @@ async def format_user_info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"ctx": format_session_context(ctx),
|
||||||
"user": {
|
"created_at": _format_datetime(ctx.user.created_at),
|
||||||
"user_uuid": str(u.uuid),
|
"last_seen": _format_datetime(ctx.user.last_seen),
|
||||||
"user_name": u.display_name,
|
"visits": ctx.user.visits,
|
||||||
"created_at": _format_datetime(u.created_at),
|
|
||||||
"last_seen": _format_datetime(u.last_seen),
|
|
||||||
"visits": u.visits,
|
|
||||||
},
|
|
||||||
"org": org_info,
|
|
||||||
"role": role_info,
|
|
||||||
"permissions": effective_permissions,
|
|
||||||
"credentials": credentials,
|
"credentials": credentials,
|
||||||
"aaguid_info": aaguid_info,
|
"aaguid_info": aaguid_info,
|
||||||
"sessions": sessions_payload,
|
"sessions": sessions_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def format_reset_user_info(user_uuid, reset_token) -> dict:
|
|
||||||
"""Format minimal user information for reset token requests.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_uuid: UUID of the user
|
|
||||||
reset_token: Reset token record
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with minimal user info for password reset flow
|
|
||||||
"""
|
|
||||||
u = db.get_user_by_uuid(user_uuid)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"authenticated": False,
|
|
||||||
"session_type": reset_token.token_type,
|
|
||||||
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user