Files
dezky/apps/operator/components/UserMenu.vue
T
Ronni Baslund 17ffd95a70 chore(portal,operator): upgrade to Nuxt 4
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).
2026-05-30 08:02:43 +02:00

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>