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:
2025-12-02 15:25:31 +00:00
parent d4f8e97469
commit 3030122807
9 changed files with 243 additions and 65 deletions
+7 -5
View File
@@ -82,20 +82,22 @@
case 'auth-error': case 'auth-error':
showStatus(`${data.message || 'Authentication failed'}`, data.cancelled ? 'info' : 'error'); showStatus(`${data.message || 'Authentication failed'}`, data.cancelled ? 'info' : 'error');
if (data.cancelled) { // Don't hide iframe on error - let user retry
hideAuthIframe();
currentApiCall = null;
}
break; break;
case 'auth-cancelled': case 'auth-cancelled':
showStatus(`Operation cancelled: ${data.message || ''}`, 'info'); showStatus(`Operation cancelled: ${data.message || ''}`, 'info');
// Don't hide iframe - deprecated message type
break;
case 'auth-back':
showStatus('User clicked Back', 'info');
hideAuthIframe(); hideAuthIframe();
currentApiCall = null; currentApiCall = null;
break; break;
case 'auth-close-request': case 'auth-close-request':
// Iframe wants to be closed // Iframe wants to be closed (legacy)
hideAuthIframe(); hideAuthIframe();
break; break;
} }
+169 -7
View File
@@ -2,28 +2,184 @@
<div class="app-shell"> <div class="app-shell">
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<ProfileView v-if="initialized" /> <ProfileView v-if="authenticated" />
<div v-else class="loading-container"> <div v-else-if="loading" class="loading-container">
<div class="loading-spinner"></div> <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> </div>
</main> </main>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' 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'
const store = useAuthStore() 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 () => { onMounted(async () => {
// Listen for postMessage from auth iframe
window.addEventListener('message', handleAuthMessage)
// Load settings
await store.loadSettings() await store.loadSettings()
if (store.settings?.rp_name) document.title = store.settings.rp_name 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> </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; } .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); } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-container p { color: var(--color-text-muted); margin: 0; } .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>
+18
View File
@@ -715,3 +715,21 @@ th {
padding: 1.5rem; 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);
}
+5
View File
@@ -173,6 +173,11 @@ const saveName = async () => {
try { try {
saving.value = true 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 }) }) 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() const data = await res.json()
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
showNameDialog.value = false showNameDialog.value = false
@@ -64,6 +64,9 @@
import { ref, onMounted, watch, computed, nextTick } from 'vue' import { ref, onMounted, watch, computed, nextTick } from 'vue'
import QRCode from 'qrcode/lib/browser' import QRCode from 'qrcode/lib/browser'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const props = defineProps({ const props = defineProps({
endpoint: { type: String, required: true }, endpoint: { type: String, required: true },
@@ -90,6 +93,11 @@ const expirationMessage = computed(() => {
async function fetchLink() { async function fetchLink() {
try { try {
const res = await fetch(props.endpoint, { method: 'POST' }) const res = await fetch(props.endpoint, { method: 'POST' })
if (res.status === 401) {
authStore.authRequired = true
emit('close')
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)
url.value = data.url url.value = data.url
+2 -10
View File
@@ -116,21 +116,13 @@ async function fetchSettings() {
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
const res = await fetch('/auth/api/user-info', { method: 'POST' }) const res = await fetch('/auth/api/user-info', { method: 'POST' })
if (!res.ok) { if (!res.ok) return
const payload = await safeParseJson(res)
showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000)
return
}
userInfo.value = await res.json() userInfo.value = await res.json()
// In login mode, if the user is authenticated but still here, they lack permissions. // 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. // In reauth mode, being authenticated is expected - we just need re-verification.
if (isAuthenticated.value && props.mode !== 'reauth') { if (isAuthenticated.value && props.mode !== 'reauth') emit('forbidden', userInfo.value)
showMessage('Permission Denied', 'error', 2000)
emit('forbidden', userInfo.value)
}
} catch (error) { } catch (error) {
console.error('Failed to load user info', error) console.error('Failed to load user info', error)
showMessage('Could not contact the authentication server', 'error', 2000)
} }
} }
@@ -5,15 +5,8 @@
@forbidden="handleForbidden" @forbidden="handleForbidden"
@logout="handleLogout" @logout="handleLogout"
@auth-error="handleAuthError" @auth-error="handleAuthError"
> @back="handleBack"
<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>
</template> </template>
<script setup> <script setup>
@@ -65,37 +58,14 @@ function handleAuthError({ message, cancelled }) {
message: message || 'Authentication failed', message: message || 'Authentication failed',
cancelled cancelled
}) })
// If it was a cancellation, attempt to close
if (cancelled) {
tryClose()
}
} }
function handleCancel() { function handleBack() {
console.log('[RestrictedApiApp] Cancel clicked') console.log('[RestrictedApiApp] Back clicked')
// Notify parent that the operation was cancelled/incomplete // Notify parent that user wants to go back
postToParent({ postToParent({
type: 'auth-cancelled', type: 'auth-back'
message: 'Authentication cancelled'
}) })
// 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(() => { onMounted(() => {
@@ -113,10 +83,10 @@ onMounted(() => {
} }
}) })
// Handle Escape key to cancel // Handle Escape key to trigger back navigation
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
handleCancel() handleBack()
} }
}) })
}) })
+10 -1
View File
@@ -9,7 +9,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
import { uiBasePath } from '@/utils/settings' import { uiBasePath } from '@/utils/settings'
@@ -44,4 +44,13 @@ function backNav() {
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
returnHome() returnHome()
} }
onMounted(() => {
// Handle Escape key to trigger back navigation
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
backNav()
}
})
})
</script> </script>
+16 -4
View File
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', {
// Auth State // Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
isLoading: false, isLoading: false,
authRequired: false, // Flag to trigger auth iframe
// Settings // Settings
settings: null, settings: null,
@@ -25,6 +26,9 @@ export const useAuthStore = defineStore('auth', {
setLoading(flag) { setLoading(flag) {
this.isLoading = !!flag this.isLoading = !!flag
}, },
clearAuthRequired() {
this.authRequired = false
},
showMessage(message, type = 'info', duration = 3000) { showMessage(message, type = 'info', duration = 3000) {
this.status = { this.status = {
message, message,
@@ -89,9 +93,9 @@ export const useAuthStore = defineStore('auth', {
} catch (_) { } catch (_) {
// ignore JSON parse errors (unlikely) // ignore JSON parse errors (unlikely)
} }
if (response.status === 401 && result?.detail) { if (response.status === 401) {
this.showMessage(result.detail, 'error', 5000) this.authRequired = true
throw new Error(result.detail) throw new Error(result?.detail || 'Authentication required')
} }
if (result?.detail) { if (result?.detail) {
// Other error style // Other error style
@@ -102,7 +106,11 @@ export const useAuthStore = defineStore('auth', {
console.log('User info loaded:', result) console.log('User info loaded:', result)
}, },
async deleteCredential(uuid) { async deleteCredential(uuid) {
const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'}) 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() const result = await response.json()
if (result.detail) throw new Error(`Server: ${result.detail}`) if (result.detail) throw new Error(`Server: ${result.detail}`)
@@ -111,6 +119,10 @@ export const useAuthStore = defineStore('auth', {
async terminateSession(sessionId) { async terminateSession(sessionId) {
try { try {
const res = await fetch(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' }) 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 let payload = null
try { try {
payload = await res.json() payload = await res.json()