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,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),
}
}