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
+17
View File
@@ -0,0 +1,17 @@
// Shared open state for the waffle app launcher. The launcher is mounted once
// in the default layout; the topbar trigger opens it.
const open = ref(false)
export const useAppLauncher = () => ({
open,
toggle: () => {
open.value = !open.value
},
show: () => {
open.value = true
},
hide: () => {
open.value = false
},
})
+51
View File
@@ -0,0 +1,51 @@
// Cached fetch of the signed-in user's profile from platform-api (proxied
// via /api/me on the portal server). One state slot per render so all
// callers share the same payload — middleware fetches once, pages read
// from cache, no per-component re-fetch.
interface MeProfile {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
platformAdmin: boolean
tenantIds: string[]
partnerId?: string
partner?: { _id: string; slug: string; name: string; status: string }
lastLoginAt?: string
}
interface MeResponse {
profile: MeProfile
tenants: unknown[]
subscriptions: unknown[]
}
export function useMe() {
const state = useState<MeResponse | null>('portal-me', () => null)
async function fetchMe(force = false): Promise<MeResponse | null> {
if (state.value && !force) return state.value
try {
// useRequestFetch on SSR forwards the incoming request's headers
// (including the nuxt-oidc-auth session cookie) when calling the
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
// would 401, the middleware would skip the redirect, and the end-user
// page would flash before client-side rehydration finally redirects.
const fetcher = useRequestFetch()
state.value = await fetcher<MeResponse>('/api/me')
} catch {
state.value = null
}
return state.value
}
const profile = computed<MeProfile | null>(() => state.value?.profile ?? null)
const partner = computed(() => profile.value?.partner ?? null)
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, fetchMe }
}
@@ -0,0 +1,29 @@
// Notification drawer open state + unread count. The drawer is mounted once
// in the default layout; the topbar bell button toggles it.
import { notifications as fixture } from '~/data/notifications'
const open = ref(false)
const items = ref(fixture)
export const useNotificationDrawer = () => {
const unreadCount = computed(() => items.value.filter((n) => !n.read).length)
return {
open,
items,
unreadCount,
show: () => {
open.value = true
},
hide: () => {
open.value = false
},
toggle: () => {
open.value = !open.value
},
markAllRead: () => {
items.value = items.value.map((n) => ({ ...n, read: true }))
},
}
}
+41
View File
@@ -0,0 +1,41 @@
// Partner admin's "acting as a customer admin" state. When a partner clicks
// into a customer org, the sidebar reshapes to that customer's admin nav and
// a persistent banner indicates the partner context.
//
// In real use, every action while in this mode is logged with the partner's
// identity (not the customer's) — the design spec is explicit about this for
// trust. For the prototype we just hold the customer id.
import type { CustomerOrg } from '~/data/customers'
const activeCustomerId = ref<string | null>(null)
export const usePartnerMode = () => {
function enter(customerId: string) {
activeCustomerId.value = customerId
if (import.meta.client) {
sessionStorage.setItem('dezky-partner-active-customer', customerId)
}
}
function exit() {
activeCustomerId.value = null
if (import.meta.client) {
sessionStorage.removeItem('dezky-partner-active-customer')
}
}
function hydrate() {
if (!import.meta.client || activeCustomerId.value) return
const stored = sessionStorage.getItem('dezky-partner-active-customer')
if (stored) activeCustomerId.value = stored
}
return {
activeCustomerId,
isActive: computed(() => activeCustomerId.value !== null),
enter,
exit,
hydrate,
setCustomer: (c: CustomerOrg | null) => {
activeCustomerId.value = c?.id ?? null
},
}
}
@@ -0,0 +1,88 @@
// Cosmetic + role tweaks for the portal shell. Persisted in localStorage so
// reloads stay coherent during prototyping. Applied to <html> as data-*
// attributes; tokens.css picks them up via selector overrides.
//
// `role` is the most important tweak — it switches which sidebar nav + which
// pages are visible (end user / customer admin / partner admin). For real use
// the role would come from Authentik group claims; the tweak lets us preview
// all three views without standing up three orgs.
export type ThemeMode = 'dark' | 'light'
export type Density = 'comfy' | 'compact'
export type Accent = 'signal' | 'cobalt' | 'coral' | 'moss'
export type PortalRole = 'end-user' | 'customer-admin' | 'partner-admin'
interface TweakState {
theme: ThemeMode
density: Density
accent: Accent
role: PortalRole
}
const STORAGE_KEY = 'dezky-portal-tweaks'
const DEFAULTS: TweakState = {
theme: 'light',
density: 'comfy',
accent: 'signal',
role: 'customer-admin',
}
const state = ref<TweakState>({ ...DEFAULTS })
const hydrated = ref(false)
function apply() {
if (!import.meta.client) return
const root = document.documentElement
root.setAttribute('data-theme', state.value.theme)
root.setAttribute('data-density', state.value.density)
root.setAttribute('data-accent', state.value.accent)
root.setAttribute('data-role', state.value.role)
}
function persist() {
if (!import.meta.client) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.value))
} catch {
// localStorage can throw in private mode; tweaks are cosmetic so swallow.
}
}
function hydrate() {
if (!import.meta.client || hydrated.value) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<TweakState>
state.value = {
theme: parsed.theme ?? DEFAULTS.theme,
density: parsed.density ?? DEFAULTS.density,
accent: parsed.accent ?? DEFAULTS.accent,
role: parsed.role ?? DEFAULTS.role,
}
}
} catch {
// ignore corrupt JSON
}
apply()
hydrated.value = true
}
export const usePortalTweaks = () => {
if (import.meta.client) hydrate()
function set<K extends keyof TweakState>(key: K, value: TweakState[K]) {
state.value = { ...state.value, [key]: value }
apply()
persist()
}
return {
state,
setTheme: (v: ThemeMode) => set('theme', v),
setDensity: (v: Density) => set('density', v),
setAccent: (v: Accent) => set('accent', v),
setRole: (v: PortalRole) => set('role', v),
}
}
+11
View File
@@ -0,0 +1,11 @@
// Shared sidebar collapse state. Used by layouts/default.vue and the layout's
// keyboard shortcut handler so ⌘[ from anywhere flips the same thing.
const collapsed = ref(false)
export const useSidebar = () => ({
collapsed,
toggle: () => {
collapsed.value = !collapsed.value
},
})
+37
View File
@@ -0,0 +1,37 @@
// Lightweight toast stack. Used by buttons/actions that want to confirm
// they fired. Rendered by components/ToastStack.vue in the default layout.
export type ToastTone = 'info' | 'ok' | 'warn' | 'bad'
export interface Toast {
id: number
tone: ToastTone
message: string
hint?: string
}
const toasts = ref<Toast[]>([])
let counter = 0
export const useToast = () => {
function push(tone: ToastTone, message: string, hint?: string) {
const id = ++counter
toasts.value = [...toasts.value, { id, tone, message, hint }]
const ttl = tone === 'bad' ? 7000 : 4000
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, ttl)
}
function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
return {
toasts,
push,
info: (m: string, h?: string) => push('info', m, h),
ok: (m: string, h?: string) => push('ok', m, h),
warn: (m: string, h?: string) => push('warn', m, h),
bad: (m: string, h?: string) => push('bad', m, h),
dismiss,
}
}