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