Update admin app authentication in API mode too, reusing components between it and the main app.

This commit is contained in:
2025-12-02 15:42:55 +00:00
parent 3030122807
commit c1ccb048f0
4 changed files with 205 additions and 31 deletions
+4 -23
View File
@@ -3,19 +3,8 @@
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<ProfileView v-if="authenticated" /> <ProfileView v-if="authenticated" />
<div v-else-if="loading" class="loading-container"> <LoadingView v-else-if="loading" :message="loadingMessage" />
<div class="loading-spinner"></div> <AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
<p>{{ loadingMessage }}</p>
</div>
<div v-else-if="showBackMessage" class="message-container">
<div class="message-content">
<h2>🔒 Authentication Required</h2>
<p>You need to authenticate to access this page.</p>
<div class="button-row">
<button class="btn-primary" @click="reloadPage">Reload Page</button>
</div>
</div>
</div>
</main> </main>
</div> </div>
</template> </template>
@@ -25,6 +14,8 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' 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 AuthRequiredMessage from '@/components/AuthRequiredMessage.vue'
const store = useAuthStore() const store = useAuthStore()
const loading = ref(true) const loading = ref(true)
@@ -184,14 +175,4 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 1rem; }
.loading-spinner { width: 40px; height: 40px; border: 4px solid var(--color-border); border-top: 4px solid var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-container p { color: var(--color-text-muted); margin: 0; }
.message-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; padding: 2rem; }
.message-content { text-align: center; max-width: 480px; }
.message-content h2 { margin: 0 0 1rem; color: var(--color-heading); }
.message-content p { color: var(--color-text-muted); margin: 0 0 1.5rem; }
.message-content .button-row { display: flex; gap: 0.75rem; justify-content: center; }
</style> </style>
+101 -7
View File
@@ -1,10 +1,12 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue' import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' 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 AuthRequiredMessage from '@/components/AuthRequiredMessage.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'
@@ -14,6 +16,9 @@ import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
const loadingMessage = ref('Loading...')
const authenticated = ref(false)
const showBackMessage = ref(false)
const error = ref(null) const error = ref(null)
const orgs = ref([]) const orgs = ref([])
const permissions = ref([]) const permissions = ref([])
@@ -31,6 +36,16 @@ const editingPermDisplay = ref(null)
const renameDisplayValue = ref('') const renameDisplayValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' }) const dialog = ref({ type: null, data: null, busy: false, error: '' })
const safeIdRegex = /[^A-Za-z0-9:._~-]/g const safeIdRegex = /[^A-Za-z0-9:._~-]/g
let authIframe = null
watch(() => authStore.authRequired, (required) => {
if (required) {
authenticated.value = false
loading.value = true
showAuthIframe()
authStore.clearAuthRequired()
}
})
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
@@ -135,9 +150,12 @@ function parseHash() {
async function loadOrgs() { async function loadOrgs() {
const res = await fetch('/auth/admin/orgs') const res = await fetch('/auth/admin/orgs')
if (res.status === 401) {
authStore.authRequired = true
throw new Error('Authentication required')
}
const data = await res.json() const data = await res.json()
if (data.detail) throw new Error(data.detail) if (data.detail) throw new Error(data.detail)
// Restructure to attach users to roles instead of flat user list at org level
orgs.value = data.map(o => { orgs.value = data.map(o => {
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] })) const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r])) const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
@@ -150,6 +168,10 @@ async function loadOrgs() {
async function loadPermissions() { async function loadPermissions() {
const res = await fetch('/auth/admin/permissions') const res = await fetch('/auth/admin/permissions')
if (res.status === 401) {
authStore.authRequired = true
throw new Error('Authentication required')
}
const data = await res.json() const data = await res.json()
if (data.detail) throw new Error(data.detail) if (data.detail) throw new Error(data.detail)
permissions.value = data permissions.value = data
@@ -157,16 +179,22 @@ async function loadPermissions() {
async function load() { async function load() {
loading.value = true loading.value = true
loadingMessage.value = 'Loading...'
error.value = null error.value = null
try { try {
const res = await fetch('/auth/api/user-info', { method: 'POST' }) const res = await fetch('/auth/api/user-info', { method: 'POST' })
if (res.status === 401) {
authStore.authRequired = true
loading.value = true
return
}
const data = await res.json() const data = await res.json()
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
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()])
} }
// After loading orgs decide view if not global admin
if (!data.is_global_admin && data.is_org_admin && orgs.value.length === 1) { if (!data.is_global_admin && data.is_org_admin && 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
@@ -288,11 +316,72 @@ function deletePermission(p) {
} }) } })
} }
function showAuthIframe() {
hideAuthIframe()
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.src = '/auth/restricted-api/?mode=login'
document.body.appendChild(authIframe)
loadingMessage.value = 'Authentication required...'
}
function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
}
}
function reloadPage() {
window.location.reload()
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
loading.value = true
loadingMessage.value = 'Loading admin panel...'
authStore.clearAuthRequired()
load()
break
case 'auth-error':
if (!data.cancelled) {
authStore.showMessage(data.message || 'Authentication failed', 'error', 5000)
}
break
case 'auth-back':
hideAuthIframe()
loading.value = false
showBackMessage.value = true
break
case 'auth-close-request':
hideAuthIframe()
break
}
}
onMounted(async () => { onMounted(async () => {
window.addEventListener('message', handleAuthMessage)
window.addEventListener('hashchange', parseHash) window.addEventListener('hashchange', parseHash)
const settings = await getSettings() const settings = await getSettings()
if (settings?.rp_name) document.title = settings.rp_name + ' Admin' if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
load() await load()
if (authStore.authRequired) {
showAuthIframe()
}
})
onUnmounted(() => {
window.removeEventListener('message', handleAuthMessage)
hideAuthIframe()
}) })
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
@@ -484,7 +573,13 @@ async function submitDialog() {
<div class="app-shell admin-shell"> <div class="app-shell admin-shell">
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<section class="view-root view-root--wide view-admin"> <LoadingView v-if="loading" :message="loadingMessage" />
<AuthRequiredMessage
v-else-if="showBackMessage"
message="You need to authenticate to access the admin panel."
@reload="reloadPage"
/>
<section v-else 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" />
@@ -492,8 +587,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="loading" class="surface surface--tight">Loading</div> <div v-if="error" class="surface surface--tight error">{{ error }}</div>
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
<template v-else> <template v-else>
<div v-if="!info?.authenticated" class="surface surface--tight"> <div v-if="!info?.authenticated" class="surface surface--tight">
<p>You must be authenticated.</p> <p>You must be authenticated.</p>
@@ -0,0 +1,54 @@
<template>
<div class="message-container">
<div class="message-content">
<h2>🔒 Authentication Required</h2>
<p>{{ message }}</p>
<div class="button-row">
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
message: {
type: String,
default: 'You need to authenticate to access this page.'
}
})
defineEmits(['reload'])
</script>
<style scoped>
.message-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 2rem;
}
.message-content {
text-align: center;
max-width: 480px;
}
.message-content h2 {
margin: 0 0 1rem;
color: var(--color-heading);
}
.message-content p {
color: var(--color-text-muted);
margin: 0 0 1.5rem;
}
.message-content .button-row {
display: flex;
gap: 0.75rem;
justify-content: center;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="loading-container">
<div class="loading-spinner"></div>
<p>{{ message }}</p>
</div>
</template>
<script setup>
defineProps({
message: {
type: String,
default: 'Loading...'
}
})
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-container p {
color: var(--color-text-muted);
margin: 0;
}
</style>