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:
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user