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,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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ onMounted(() => {
|
||||
<IncidentModal />
|
||||
<NotificationDrawer />
|
||||
<TweaksPanel />
|
||||
<ToastStack />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user