Files
dezky/apps/operator/components/UserMenu.vue
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00

168 lines
4.7 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(() => user.value?.userInfo?.name || user.value?.userName || '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>