17ffd95a70
Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
171 lines
4.8 KiB
Vue
171 lines
4.8 KiB
Vue
<script setup lang="ts">
|
|
// Avatar dropdown shown in the topbar. Replaces the inert sidebar profile
|
|
// card that used to live in OpSidebar. Owns its own open/close state plus
|
|
// outside-click + Escape + route-change dismissal so the parent topbar stays
|
|
// dumb.
|
|
|
|
const { user } = useOidcAuth()
|
|
const { state: tweaks, setTheme } = useTweaks()
|
|
const route = useRoute()
|
|
|
|
const open = ref(false)
|
|
|
|
const displayName = computed<string>(() => {
|
|
const name = (user.value?.userInfo as { name?: string } | undefined)?.name
|
|
return name || (user.value?.userName as string | undefined) || 'operator'
|
|
})
|
|
const email = computed(() => (user.value?.userInfo as { email?: string } | undefined)?.email ?? '')
|
|
|
|
function toggle() {
|
|
open.value = !open.value
|
|
}
|
|
function close() {
|
|
open.value = false
|
|
}
|
|
function flipTheme() {
|
|
setTheme(tweaks.value.theme === 'dark' ? 'light' : 'dark')
|
|
}
|
|
|
|
async function signOut() {
|
|
close()
|
|
// Use our custom endpoint instead of useOidcAuth().logout() — see
|
|
// apps/operator/server/api/auth/sign-out.get.ts. It ends BOTH the local
|
|
// session and the Authentik IdP session (required for shared-workstation
|
|
// safety on an elevated-privilege portal) and lands on /signed-out.
|
|
await navigateTo('/api/auth/sign-out', { external: true })
|
|
}
|
|
|
|
watch(() => route.path, close)
|
|
|
|
onMounted(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && open.value) close()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="usermenu">
|
|
<button class="trigger" :class="{ on: open }" type="button" :title="displayName" @click="toggle">
|
|
<Avatar :name="displayName" :size="26" />
|
|
</button>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="open" class="scrim" @click="close" />
|
|
</Teleport>
|
|
|
|
<Transition name="menu">
|
|
<div v-if="open" class="menu" role="menu" aria-label="User menu">
|
|
<div class="ident">
|
|
<Avatar :name="displayName" :size="32" />
|
|
<div class="ident-meta">
|
|
<div class="ident-name">{{ displayName }}</div>
|
|
<Mono dim>{{ email }}</Mono>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="divider" />
|
|
|
|
<button class="item" type="button" role="menuitem" @click="flipTheme">
|
|
<UiIcon :name="tweaks.theme === 'dark' ? 'shield' : 'shield'" :size="13" />
|
|
<span class="label">Theme · {{ tweaks.theme === 'dark' ? 'dark' : 'light' }}</span>
|
|
<Mono dim>{{ tweaks.theme === 'dark' ? 'switch to light' : 'switch to dark' }}</Mono>
|
|
</button>
|
|
|
|
<NuxtLink class="item" to="/settings" role="menuitem" @click="close">
|
|
<UiIcon name="shield" :size="13" />
|
|
<span class="label">Settings</span>
|
|
</NuxtLink>
|
|
|
|
<div class="divider" />
|
|
|
|
<button class="item danger" type="button" role="menuitem" @click="signOut">
|
|
<UiIcon name="logout" :size="13" />
|
|
<span class="label">Sign out</span>
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.usermenu { position: relative; }
|
|
|
|
.trigger {
|
|
appearance: none;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 999px;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.trigger:hover { border-color: var(--border); }
|
|
.trigger.on { border-color: var(--border-hi); }
|
|
|
|
/* Transparent full-screen scrim that catches outside clicks. Sits below the
|
|
menu since the menu is positioned in the same .usermenu container above. */
|
|
.scrim {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 90;
|
|
background: transparent;
|
|
}
|
|
|
|
.menu {
|
|
position: absolute;
|
|
top: calc(100% + 8px);
|
|
right: 0;
|
|
z-index: 100;
|
|
width: 240px;
|
|
background: var(--elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.ident {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
}
|
|
.ident-meta { min-width: 0; flex: 1; }
|
|
.ident-name { font-size: 13px; font-weight: 500; }
|
|
|
|
.divider { height: 1px; background: var(--border); margin: 4px 0; }
|
|
|
|
.item {
|
|
appearance: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
background: transparent;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
text-align: left;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.item:hover { background: var(--surface); }
|
|
.item .label { flex: 1; }
|
|
.item.danger { color: var(--bad); }
|
|
.item.danger:hover { background: rgba(240, 88, 88, 0.08); }
|
|
|
|
.menu-enter-active, .menu-leave-active { transition: opacity 0.12s, transform 0.12s; }
|
|
.menu-enter-from, .menu-leave-to { opacity: 0; transform: translateY(-4px); }
|
|
</style>
|