Unify host profile view as framed dialog, add profile iframe mode
HostProfileView now renders the same centered frame card as the login flows, whether shown full-page at /auth/ (host mode) or inside the new #mode=profile restricted iframe. The component self-fetches its data when the parent does not provide it, and emits back/logout so each context reacts appropriately: the full page reloads, the iframe posts auth-back / auth-logout to the host.
This commit is contained in:
+15
-1
@@ -2,7 +2,14 @@
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
|
<HostProfileView
|
||||||
|
v-if="viewState === 'profile' && isHostMode"
|
||||||
|
:ctx="store.ctx"
|
||||||
|
:user-info="store.userInfo"
|
||||||
|
:settings="store.settings"
|
||||||
|
@back="goBack"
|
||||||
|
@logout="onHostLogout"
|
||||||
|
/>
|
||||||
<ProfileView v-else-if="viewState === 'profile'" />
|
<ProfileView v-else-if="viewState === 'profile'" />
|
||||||
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
|
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
|
||||||
<AccessDenied v-else-if="viewState === 'terminal'" />
|
<AccessDenied v-else-if="viewState === 'terminal'" />
|
||||||
@@ -15,6 +22,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||||
import { updateThemeFromSession } from '@/utils/theme'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
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 HostProfileView from '@/components/HostProfileView.vue'
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
@@ -48,6 +56,12 @@ const isHostMode = computed(() => {
|
|||||||
return currentHost !== configuredHost
|
return currentHost !== configuredHost
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// HostProfileView already posted /auth/api/logout; clear local state and reload.
|
||||||
|
function onHostLogout() {
|
||||||
|
sessionStorage.clear()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
function onSessionLost(e) {
|
function onSessionLost(e) {
|
||||||
store.userInfo = null
|
store.userInfo = null
|
||||||
store.ctx = null
|
store.ctx = null
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<HostProfileView
|
||||||
|
v-if="authMode === 'profile'"
|
||||||
|
@back="handleBack"
|
||||||
|
@logout="handleLogout"
|
||||||
|
/>
|
||||||
<RestrictedAuth
|
<RestrictedAuth
|
||||||
|
v-else
|
||||||
:mode="authMode"
|
:mode="authMode"
|
||||||
:remote-auth-token="remoteAuthToken"
|
:remote-auth-token="remoteAuthToken"
|
||||||
:oidc-query-string="oidcQueryString"
|
:oidc-query-string="oidcQueryString"
|
||||||
@@ -11,6 +17,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
|
|
||||||
// Check if this is a remote auth URL: /auth/{token}
|
// Check if this is a remote auth URL: /auth/{token}
|
||||||
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||||
@@ -45,8 +52,8 @@ let authMode
|
|||||||
if (window.location.pathname === '/auth/restricted/oidc') {
|
if (window.location.pathname === '/auth/restricted/oidc') {
|
||||||
authMode = 'oidc'
|
authMode = 'oidc'
|
||||||
} else {
|
} else {
|
||||||
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
|
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth/profile)
|
||||||
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
authMode = ['reauth', 'forbidden', 'profile'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
||||||
}
|
}
|
||||||
|
|
||||||
function postToParent(message) {
|
function postToParent(message) {
|
||||||
@@ -74,6 +81,12 @@ function handleBack() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
postToParent({
|
||||||
|
type: 'auth-logout'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Check for remote auth token in URL
|
// Check for remote auth token in URL
|
||||||
remoteAuthToken.value = extractRemoteToken()
|
remoteAuthToken.value = extractRemoteToken()
|
||||||
|
|||||||
@@ -1,92 +1,115 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root view-root--wide host-view" data-view="host-profile">
|
<div class="view-root host-profile" data-view="host-profile">
|
||||||
<header class="view-header">
|
<div class="surface surface--tight">
|
||||||
<h1>{{ headingTitle }}</h1>
|
<header class="view-header center">
|
||||||
<p class="view-lede">{{ subheading }}</p>
|
<h1>{{ headingTitle }}</h1>
|
||||||
</header>
|
<p class="view-lede">{{ subheading }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block">
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="ctx"
|
v-if="sessionCtx && info"
|
||||||
:name="ctx.user.display_name"
|
:name="sessionCtx.user.display_name"
|
||||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
:avatar-url="info.user.avatar_url"
|
||||||
:visits="authStore.userInfo.user.visits"
|
:visits="info.user.visits"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="info.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="info.user.last_seen"
|
||||||
:email="ctx.user.email"
|
:email="sessionCtx.user.email"
|
||||||
:telephone="ctx.user.telephone"
|
:telephone="sessionCtx.user.telephone"
|
||||||
:org-display-name="orgDisplayName"
|
:org-display-name="orgDisplayName"
|
||||||
:role-name="roleDisplayName"
|
:role-name="roleDisplayName"
|
||||||
:can-edit="false"
|
:can-edit="false"
|
||||||
/>
|
/>
|
||||||
<p v-else class="empty-state">
|
<p v-else class="empty-state">
|
||||||
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
{{ loading ? 'Loading your account…' : 'No active session found.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section-block">
|
|
||||||
<div class="section-body host-actions">
|
|
||||||
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary"
|
|
||||||
@click="goBack"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
:disabled="authStore.isLoading"
|
|
||||||
@click="logout"
|
|
||||||
>
|
|
||||||
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="authSiteUrl"
|
|
||||||
type="button"
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="authStore.isLoading"
|
|
||||||
@click="goToAuthSite"
|
|
||||||
>
|
|
||||||
Full Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
</section>
|
||||||
</div>
|
|
||||||
</section>
|
<section class="section-block">
|
||||||
</section>
|
<div class="section-body host-actions">
|
||||||
|
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="$emit('back')"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="sessionCtx"
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
{{ busy ? 'Signing out…' : 'Logout' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="goToAuthSite"
|
||||||
|
>
|
||||||
|
Full Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="isRemoteAuthSite" class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||||
|
<p v-else class="note"><strong>Logout</strong> from {{ currentHost }}, or open your <strong>Full Profile</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { goBack } from '@/utils/helpers'
|
import { fetchJson, settings as paskiaSettings } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import { getDirection, navigateButtonRow } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow } from '@/utils/keynav'
|
||||||
|
|
||||||
defineProps({
|
// Data may be provided by the parent (full-page /auth/ app already loaded it
|
||||||
initializing: {
|
// into the store); otherwise the component fetches it itself (restricted iframe).
|
||||||
type: Boolean,
|
const props = defineProps({
|
||||||
default: false
|
ctx: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const emit = defineEmits(['back', 'logout'])
|
||||||
|
|
||||||
|
const inIframe = window.parent !== window
|
||||||
const currentHost = window.location.host
|
const currentHost = window.location.host
|
||||||
|
|
||||||
|
const fetchedCtx = ref(null)
|
||||||
|
const fetchedInfo = ref(null)
|
||||||
|
const fetchedSettings = ref(null)
|
||||||
|
const loading = ref(!(props.ctx && props.userInfo))
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const userInfoSection = ref(null)
|
|
||||||
const buttonRow = ref(null)
|
const buttonRow = ref(null)
|
||||||
|
|
||||||
const ctx = computed(() => authStore.userInfo || null)
|
const sessionCtx = computed(() => props.ctx || fetchedCtx.value)
|
||||||
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
|
const info = computed(() => props.userInfo || fetchedInfo.value)
|
||||||
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
|
const settingsData = computed(() => props.settings || fetchedSettings.value)
|
||||||
|
const orgDisplayName = computed(() => sessionCtx.value?.org?.display_name ?? '')
|
||||||
|
const roleDisplayName = computed(() => sessionCtx.value?.role?.display_name ?? '')
|
||||||
|
|
||||||
const headingTitle = computed(() => {
|
const headingTitle = computed(() => {
|
||||||
const service = authStore.settings?.rp_name
|
const service = settingsData.value?.rp_name
|
||||||
return service ? `${service} account` : 'Account overview'
|
return service ? `${service} account` : 'Account overview'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,11 +117,17 @@ const subheading = computed(() => {
|
|||||||
return `You're signed in to ${currentHost}.`
|
return `You're signed in to ${currentHost}.`
|
||||||
})
|
})
|
||||||
|
|
||||||
const authSiteHost = computed(() => authStore.settings?.auth_host || '')
|
const authSiteHost = computed(() => settingsData.value?.auth_host || '')
|
||||||
|
// Normalize for comparison (lowercase, strip default ports), matching App.vue
|
||||||
|
const normalizeHost = (raw) => (raw || '').trim().toLowerCase().replace(/:80$/, '').replace(/:443$/, '')
|
||||||
|
const isRemoteAuthSite = computed(() => {
|
||||||
|
return !!authSiteHost.value && normalizeHost(authSiteHost.value) !== normalizeHost(currentHost)
|
||||||
|
})
|
||||||
const authSiteUrl = computed(() => {
|
const authSiteUrl = computed(() => {
|
||||||
const host = authSiteHost.value
|
// Fall back to the current host when no separate auth host is configured;
|
||||||
if (!host) return ''
|
// the full profile is at ui_base_path either way.
|
||||||
let path = authStore.settings?.ui_base_path ?? '/auth/'
|
const host = authSiteHost.value || currentHost
|
||||||
|
let path = settingsData.value?.ui_base_path ?? '/auth/'
|
||||||
if (!path.startsWith('/')) path = `/${path}`
|
if (!path.startsWith('/')) path = `/${path}`
|
||||||
if (!path.endsWith('/')) path = `${path}/`
|
if (!path.endsWith('/')) path = `${path}/`
|
||||||
const protocol = window.location.protocol || 'https:'
|
const protocol = window.location.protocol || 'https:'
|
||||||
@@ -107,11 +136,27 @@ const authSiteUrl = computed(() => {
|
|||||||
|
|
||||||
const goToAuthSite = () => {
|
const goToAuthSite = () => {
|
||||||
if (!authSiteUrl.value) return
|
if (!authSiteUrl.value) return
|
||||||
window.location.href = authSiteUrl.value
|
// Inside an iframe, open the full profile in a new window and close the
|
||||||
|
// frame (auth-back) so the host page regains focus.
|
||||||
|
if (inIframe) {
|
||||||
|
window.open(authSiteUrl.value, '_blank')
|
||||||
|
emit('back')
|
||||||
|
} else {
|
||||||
|
window.location.href = authSiteUrl.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await authStore.logout()
|
if (busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
}
|
||||||
|
// The parent decides how to react: the full-page app reloads, the iframe
|
||||||
|
// host receives auth-logout and closes the frame.
|
||||||
|
emit('logout')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard navigation for button row
|
// Keyboard navigation for button row
|
||||||
@@ -124,7 +169,39 @@ const handleButtonRowKeydown = (event) => {
|
|||||||
if (direction === 'left' || direction === 'right') {
|
if (direction === 'left' || direction === 'right') {
|
||||||
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
|
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
|
||||||
}
|
}
|
||||||
// Up does nothing (no elements above to navigate to)
|
|
||||||
// Down does nothing (no elements below to navigate to)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!props.settings) {
|
||||||
|
getSettings().then((data) => { fetchedSettings.value = data })
|
||||||
|
}
|
||||||
|
if (props.ctx && props.userInfo) return
|
||||||
|
try {
|
||||||
|
const [validateData, infoData] = await Promise.all([
|
||||||
|
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||||
|
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||||
|
])
|
||||||
|
fetchedCtx.value = validateData.ctx
|
||||||
|
fetchedInfo.value = infoData
|
||||||
|
updateThemeFromSession(validateData.ctx)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 401 && error.status !== 403) {
|
||||||
|
console.error('Failed to load account summary:', error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.view-root.host-profile { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
|
.surface.surface--tight {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user