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:
2026-01-26 19:40:48 +00:00
parent fbc6108b7a
commit 7e568dbd10
12 changed files with 183 additions and 238 deletions
+12 -12
View File
@@ -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 })
<template>
<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">
<button v-if="isGlobalAdmin" @click="$emit('createOrg')">+ Create Org</button>
<button v-if="isMasterAdmin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
<thead>
@@ -259,18 +259,18 @@ defineExpose({ focusFirstElement })
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="isGlobalAdmin">Actions</th>
<th v-if="isMasterAdmin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<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 class="role-names">{{ getRoleNames(o) }}</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>
</td>
</tr>
@@ -278,7 +278,7 @@ defineExpose({ focusFirstElement })
</table>
</div>
<div v-if="isGlobalAdmin" class="permissions-section">
<div v-if="isMasterAdmin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<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>
</div>
<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>
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
<thead>
+18 -4
View File
@@ -1,10 +1,11 @@
<template>
<div class="message-container">
<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">
<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>
@@ -13,7 +14,15 @@
<script setup>
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>
<style scoped>
@@ -32,10 +41,15 @@ defineEmits(['reload'])
}
.message-content h2 {
margin: 0 0 1.5rem;
margin: 0 0 1rem;
color: var(--color-heading);
}
.message-content .error-detail {
margin: 0 0 1.5rem;
color: var(--color-text-muted);
}
.message-content .button-row {
display: flex;
gap: 0.75rem;
+8 -8
View File
@@ -8,11 +8,11 @@
<section class="section-block" ref="userInfoSection">
<div class="section-body">
<UserBasicInfo
v-if="user"
:name="user.user_name"
:visits="user.visits || 0"
:created-at="user.created_at"
:last-seen="user.last_seen"
v-if="ctx"
:name="ctx.user.display_name"
:visits="authStore.userInfo?.visits || 0"
:created-at="authStore.userInfo?.created_at"
:last-seen="authStore.userInfo?.last_seen"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
@@ -78,9 +78,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null)
const buttonRow = ref(null)
const user = computed(() => authStore.userInfo?.user || null)
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
const ctx = computed(() => authStore.userInfo?.ctx || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
const headingTitle = computed(() => {
const service = authStore.settings?.rp_name
+8 -8
View File
@@ -8,12 +8,12 @@
<section class="section-block" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.user"
v-if="authStore.userInfo?.ctx"
ref="userBasicInfo"
:name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:name="authStore.userInfo.ctx.user.display_name"
:visits="authStore.userInfo.visits"
:created-at="authStore.userInfo.created_at"
:last-seen="authStore.userInfo.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
@@ -151,7 +151,7 @@ const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
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(() => {
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 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 perms = authStore.userInfo?.permissions ?? []
const perms = authStore.userInfo?.ctx.permissions
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
+10 -11
View File
@@ -76,13 +76,13 @@ const status = reactive({ show: false, message: '', type: 'info' })
const initializing = ref(true)
const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
const session = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local' or 'remote'
const buttonRow = ref(null)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const isAuthenticated = computed(() => !!session.value)
const canAuthenticate = computed(() => {
if (initializing.value) return false
@@ -115,7 +115,7 @@ const headerMessage = computed(() => {
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) {
status.show = true
@@ -140,22 +140,21 @@ async function fetchSettings() {
}
}
async function fetchUserInfo() {
async function validateSession() {
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') {
currentView.value = 'forbidden'
emit('forbidden', userInfo.value)
emit('forbidden', session.value)
} else {
currentView.value = 'login'
}
} catch (error) {
console.error('Failed to load user info', error)
session.value = null
currentView.value = 'login'
if (error.status !== 401 && error.status !== 403) {
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
}
userInfo.value = null
currentView.value = 'login'
}
}
@@ -188,7 +187,7 @@ async function logoutUser() {
loading.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST' })
userInfo.value = null
session.value = null
currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
} catch (error) {
@@ -266,7 +265,7 @@ watch(initializing, (newVal) => {
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
await validateSession()
initializing.value = false
// Add click handler for inline links