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,312 @@
<script setup lang="ts">
// Mirror of InviteOperatorModal but scoped to a partner. POSTs to
// /api/partners/:slug/users which proxies platform-api's
// /partners/:slug/users — the server resolves the partner, creates the
// Authentik user, adds them to the dezky-partner-staff group, sets
// User.partnerId, and returns the same recovery-link/temp-password
// handoff shape as the operator invite. UI text differs to match the
// partner-staff context.
interface InviteResult {
subject: string
userId: string
// True if the user already existed in Authentik and we just attached them.
// When set, link/tempPassword are absent and the UI hides the credential
// row (the user already has a password from their original signup).
attached?: boolean
link?: string
tempPassword?: string
}
const props = defineProps<{ open: boolean; partnerSlug: string; partnerName: string }>()
const emit = defineEmits<{ close: []; invited: [InviteResult] }>()
const name = ref('')
const email = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
const result = ref<InviteResult | null>(null)
const copied = ref(false)
watch(
() => props.open,
(v) => {
if (v) {
name.value = ''
email.value = ''
busy.value = false
error.value = null
result.value = null
copied.value = false
}
},
)
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.open && !busy.value) emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
const canSubmit = computed(() => {
return (
!busy.value &&
name.value.trim().length >= 2 &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim())
)
})
async function submit() {
if (!canSubmit.value) return
busy.value = true
error.value = null
try {
result.value = await $fetch<InviteResult>(`/api/partners/${props.partnerSlug}/users`, {
method: 'POST',
body: { name: name.value.trim(), email: email.value.trim() },
})
emit('invited', result.value)
} catch (e) {
const err = e as { data?: { data?: { message?: string }; message?: string; statusMessage?: string } }
error.value =
err.data?.data?.message ||
err.data?.message ||
err.data?.statusMessage ||
'Invite failed'
} finally {
busy.value = false
}
}
async function copyToClipboard(value: string) {
try {
await navigator.clipboard.writeText(value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
} catch {
// Non-secure context — user can still select the readonly input.
}
}
function done() {
emit('close')
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="!busy && emit('close')">
<div class="modal" role="dialog" aria-label="Invite team member" @click.stop>
<header>
<div>
<Eyebrow>{{ partnerName }}</Eyebrow>
<h2>Invite team member</h2>
</div>
<button class="x" type="button" aria-label="Close" :disabled="busy" @click="emit('close')">
<UiIcon name="x" :size="12" />
</button>
</header>
<!-- Step 1: collect details -->
<div v-if="!result" class="body">
<section>
<label class="label">Name</label>
<input v-model="name" type="text" placeholder="Ronni Baslund" :disabled="busy" />
</section>
<section>
<label class="label">Email</label>
<input v-model="email" type="email" placeholder="ronni@baslund.com" :disabled="busy" />
</section>
<div class="note">
<UiIcon name="shield" :size="13" />
<Mono dim>
Adds the user to the <strong>dezky-partner-staff</strong> Authentik group and
attaches them to <strong>{{ partnerName }}</strong>. They'll receive a
single-use recovery link to set their password + enroll MFA.
</Mono>
</div>
<p v-if="error" class="err">{{ error }}</p>
</div>
<!-- Step 2: show the credential to share -->
<div v-else class="body result">
<Badge tone="ok" dot>{{ result.attached ? 'attached' : 'invited' }}</Badge>
<!-- Existing-user path: no credential, just confirm attach -->
<template v-if="result.attached">
<p class="success">
<Mono>{{ email }}</Mono> already existed in Authentik. They're now
part of <Mono>{{ partnerName }}</Mono> no new password needed;
they sign in with their existing credentials and will see the
partner workspace on next login.
</p>
</template>
<template v-else-if="result.link">
<p class="success">
{{ name }} (<Mono>{{ email }}</Mono>) was added to
<Mono>{{ partnerName }}</Mono>. Share the link below it's single-use
and they'll set their own password + MFA.
</p>
<div class="cred-row">
<input :value="result.link" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.link!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</template>
<template v-else-if="result.tempPassword">
<p class="success">
{{ name }} (<Mono>{{ email }}</Mono>) was added to
<Mono>{{ partnerName }}</Mono>. Authentik doesn't have a recovery
flow configured yet, so we set a temporary password — share it with
them out-of-band, they'll be prompted to change it on first login.
</p>
<section>
<label class="label">Username / email</label>
<div class="cred-row">
<input :value="email" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(email)">
Copy
</UiButton>
</div>
</section>
<section>
<label class="label">Temporary password</label>
<div class="cred-row">
<input :value="result.tempPassword" readonly @focus="($event.target as HTMLInputElement).select()" />
<UiButton variant="secondary" @click="copyToClipboard(result.tempPassword!)">
{{ copied ? 'Copied' : 'Copy' }}
</UiButton>
</div>
</section>
<Mono dim>
// configure a recovery flow in Authentik (Flows → recovery) to
switch this to a self-service link · once SMTP is wired the
credential gets emailed automatically
</Mono>
</template>
</div>
<footer v-if="!result">
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" :disabled="!canSubmit" @click="submit">
{{ busy ? 'Inviting' : 'Send invite' }}
</UiButton>
</footer>
<footer v-else>
<UiButton variant="primary" @click="done">Done</UiButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex; align-items: center; justify-content: center;
padding: 24px;
z-index: 180;
}
.modal {
width: 100%; max-width: 520px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
display: flex; flex-direction: column;
overflow: hidden;
}
header {
padding: 16px 20px;
display: flex; justify-content: space-between; align-items: flex-start;
border-bottom: 1px solid var(--border);
}
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.x {
width: 26px; height: 26px;
border: 0; border-radius: 6px; background: transparent;
color: var(--text-mute); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.x:hover:not(:disabled) { background: var(--surface); color: var(--text); }
.x:disabled { opacity: 0.4; cursor: not-allowed; }
.body {
padding: 16px 20px;
display: flex; flex-direction: column; gap: 14px;
}
section { display: flex; flex-direction: column; gap: 6px; }
.label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
input {
height: 34px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
outline: 0;
}
input:focus { border-color: var(--border-hi); }
input:disabled { opacity: 0.6; }
.note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-mute);
}
.note strong { color: var(--text); font-family: var(--font-mono); font-weight: 600; }
.err {
margin: 0;
padding: 10px 12px;
background: rgba(240, 88, 88, 0.08);
border: 1px solid rgba(240, 88, 88, 0.24);
color: var(--bad);
border-radius: 6px;
font-size: 12px;
}
.result .success {
margin: 4px 0 0 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
}
.cred-row { display: flex; gap: 8px; align-items: center; }
.cred-row input {
flex: 1;
font-family: var(--font-mono);
font-size: 11px;
}
footer {
padding: 12px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
background: var(--surface);
}
</style>
+1
View File
@@ -24,6 +24,7 @@ const NAV: NavRow[] = [
{ id: 'users', label: 'Users (global)', icon: 'users', href: '/users' },
{ id: 'support', label: 'Support', icon: 'help', href: '/support' },
{ sec: 'Commercial' },
{ id: 'pricing', label: 'Pricing', icon: 'card', href: '/pricing' },
{ id: 'billing', label: 'Platform billing', icon: 'card', href: '/billing' },
{ id: 'reports', label: 'Reports', icon: 'database', href: '/reports' },
{ sec: 'Operations' },
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
// Toast stack mounted once in the default layout. Top-right.
const { toasts, dismiss } = useToast()
const TONE_COLOR: Record<string, string> = {
info: 'var(--info)',
ok: 'var(--ok)',
warn: 'var(--warn)',
bad: 'var(--bad)',
}
</script>
<template>
<div class="stack">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :data-tone="t.tone">
<span class="dot" :style="{ background: TONE_COLOR[t.tone] }" />
<div class="body">
<div class="msg">{{ t.message }}</div>
<div v-if="t.hint" class="hint">{{ t.hint }}</div>
</div>
<button class="x" @click="dismiss(t.id)">
<UiIcon name="x" :size="12" />
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.stack {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
display: flex;
gap: 10px;
align-items: flex-start;
min-width: 280px;
max-width: 360px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 6px; flex-shrink: 0; }
.body { flex: 1; min-width: 0; }
.msg { font-size: 13px; font-weight: 500; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--text-mute); margin-top: 2px; }
.x {
background: transparent;
border: 0;
padding: 4px;
border-radius: 4px;
color: var(--text-mute);
cursor: pointer;
}
.x:hover { background: var(--surface); color: var(--text); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.18s, transform 0.18s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(20px); }
</style>
+6 -2
View File
@@ -4,7 +4,7 @@
// outside-click + Escape + route-change dismissal so the parent topbar stays
// dumb.
const { user, logout } = useOidcAuth()
const { user } = useOidcAuth()
const { state: tweaks, setTheme } = useTweaks()
const route = useRoute()
@@ -25,7 +25,11 @@ function flipTheme() {
async function signOut() {
close()
await logout()
// 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)
+54
View File
@@ -0,0 +1,54 @@
// Cached fetch of the signed-in operator's profile from platform-api.
// Mirrors the portal's useMe — kept here so any future middleware /
// layout in operator can read identity data SSR-safely without flashing
// the wrong layout to the browser.
//
// No current consumer; the portal version is what motivated this pattern
// (route middleware fetching /api/me with bare $fetch missed the session
// cookie on SSR, causing a flash of the end-user dashboard before the
// client-side redirect kicked in). Adding the same shape here means the
// trap is pre-disarmed if operator ever grows comparable middleware.
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>('operator-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 and would
// 401, producing a stale-state / wrong-layout flash on full reload.
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 isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
return { state, profile, isPlatformAdmin, fetchMe }
}
+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,
}
}
+1
View File
@@ -61,6 +61,7 @@ onMounted(() => {
<IncidentModal />
<NotificationDrawer />
<TweaksPanel />
<ToastStack />
</div>
</template>
+22
View File
@@ -10,6 +10,17 @@ export default defineNuxtConfig({
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
// Auto-import from the shared packages/ui workspace in addition to the
// app's own components/. /shared-packages is bind-mounted in
// docker-compose.yml — outside containers the same files live at
// <repo>/packages/ui/components/. The local dir keeps the default
// directory-based prefix; the shared dir uses no prefix so
// CountrySelect.vue is just <CountrySelect>.
components: [
'~/components',
{ path: '/shared-packages/ui/components', pathPrefix: false },
],
app: {
head: {
htmlAttrs: { 'data-theme': 'dark' },
@@ -55,6 +66,11 @@ export default defineNuxtConfig({
pkce: true,
skipAccessTokenParsing: true,
exposeAccessToken: true,
// Also expose id_token so /api/auth/sign-out can pass it as
// id_token_hint to Authentik's end-session endpoint. Without it
// Authentik can't identify the session to terminate and falls back
// to its own "you've logged out" confirmation page.
exposeIdToken: true,
},
},
},
@@ -74,5 +90,11 @@ export default defineNuxtConfig({
routeRules: {
'/api/**': { cors: true },
},
// Persist nuxt-oidc-auth's session store on disk so HMR / dev-server
// restarts don't sign operators out. The default memory driver is fine
// in prod where one long-running container holds the state.
storage: {
oidc: { driver: 'fs', base: '.nuxt/oidc-store' },
},
},
})
+77 -1
View File
@@ -225,6 +225,36 @@ async function confirmDetach() {
}
}
// ── Team (partner users) ──────────────────────────────────────────────────
// Lists users whose User.partnerId === this partner. Invite flow surfaces a
// modal that POSTs to /api/partners/:slug/users, which proxies platform-api
// and creates the Authentik user + group + local User doc atomically.
interface PartnerUser {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
lastLoginAt?: string
createdAt?: string
}
const { data: team, refresh: refreshTeam } = await useFetch<PartnerUser[]>(
() => `/api/partners/${slug.value}/users`,
{ default: () => [], watch: [slug] },
)
const inviteOpen = ref(false)
function onInvited() {
// Don't close the modal — the user needs to see the recovery link / temp
// password. Just refresh the team list in the background so the new user
// is visible once they click Done.
void refreshTeam()
}
// ── Soft-terminate partner ────────────────────────────────────────────────
const terminateOpen = ref(false)
const terminateBusy = ref(false)
@@ -371,7 +401,7 @@ async function confirmTerminate() {
</div>
<div class="dl-row">
<dt>Country</dt>
<dd><input v-model="draft.billingInfo.country" class="field mono country" type="text" maxlength="2" placeholder="DK" :disabled="saving" /></dd>
<dd><CountrySelect v-model="draft.billingInfo.country" :disabled="saving" /></dd>
</div>
</dl>
</Card>
@@ -420,6 +450,43 @@ async function confirmTerminate() {
</table>
</Card>
<Card :pad="0">
<div class="card-head padded">
<div>
<h2>Team</h2>
<p class="hint">People at <Mono>{{ partner.name }}</Mono> who can sign in. <Mono dim>partnerId</Mono> on the user record points here.</p>
</div>
<UiButton variant="primary" @click="inviteOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Invite team member
</UiButton>
</div>
<table>
<thead>
<tr>
<th>Name</th><th>Email</th><th>Role</th><th>Last login</th><th>Status</th>
</tr>
</thead>
<tbody>
<tr v-if="(team ?? []).length === 0" class="empty">
<td colspan="5">
<span class="empty-inner">No team members yet. Click <Mono>Invite team member</Mono> to add one.</span>
</td>
</tr>
<tr v-for="u in (team ?? [])" :key="u._id">
<td>
<div class="cell-name">{{ u.name }}</div>
<Mono dim>{{ u.authentikSubjectId }}</Mono>
</td>
<td><Mono>{{ u.email }}</Mono></td>
<td><Badge tone="neutral">{{ u.role }}</Badge></td>
<td><Mono :dim="!u.lastLoginAt">{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'never' }}</Mono></td>
<td><Badge :tone="u.active ? 'ok' : 'bad'" dot>{{ u.active ? 'active' : 'disabled' }}</Badge></td>
</tr>
</tbody>
</table>
</Card>
<Card>
<h2 class="danger">Soft-terminate partner</h2>
<p>
@@ -492,6 +559,15 @@ async function confirmTerminate() {
</p>
<p v-if="terminateError" class="err">{{ terminateError }}</p>
</ConfirmDialog>
<!-- Invite partner team-member modal -->
<InvitePartnerUserModal
:open="inviteOpen"
:partner-slug="partner.slug"
:partner-name="partner.name"
@close="inviteOpen = false"
@invited="onInvited"
/>
</div>
</template>
+346
View File
@@ -0,0 +1,346 @@
<script setup lang="ts">
// Pricing catalog editor. Operator-only. Each (plan, cycle) is a single row
// with three independent per-currency amounts (DKK / EUR / USD). Operator
// types clean round numbers in each currency — no FX derivation. Empty cells
// mean "we don't sell this plan/cycle in that currency."
//
// Backed by /prices in platform-api. Amounts are stored in MINOR units
// (4900 = 49.00); display uses major-unit strings with 2 decimals.
const CURRENCIES = ['DKK', 'EUR', 'USD'] as const
type Currency = (typeof CURRENCIES)[number]
interface PriceRow {
_id: string
plan: 'mvp' | 'pro' | 'enterprise'
cycle: 'monthly' | 'quarterly' | 'yearly'
amounts: Partial<Record<Currency, number>>
active: boolean
createdAt?: string
updatedAt?: string
}
const toast = useToast()
const showInactive = ref(false)
const { data: prices, refresh } = await useFetch<PriceRow[]>(
() => `/api/prices${showInactive.value ? '?includeInactive=true' : ''}`,
{ key: 'pricing-catalog', default: () => [], watch: [showInactive] },
)
const PLAN_LABEL: Record<PriceRow['plan'], string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
const CYCLE_LABEL: Record<PriceRow['cycle'], string> = {
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
}
function toMajor(minor?: number): string {
if (typeof minor !== 'number') return ''
return (minor / 100).toFixed(2)
}
function toMinor(major: string): number | undefined {
if (!major.trim()) return undefined // empty → unset that currency
const cleaned = major.replace(/\s+/g, '').replace(',', '.')
const n = Number(cleaned)
if (!Number.isFinite(n) || n < 0) return NaN
return Math.round(n * 100)
}
// Per-row draft. Keyed by row._id, holds one major-unit string per currency.
// undefined draft = not currently editing.
type Draft = Record<Currency, string>
const drafts = reactive<Record<string, Draft>>({})
const saving = ref<string | null>(null)
function startEdit(row: PriceRow) {
drafts[row._id] = {
DKK: toMajor(row.amounts.DKK),
EUR: toMajor(row.amounts.EUR),
USD: toMajor(row.amounts.USD),
}
}
function cancelEdit(id: string) {
delete drafts[id]
}
async function saveEdit(row: PriceRow) {
const draft = drafts[row._id]
if (!draft) return
const next: Partial<Record<Currency, number>> = {}
for (const c of CURRENCIES) {
const parsed = toMinor(draft[c])
if (parsed === undefined) continue // empty input → currency stays unset
if (Number.isNaN(parsed)) {
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0, or blank to leave unset')
return
}
next[c] = parsed
}
saving.value = row._id
try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } })
toast.ok('Prices updated', `${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`)
cancelEdit(row._id)
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Update failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
saving.value = null
}
}
async function toggleActive(row: PriceRow) {
saving.value = row._id
try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { active: !row.active } })
toast.ok(
row.active ? 'Price deactivated' : 'Price reactivated',
`${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`,
)
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Toggle failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
saving.value = null
}
}
// Add-row form. Used to insert an Enterprise row or replace a deactivated one.
const addForm = reactive<{
plan: PriceRow['plan']
cycle: PriceRow['cycle']
amounts: Record<Currency, string>
}>({
plan: 'enterprise',
cycle: 'monthly',
amounts: { DKK: '', EUR: '', USD: '' },
})
const adding = ref(false)
async function addRow() {
const amounts: Partial<Record<Currency, number>> = {}
for (const c of CURRENCIES) {
const parsed = toMinor(addForm.amounts[c])
if (parsed === undefined) continue
if (Number.isNaN(parsed)) {
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0')
return
}
amounts[c] = parsed
}
if (Object.keys(amounts).length === 0) {
toast.warn('No prices entered', 'Set at least one currency amount')
return
}
adding.value = true
try {
await $fetch('/api/prices', {
method: 'POST',
body: { plan: addForm.plan, cycle: addForm.cycle, amounts },
})
toast.ok('Row added', `${PLAN_LABEL[addForm.plan]} · ${CYCLE_LABEL[addForm.cycle]}`)
for (const c of CURRENCIES) addForm.amounts[c] = ''
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Add failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
adding.value = false
}
}
// Sort: active first, then plan order (mvp/pro/enterprise), then cycle order.
const PLAN_ORDER: Record<PriceRow['plan'], number> = { mvp: 0, pro: 1, enterprise: 2 }
const CYCLE_ORDER: Record<PriceRow['cycle'], number> = { monthly: 0, quarterly: 1, yearly: 2 }
const sortedPrices = computed<PriceRow[]>(() =>
[...(prices.value ?? [])].sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1
if (a.plan !== b.plan) return PLAN_ORDER[a.plan] - PLAN_ORDER[b.plan]
return CYCLE_ORDER[a.cycle] - CYCLE_ORDER[b.cycle]
}),
)
</script>
<template>
<div class="stage">
<PageHeader
eyebrow="Operator · operator.dezky.local"
title="Pricing catalog"
subtitle="One row per plan + cycle, with independent prices per currency. Changes affect subscriptions provisioned from now on — existing customers keep the price snapshot taken at provisioning."
>
<template #actions>
<label class="toggle">
<input v-model="showInactive" type="checkbox" />
Show inactive
</label>
</template>
</PageHeader>
<Card :pad="0">
<table>
<thead>
<tr>
<th>Plan</th>
<th>Cycle</th>
<th v-for="c in CURRENCIES" :key="c" class="th-amount">{{ c }} / seat</th>
<th>Status</th>
<th class="th-actions">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="sortedPrices.length === 0" class="empty">
<td :colspan="4 + CURRENCIES.length">
<span class="empty-inner">No prices yet add one below.</span>
</td>
</tr>
<tr v-for="row in sortedPrices" :key="row._id" :class="{ inactive: !row.active }">
<td>
<div class="cell-name">{{ PLAN_LABEL[row.plan] }}</div>
<Mono dim>{{ row.plan }}</Mono>
</td>
<td>{{ CYCLE_LABEL[row.cycle] }}</td>
<td v-for="c in CURRENCIES" :key="c" class="cell-amount">
<template v-if="drafts[row._id]">
<input
v-model="drafts[row._id][c]"
type="text"
inputmode="decimal"
class="amount-input"
:placeholder="`— ${c}`"
:disabled="saving === row._id"
@keydown.enter="saveEdit(row)"
@keydown.escape="cancelEdit(row._id)"
/>
</template>
<template v-else>
<template v-if="row.amounts[c] !== undefined">
<span class="amount">{{ toMajor(row.amounts[c]) }}</span>
</template>
<Mono v-else dim></Mono>
</template>
</td>
<td>
<Badge :tone="row.active ? 'ok' : 'neutral'" dot>{{ row.active ? 'active' : 'inactive' }}</Badge>
</td>
<td class="cell-actions">
<div class="actions">
<template v-if="drafts[row._id]">
<UiButton size="sm" variant="primary" :disabled="saving === row._id" @click="saveEdit(row)">
{{ saving === row._id ? 'Saving' : 'Save' }}
</UiButton>
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="cancelEdit(row._id)">
Cancel
</UiButton>
</template>
<template v-else>
<UiButton size="sm" variant="secondary" @click="startEdit(row)">Edit</UiButton>
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="toggleActive(row)">
{{ row.active ? 'Deactivate' : 'Reactivate' }}
</UiButton>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</Card>
<Card>
<h2>Add a row</h2>
<p class="hint">
Used for missing Enterprise rows or to replace a deactivated row.
Fill in only the currencies you want to offer; blank cells mean
"not sold in this currency."
</p>
<div class="add-form">
<label class="field">
<Eyebrow>Plan</Eyebrow>
<select v-model="addForm.plan">
<option value="mvp">Starter</option>
<option value="pro">Business</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label class="field">
<Eyebrow>Cycle</Eyebrow>
<select v-model="addForm.cycle">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</label>
<label v-for="c in CURRENCIES" :key="c" class="field">
<Eyebrow>{{ c }} / seat</Eyebrow>
<input
v-model="addForm.amounts[c]"
type="text"
inputmode="decimal"
:placeholder="`e.g. ${c === 'DKK' ? '49.00' : '7.00'}`"
/>
</label>
<UiButton variant="primary" :disabled="adding" @click="addRow">
{{ adding ? 'Adding' : 'Add row' }}
</UiButton>
</div>
</Card>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 18px; }
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
th { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; }
.cell-name { font-size: 13px; font-weight: 500; }
.empty .empty-inner { display: block; padding: 24px 0; text-align: center; color: var(--text-mute); }
tr.inactive { opacity: 0.55; }
.th-amount, .cell-amount { text-align: right; width: 130px; white-space: nowrap; }
.amount { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; }
.amount-input {
width: 96px;
padding: 6px 8px;
text-align: right;
background: var(--surface);
border: 1px solid var(--border-hi);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text);
}
.th-actions, .cell-actions { text-align: right; width: 220px; }
.actions { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
.add-form { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: 12px; align-items: end; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field select, .field input {
height: 34px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: 0;
}
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; }
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
// Sign-out landing for the operator portal. /api/auth/sign-out cleared the
// local session and bounced through Authentik's end-session endpoint, which
// ended the IdP session. By the time we render here the user has no
// session anywhere — clicking Sign in again forces fresh credentials.
definePageMeta({ layout: 'blank', auth: false, oidcAuth: { enabled: false } })
function signInAgain() {
return navigateTo('/auth/oidc/login', { external: true })
}
</script>
<template>
<div class="shell">
<div class="card">
<div class="badge">
<UiIcon name="shield" :size="22" />
</div>
<p class="eyebrow">dezky · ops</p>
<h1>You're signed out</h1>
<p class="lead">
Your operator session has been ended. Sign in again whenever you're
ready Authentik will ask for fresh credentials.
</p>
<button class="primary" type="button" @click="signInAgain">Sign in again</button>
<p class="hint">operator.dezky.local</p>
</div>
</div>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: var(--bg);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px 36px 32px;
width: 100%;
max-width: 420px;
text-align: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
}
.badge {
width: 48px;
height: 48px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(52, 199, 123, 0.12);
color: var(--ok);
margin: 0 auto 16px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-mute);
margin: 0 0 10px 0;
}
h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 24px;
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0;
}
.lead {
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
margin: 14px 0 26px;
}
.primary {
display: block;
width: 100%;
height: 42px;
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 7px;
font-weight: 600;
font-size: 13px;
font-family: inherit;
cursor: pointer;
}
.primary:hover { filter: brightness(0.96); }
.hint {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-mute);
margin: 22px 0 0 0;
}
</style>
@@ -0,0 +1,35 @@
// Sign-out for the operator portal. Same shape as the customer portal's
// sign-out (apps/portal/server/api/auth/sign-out.get.ts) — ends BOTH the
// local nuxt-oidc-auth session AND the Authentik IdP session so the next
// person at the same browser must re-enter credentials. Required for
// shared-workstation safety; operator portal carries elevated privileges.
//
// Flow:
// 1. Read the id_token off the local session (needed as id_token_hint).
// 2. Clear the local session (cookie + persistent store).
// 3. 302 the BROWSER through Authentik's dezky-operator end-session URL
// with post_logout_redirect_uri=/signed-out.
//
// The brief URL-bar flash to auth.dezky.local is unavoidable: that's the
// only host that can clear the Authentik session cookie (server-to-server
// invalidation alone leaves the browser cookie, which would let the next
// visit silently re-authorize).
import { getUserSession, clearUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
const END_SESSION = 'https://auth.dezky.local/application/o/dezky-operator/end-session/'
const POST_LOGOUT_REDIRECT = 'https://operator.dezky.local/signed-out'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => ({} as any))
const idToken: string | undefined = (session as any).idToken
await clearUserSession(event).catch(() => {})
const params = new URLSearchParams({
post_logout_redirect_uri: POST_LOGOUT_REDIRECT,
...(idToken && { id_token_hint: idToken }),
})
return sendRedirect(event, `${END_SESSION}?${params.toString()}`, 302)
})
+26
View File
@@ -0,0 +1,26 @@
// Operator identity proxy. Same shape as the portal's /api/me — pulls
// /users/me from platform-api with the signed-in operator's access token,
// plus tenants + subscriptions for context. Consumed by the useMe()
// composable; no UI surface uses it yet, but the path is here so any
// future middleware / layout that needs profile data has a known endpoint.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in or no access token' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
const headers = { Authorization: `Bearer ${accessToken}` }
const [profile, tenants, subscriptions] = await Promise.all([
$fetch(`${base}/users/me`, { headers }),
$fetch(`${base}/tenants`, { headers }),
$fetch(`${base}/subscriptions`, { headers }),
])
return { profile, tenants, subscriptions }
})
@@ -0,0 +1,6 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
return platformApi(event, `/partners/${slug}/users`)
})
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
return platformApi(event, `/partners/${slug}/users`, { method: 'POST', body })
})
@@ -0,0 +1,6 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
return platformApi(event, `/prices/${id}`, { method: 'DELETE' })
})
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const body = await readBody(event)
return platformApi(event, `/prices/${id}`, { method: 'PATCH', body })
})
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const q = getQuery(event)
const includeInactive = q.includeInactive === 'true'
return platformApi(event, `/prices${includeInactive ? '?includeInactive=true' : ''}`)
})
@@ -0,0 +1,6 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
return platformApi(event, '/prices', { method: 'POST', body })
})