Implemented auth app authentication in API mode (if loading the app itself wasn't blocked). Removed unnecessary toasts when entering restricted pages.
This commit is contained in:
@@ -82,20 +82,22 @@
|
||||
|
||||
case 'auth-error':
|
||||
showStatus(`⚠ ${data.message || 'Authentication failed'}`, data.cancelled ? 'info' : 'error');
|
||||
if (data.cancelled) {
|
||||
hideAuthIframe();
|
||||
currentApiCall = null;
|
||||
}
|
||||
// Don't hide iframe on error - let user retry
|
||||
break;
|
||||
|
||||
case 'auth-cancelled':
|
||||
showStatus(`Operation cancelled: ${data.message || ''}`, 'info');
|
||||
// Don't hide iframe - deprecated message type
|
||||
break;
|
||||
|
||||
case 'auth-back':
|
||||
showStatus('User clicked Back', 'info');
|
||||
hideAuthIframe();
|
||||
currentApiCall = null;
|
||||
break;
|
||||
|
||||
case 'auth-close-request':
|
||||
// Iframe wants to be closed
|
||||
// Iframe wants to be closed (legacy)
|
||||
hideAuthIframe();
|
||||
break;
|
||||
}
|
||||
|
||||
+169
-7
@@ -2,28 +2,184 @@
|
||||
<div class="app-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<ProfileView v-if="initialized" />
|
||||
<div v-else class="loading-container">
|
||||
<ProfileView v-if="authenticated" />
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
|
||||
const store = useAuthStore()
|
||||
const initialized = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadingMessage = ref('Loading...')
|
||||
const authenticated = ref(false)
|
||||
const showBackMessage = ref(false)
|
||||
let validationTimer = null
|
||||
let authIframe = null
|
||||
|
||||
// Watch for auth required flag from store
|
||||
watch(() => store.authRequired, (required) => {
|
||||
if (required) {
|
||||
authenticated.value = false
|
||||
loading.value = true
|
||||
stopSessionValidation()
|
||||
showAuthIframe()
|
||||
store.clearAuthRequired()
|
||||
}
|
||||
})
|
||||
|
||||
async function tryLoadUserInfo() {
|
||||
try {
|
||||
await store.loadUserInfo()
|
||||
authenticated.value = true
|
||||
loading.value = false
|
||||
startSessionValidation()
|
||||
return true
|
||||
} catch (error) {
|
||||
// User info load failed - likely 401
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthIframe() {
|
||||
// Remove existing iframe if any
|
||||
hideAuthIframe()
|
||||
|
||||
// Create new iframe for authentication
|
||||
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':
|
||||
// Authentication successful - reload user info
|
||||
hideAuthIframe()
|
||||
loading.value = true
|
||||
loadingMessage.value = 'Loading user profile...'
|
||||
store.clearAuthRequired()
|
||||
tryLoadUserInfo()
|
||||
break
|
||||
|
||||
case 'auth-error':
|
||||
// Authentication failed - keep iframe open so user can retry
|
||||
if (data.cancelled) {
|
||||
console.log('Authentication cancelled by user')
|
||||
} else {
|
||||
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-cancelled':
|
||||
// Legacy support - treat as auth-error with cancelled flag
|
||||
console.log('Authentication cancelled')
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
// User clicked Back - show message with reload option
|
||||
hideAuthIframe()
|
||||
loading.value = false
|
||||
showBackMessage.value = true
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
// Legacy support - treat as back
|
||||
hideAuthIframe()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
const response = await fetch('/auth/api/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Session expired - need to re-authenticate
|
||||
console.log('Session expired, requiring re-authentication')
|
||||
authenticated.value = false
|
||||
loading.value = true
|
||||
stopSessionValidation()
|
||||
showAuthIframe()
|
||||
}
|
||||
// If successful, session was renewed automatically
|
||||
} catch (error) {
|
||||
console.error('Session validation error:', error)
|
||||
// Don't treat network errors as session expiry
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionValidation() {
|
||||
// Validate session every 2 minutes
|
||||
stopSessionValidation()
|
||||
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
|
||||
}
|
||||
|
||||
function stopSessionValidation() {
|
||||
if (validationTimer) {
|
||||
clearInterval(validationTimer)
|
||||
validationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for postMessage from auth iframe
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
|
||||
// Load settings
|
||||
await store.loadSettings()
|
||||
if (store.settings?.rp_name) document.title = store.settings.rp_name
|
||||
try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ }
|
||||
initialized.value = true
|
||||
|
||||
// Try to load user info
|
||||
const success = await tryLoadUserInfo()
|
||||
|
||||
if (!success) {
|
||||
// Need authentication - show login iframe
|
||||
showAuthIframe()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleAuthMessage)
|
||||
stopSessionValidation()
|
||||
hideAuthIframe()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -32,4 +188,10 @@ onMounted(async () => {
|
||||
.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>
|
||||
|
||||
@@ -715,3 +715,21 @@ th {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Auth iframe overlay styles */
|
||||
body:has(#auth-iframe) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#auth-iframe {
|
||||
border: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
color-scheme: auto;
|
||||
backdrop-filter: blur(4px) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(4px) brightness(0.7);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ const saveName = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
||||
if (res.status === 401) {
|
||||
authStore.authRequired = true
|
||||
authStore.showMessage('Authentication required', 'error')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
showNameDialog.value = false
|
||||
|
||||
@@ -64,6 +64,9 @@
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
@@ -90,6 +93,11 @@ const expirationMessage = computed(() => {
|
||||
async function fetchLink() {
|
||||
try {
|
||||
const res = await fetch(props.endpoint, { method: 'POST' })
|
||||
if (res.status === 401) {
|
||||
authStore.authRequired = true
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
url.value = data.url
|
||||
|
||||
@@ -116,21 +116,13 @@ async function fetchSettings() {
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const payload = await safeParseJson(res)
|
||||
showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000)
|
||||
return
|
||||
}
|
||||
if (!res.ok) return
|
||||
userInfo.value = await res.json()
|
||||
// In login mode, if the user is authenticated but still here, they lack permissions.
|
||||
// In reauth mode, being authenticated is expected - we just need re-verification.
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
showMessage('Permission Denied', 'error', 2000)
|
||||
emit('forbidden', userInfo.value)
|
||||
}
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') emit('forbidden', userInfo.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
showMessage('Could not contact the authentication server', 'error', 2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,8 @@
|
||||
@forbidden="handleForbidden"
|
||||
@logout="handleLogout"
|
||||
@auth-error="handleAuthError"
|
||||
>
|
||||
<template #actions="{ loading, canAuthenticate, isAuthenticated, authenticate, logout, mode }">
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticate">
|
||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||
</button>
|
||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logout">Logout</button>
|
||||
<button class="btn-secondary" :disabled="loading" @click="handleCancel">Cancel</button>
|
||||
</template>
|
||||
</RestrictedAuth>
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -65,37 +58,14 @@ function handleAuthError({ message, cancelled }) {
|
||||
message: message || 'Authentication failed',
|
||||
cancelled
|
||||
})
|
||||
|
||||
// If it was a cancellation, attempt to close
|
||||
if (cancelled) {
|
||||
tryClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
console.log('[RestrictedApiApp] Cancel clicked')
|
||||
// Notify parent that the operation was cancelled/incomplete
|
||||
function handleBack() {
|
||||
console.log('[RestrictedApiApp] Back clicked')
|
||||
// Notify parent that user wants to go back
|
||||
postToParent({
|
||||
type: 'auth-cancelled',
|
||||
message: 'Authentication cancelled'
|
||||
type: 'auth-back'
|
||||
})
|
||||
|
||||
// Attempt to close the iframe
|
||||
tryClose()
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
console.log('[RestrictedApiApp] tryClose called')
|
||||
// Signal to parent that we'd like to be removed
|
||||
// Parent can listen for this and remove the iframe element
|
||||
postToParent({
|
||||
type: 'auth-close-request'
|
||||
})
|
||||
|
||||
// Try to close (doesn't work for iframes but harmless to try)
|
||||
try {
|
||||
window.close()
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -113,10 +83,10 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Handle Escape key to cancel
|
||||
// Handle Escape key to trigger back navigation
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleCancel()
|
||||
handleBack()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import { uiBasePath } from '@/utils/settings'
|
||||
|
||||
@@ -44,4 +44,13 @@ function backNav() {
|
||||
} catch (_) { /* ignore */ }
|
||||
returnHome()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Handle Escape key to trigger back navigation
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
backNav()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
// Auth State
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||
isLoading: false,
|
||||
authRequired: false, // Flag to trigger auth iframe
|
||||
|
||||
// Settings
|
||||
settings: null,
|
||||
@@ -25,6 +26,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
setLoading(flag) {
|
||||
this.isLoading = !!flag
|
||||
},
|
||||
clearAuthRequired() {
|
||||
this.authRequired = false
|
||||
},
|
||||
showMessage(message, type = 'info', duration = 3000) {
|
||||
this.status = {
|
||||
message,
|
||||
@@ -89,9 +93,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors (unlikely)
|
||||
}
|
||||
if (response.status === 401 && result?.detail) {
|
||||
this.showMessage(result.detail, 'error', 5000)
|
||||
throw new Error(result.detail)
|
||||
if (response.status === 401) {
|
||||
this.authRequired = true
|
||||
throw new Error(result?.detail || 'Authentication required')
|
||||
}
|
||||
if (result?.detail) {
|
||||
// Other error style
|
||||
@@ -103,6 +107,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async deleteCredential(uuid) {
|
||||
const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'})
|
||||
if (response.status === 401) {
|
||||
this.authRequired = true
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
const result = await response.json()
|
||||
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
||||
|
||||
@@ -111,6 +119,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
async terminateSession(sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||
if (res.status === 401) {
|
||||
this.authRequired = true
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
let payload = null
|
||||
try {
|
||||
payload = await res.json()
|
||||
|
||||
Reference in New Issue
Block a user