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

This commit is contained in:
Leo Vasanko
2025-12-02 15:42:55 +00:00
parent d1a7a53c19
commit 77d8e97dc9
4 changed files with 205 additions and 31 deletions
+4 -23
View File
@@ -3,19 +3,8 @@
<StatusMessage />
<main class="app-main">
<ProfileView v-if="authenticated" />
<div v-else-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<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>
<LoadingView v-else-if="loading" :message="loadingMessage" />
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
</main>
</div>
</template>
@@ -25,6 +14,8 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
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'
const store = useAuthStore()
const loading = ref(true)
@@ -184,14 +175,4 @@ onUnmounted(() => {
</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; }
.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>
+102 -8
View File
@@ -1,10 +1,12 @@
<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 CredentialList from '@/components/CredentialList.vue'
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 AdminOverview from './AdminOverview.vue'
import AdminOrgDetail from './AdminOrgDetail.vue'
import AdminUserDetail from './AdminUserDetail.vue'
@@ -14,6 +16,9 @@ import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
const info = ref(null)
const loading = ref(true)
const loadingMessage = ref('Loading...')
const authenticated = ref(false)
const showBackMessage = ref(false)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
@@ -31,6 +36,16 @@ const editingPermDisplay = ref(null)
const renameDisplayValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' })
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, '') }
@@ -135,9 +150,12 @@ function parseHash() {
async function loadOrgs() {
const res = await fetch('/auth/admin/orgs')
if (res.status === 401) {
authStore.authRequired = true
throw new Error('Authentication required')
}
const data = await res.json()
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 => {
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
@@ -150,6 +168,10 @@ async function loadOrgs() {
async function loadPermissions() {
const res = await fetch('/auth/admin/permissions')
if (res.status === 401) {
authStore.authRequired = true
throw new Error('Authentication required')
}
const data = await res.json()
if (data.detail) throw new Error(data.detail)
permissions.value = data
@@ -157,16 +179,22 @@ async function loadPermissions() {
async function load() {
loading.value = true
loadingMessage.value = 'Loading...'
error.value = null
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()
if (data.detail) throw new Error(data.detail)
info.value = data
authenticated.value = true
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
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 (!window.location.hash || window.location.hash === '#overview') {
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 () => {
window.addEventListener('message', handleAuthMessage)
window.addEventListener('hashchange', parseHash)
const settings = await getSettings()
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)
@@ -484,7 +573,13 @@ async function submitDialog() {
<div class="app-shell admin-shell">
<StatusMessage />
<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">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
@@ -492,8 +587,7 @@ async function submitDialog() {
<section class="section-block admin-section">
<div class="section-body admin-section-body">
<div v-if="loading" class="surface surface--tight">Loading</div>
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
<template v-else>
<div v-if="!info?.authenticated" class="surface surface--tight">
<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>