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
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
@@ -0,0 +1,121 @@
<script setup lang="ts">
// "..." menu for a single device row. The menu is teleported to <body> so it
// escapes any overflow/clip on the table — same pattern as the React design
// source. Closes on outside-click, Escape, or scroll.
interface DeviceLike {
id: string
current?: boolean
trusted?: boolean
}
const props = defineProps<{ device: DeviceLike }>()
const emit = defineEmits<{
rename: [DeviceLike]
trust: [DeviceLike]
history: [DeviceLike]
revoke: [DeviceLike]
}>()
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const pos = ref({ top: 0, right: 0 })
function toggle(e: MouseEvent) {
e.stopPropagation()
if (!triggerRef.value) return
const r = triggerRef.value.getBoundingClientRect()
pos.value = { top: r.bottom + 4, right: window.innerWidth - r.right }
open.value = !open.value
}
function close() { open.value = false }
function onDoc(e: MouseEvent) {
if (!open.value) return
const t = e.target as Node
if (menuRef.value?.contains(t) || triggerRef.value?.contains(t)) return
open.value = false
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
function onScroll() { open.value = false }
onMounted(() => {
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
</script>
<template>
<span ref="triggerRef" class="more-wrap">
<UiButton size="sm" variant="ghost" @click="toggle">
<UiIcon name="more" :size="14" />
</UiButton>
</span>
<Teleport to="body">
<Transition name="pop">
<div v-if="open" ref="menuRef" class="menu" :style="{ top: pos.top + 'px', right: pos.right + 'px' }">
<button @click="close(); emit('rename', device)"><UiIcon name="brush" :size="14" /> Rename device</button>
<button @click="close(); emit('trust', device)"><UiIcon name="shield" :size="14" /> {{ device.trusted ? 'Distrust device' : 'Trust this device · skip MFA for 30d' }}</button>
<button @click="close(); emit('history', device)"><UiIcon name="file" :size="14" /> View sign-in history</button>
<span class="sep" />
<button
class="danger"
:disabled="device.current"
@click="close(); !device.current && emit('revoke', device)"
>
<UiIcon name="logout" :size="14" />
{{ device.current ? 'Cannot revoke current device' : 'Revoke session' }}
</button>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.more-wrap { display: inline-flex; }
.menu {
position: fixed;
min-width: 260px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
z-index: 100;
}
.menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 5px;
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 13px;
text-align: left;
color: var(--text);
}
.menu button:hover { background: var(--row-hover); }
.menu button:disabled { opacity: 0.5; cursor: not-allowed; }
.menu .danger { color: var(--bad); }
.menu .sep { display: block; height: 1px; background: var(--border); margin: 4px 0; }
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
// Simple label + slot wrapper used across the profile/security pages.
defineProps<{ label: string; hint?: string }>()
</script>
<template>
<label class="field">
<span class="label">{{ label }}</span>
<slot />
<span v-if="hint" class="hint">{{ hint }}</span>
</label>
</template>
<style scoped>
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
.hint {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
}
.field :deep(input),
.field :deep(textarea),
.field :deep(select) {
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
outline: none;
width: 100%;
transition: border-color 0.12s, background 0.12s;
}
.field :deep(textarea) {
min-height: 110px;
padding: 10px 12px;
height: auto;
resize: vertical;
line-height: 1.55;
}
.field :deep(input:focus),
.field :deep(textarea:focus),
.field :deep(select:focus) {
border-color: var(--text);
background: var(--bg);
}
.field :deep(input:disabled) { background: var(--bg); color: var(--text-mute); cursor: not-allowed; }
</style>
@@ -0,0 +1,129 @@
<script setup lang="ts">
// Presence selector for the dashboard greeting strip. Drop-down trigger styled
// as a pill with a status dot, label, and short hint. Visual only — no API call.
type Presence = 'available' | 'meeting' | 'focus' | 'away'
interface PresenceOption {
value: Presence
label: string
hint: string
color: string
}
const opts: PresenceOption[] = [
{ value: 'available', label: 'Available', hint: 'visible to teammates', color: 'var(--ok)' },
{ value: 'meeting', label: 'In a meeting', hint: 'silenced · auto-clears', color: 'var(--warn)' },
{ value: 'focus', label: 'Focus', hint: 'no notifications', color: 'var(--info)' },
{ value: 'away', label: 'Away', hint: 'be right back', color: 'var(--text-mute)' },
]
const props = defineProps<{ modelValue: Presence }>()
const emit = defineEmits<{ 'update:modelValue': [Presence] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0])
function pick(v: Presence) {
emit('update:modelValue', v)
open.value = false
}
function onDocClick(e: MouseEvent) {
if (!rootRef.value) return
if (!rootRef.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="rootRef" class="presence">
<button class="trigger" @click="open = !open" :aria-expanded="open">
<StatusDot :color="current.color" :size="8" />
<div class="trigger-text">
<span class="trigger-label">{{ current.label }}</span>
<span class="trigger-hint">{{ current.hint }}</span>
</div>
<UiIcon name="chevDown" :size="12" />
</button>
<Transition name="pop">
<div v-if="open" class="menu">
<button
v-for="o in opts"
:key="o.value"
class="opt"
:class="{ active: o.value === modelValue }"
@click="pick(o.value)"
>
<StatusDot :color="o.color" :size="8" />
<div class="opt-text">
<span class="opt-label">{{ o.label }}</span>
<span class="opt-hint">{{ o.hint }}</span>
</div>
<UiIcon v-if="o.value === modelValue" name="check" :size="13" />
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.presence { position: relative; }
.trigger {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--border);
cursor: pointer;
font-family: inherit;
color: var(--text);
}
.trigger:hover { border-color: var(--border-hi); }
.trigger-text { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.trigger-label { font-size: 13px; font-weight: 500; }
.trigger-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
.menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 220px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
padding: 4px;
z-index: 50;
}
.opt {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
color: var(--text);
text-align: left;
}
.opt:hover, .opt.active { background: var(--row-hover); }
.opt-text { display: flex; flex-direction: column; flex: 1; gap: 1px; }
.opt-label { font-size: 13px; font-weight: 500; }
.opt-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
// Sticky save bar — appears at bottom-center of the page when any field
// in the active tab is dirty. Dark pill with Discard + Save changes.
defineProps<{ dirty: boolean }>()
defineEmits<{ discard: []; save: [] }>()
</script>
<template>
<Teleport to="body">
<Transition name="lift">
<div v-if="dirty" class="save-bar">
<span class="pulse" />
<div class="text">
<span class="t1">You have unsaved changes</span>
<span class="t2">changes are queued · not yet applied</span>
</div>
<button class="discard" @click="$emit('discard')">Discard</button>
<button class="save" @click="$emit('save')">
<UiIcon name="check" :size="13" :stroke-width="2.4" />
Save changes
</button>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.save-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 14px;
min-width: 420px;
padding: 12px 14px 12px 20px;
background: var(--text);
color: var(--bg);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);
z-index: 70;
font-family: var(--font-sans);
}
.pulse {
width: 6px; height: 6px; border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(212, 255, 58, 0.18);
}
.text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.t1 { font-size: 13px; font-weight: 500; }
.t2 { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; opacity: 0.55; }
.discard, .save {
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
padding: 8px 14px;
}
.discard { background: transparent; color: var(--bg); opacity: 0.7; }
.discard:hover { opacity: 1; }
.save {
background: var(--accent);
color: var(--accent-fg);
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
padding: 8px 16px;
}
.save:hover { filter: brightness(0.94); }
.lift-enter-active, .lift-leave-active {
transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.18s;
}
.lift-enter-from, .lift-leave-to { transform: translate(-50%, 24px); opacity: 0; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
// Compact toggle pill used inside profile/notification cards.
const props = defineProps<{ modelValue: boolean; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
function flip() {
if (props.disabled) return
emit('update:modelValue', !props.modelValue)
}
</script>
<template>
<button
class="toggle"
:data-on="modelValue"
:disabled="disabled"
:aria-pressed="modelValue"
@click="flip"
>
<span class="knob" />
</button>
</template>
<style scoped>
.toggle {
width: 38px; height: 22px;
background: var(--border);
border: 1px solid var(--border-hi);
border-radius: 999px;
position: relative;
padding: 0;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.toggle[data-on='true'] { background: var(--text); border-color: var(--text); }
.toggle:disabled { opacity: 0.4; cursor: not-allowed; }
.knob {
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
background: var(--bg);
border-radius: 999px;
transition: transform 0.18s cubic-bezier(0.32, 0.72, 0, 1);
}
.toggle[data-on='true'] .knob { transform: translateX(16px); }
</style>