feat(operator): tenant list + 7-tab detail with real lifecycle (O.5)
Operator can now manage tenants end-to-end from the UI:
- pages/tenants/index.vue — list with status/plan/domains/created/
provisioning-state columns, search by slug or name, status chips
with live counts (all/active/pending/suspended), click-through
to detail
- pages/tenants/[slug].vue — 7-tab detail (Overview, Users, Resources,
Billing, Audit, Support, Danger zone)
- 3 tabs hit real backends: Overview (identity + billing fields),
Users (lazy-loaded via new GET /tenants/:slug/users endpoint),
Resources (live provisioning state per integration + Reconcile button)
- 3 tabs render mock fixtures with warn-tone "mock" badges: Billing
(Stripe placeholder), Audit (sample log lines), Support (placeholder
pending the ticket queue work)
- Danger zone: 3 real-backend cards (Suspend / Resume / Soft-delete),
each gated by a ConfirmDialog modal. Verified live — clicked
Suspend on acme, status flipped to 'suspended' in Mongo, then
Resumed back to 'active'
platform-api additions:
- GET /tenants/:slug/users returns users with this tenant in their
tenantIds, sorted by last login. Same authorization rule as the
existing /tenants/:slug — platform admins always pass,
non-admins must be a member of the tenant
- tenants.module imports User schema for the new lookup
New components (apps/operator/components/):
- Tabs.vue — horizontal strip with optional per-tab counts, v-model
- ConfirmDialog.vue — Teleport-to-body modal, Escape/backdrop close,
danger/primary tone for the confirm button
Server proxy infrastructure (apps/operator/server/):
- utils/platform-api.ts — single helper encapsulating
access-token-from-session + bearer-forward + error normalization.
Every operator proxy route is now a one-liner against this helper
- api/tenants/index.get.ts, [slug]/{index.get,index.patch,index.delete,
users.get,suspend.post,resume.post,reconcile.post}.ts
Two real bugs found and fixed during the smoke test:
- Mongoose subdocument `_id` leaks into JSON when iterating
tenant.provisioningStatus. Switched to an explicit
`['authentik', 'stalwart', 'ocis']` whitelist in both v-fors
- Documents created before provisioningErrors was added (like the
acme tenant) don't have the field at all in JSON. Use optional
chaining (`tenant.provisioningErrors?.[k]`) instead of bracket
access. Without it: 'Cannot read properties of undefined (reading
"authentik")' during the Resources tab render
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Modal confirmation dialog for destructive/important actions. Closes on
|
||||||
|
// Escape or backdrop click. Confirm button styled by `tone` — primary for
|
||||||
|
// neutral confirmations, danger for destructive ones.
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
eyebrow?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
tone?: 'primary' | 'danger'
|
||||||
|
busy?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
confirmLabel: 'Confirm',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
tone: 'primary',
|
||||||
|
busy: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: []; confirm: [] }>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="backdrop" @click="emit('close')">
|
||||||
|
<div class="modal" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="close" @click="emit('close')">
|
||||||
|
<UiIcon name="x" :size="18" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<UiButton variant="ghost" @click="emit('close')">{{ cancelLabel }}</UiButton>
|
||||||
|
<UiButton :variant="tone === 'danger' ? 'danger' : 'primary'" :disabled="busy" @click="emit('confirm')">
|
||||||
|
{{ busy ? 'Working…' : confirmLabel }}
|
||||||
|
</UiButton>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 18px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.close:hover { background: var(--surface); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Horizontal tab strip with optional count badges. Active tab gets a 2px
|
||||||
|
// underline in --text color. v-model:value follows Vue convention.
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{ items: TabItem[]; modelValue: string }>()
|
||||||
|
defineEmits<{ 'update:modelValue': [string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="it in items"
|
||||||
|
:key="it.value"
|
||||||
|
:class="{ active: it.value === modelValue }"
|
||||||
|
@click="$emit('update:modelValue', it.value)"
|
||||||
|
>
|
||||||
|
{{ it.label }}
|
||||||
|
<span v-if="it.count !== undefined" class="count">{{ it.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-mute);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Tenant, TenantUser } from '~/types/tenant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = computed(() => route.params.slug as string)
|
||||||
|
|
||||||
|
const { data: tenant, refresh: refreshTenant } = await useFetch<Tenant>(() => `/api/tenants/${slug.value}`, {
|
||||||
|
watch: [slug],
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref<'overview' | 'users' | 'resources' | 'billing' | 'audit' | 'support' | 'danger'>('overview')
|
||||||
|
|
||||||
|
// Lazy-fetch users only when the tab is opened.
|
||||||
|
const { data: users, refresh: refreshUsers } = useLazyFetch<TenantUser[]>(
|
||||||
|
() => `/api/tenants/${slug.value}/users`,
|
||||||
|
{ immediate: false, default: () => [] },
|
||||||
|
)
|
||||||
|
watch(activeTab, (t) => {
|
||||||
|
if (t === 'users' && (users.value?.length ?? 0) === 0) refreshUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ value: 'overview', label: 'Overview' },
|
||||||
|
{ value: 'users', label: 'Users', count: users.value?.length },
|
||||||
|
{ value: 'resources', label: 'Resources' },
|
||||||
|
{ value: 'billing', label: 'Billing' },
|
||||||
|
{ value: 'audit', label: 'Audit' },
|
||||||
|
{ value: 'support', label: 'Support' },
|
||||||
|
{ value: 'danger', label: 'Danger zone' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const STATUS_TONE = {
|
||||||
|
active: 'ok', pending: 'warn', suspended: 'bad', deleted: 'neutral',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const INTEGRATION_TONE = {
|
||||||
|
ok: 'ok', skipped: 'neutral', error: 'bad', pending: 'warn',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Mongoose subdocs include an `_id` key when serialized — iterate explicitly
|
||||||
|
// over the integration names we care about so it doesn't leak into the UI.
|
||||||
|
const INTEGRATIONS = ['authentik', 'stalwart', 'ocis'] as const
|
||||||
|
type IntegrationKey = (typeof INTEGRATIONS)[number]
|
||||||
|
|
||||||
|
// ── Danger-zone state ─────────────────────────────────────────────────────
|
||||||
|
const dangerAction = ref<'suspend' | 'resume' | 'delete' | null>(null)
|
||||||
|
const dangerBusy = ref(false)
|
||||||
|
const dangerError = ref<string | null>(null)
|
||||||
|
const reconcileBusy = ref(false)
|
||||||
|
const reconcileMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function confirmDanger() {
|
||||||
|
if (!dangerAction.value) return
|
||||||
|
dangerBusy.value = true
|
||||||
|
dangerError.value = null
|
||||||
|
try {
|
||||||
|
if (dangerAction.value === 'suspend') {
|
||||||
|
await $fetch(`/api/tenants/${slug.value}/suspend`, { method: 'POST' })
|
||||||
|
} else if (dangerAction.value === 'resume') {
|
||||||
|
await $fetch(`/api/tenants/${slug.value}/resume`, { method: 'POST' })
|
||||||
|
} else if (dangerAction.value === 'delete') {
|
||||||
|
await $fetch(`/api/tenants/${slug.value}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
await refreshTenant()
|
||||||
|
dangerAction.value = null
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { data?: { data?: { message?: string }; message?: string }; statusCode?: number }
|
||||||
|
dangerError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
|
||||||
|
} finally {
|
||||||
|
dangerBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcile() {
|
||||||
|
reconcileBusy.value = true
|
||||||
|
reconcileMessage.value = null
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/tenants/${slug.value}/reconcile`, { method: 'POST' })
|
||||||
|
await refreshTenant()
|
||||||
|
reconcileMessage.value = 'Reconciled. Provisioning state updated.'
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
||||||
|
reconcileMessage.value = `Failed: ${e.data?.data?.message ?? e.data?.message ?? err}`
|
||||||
|
} finally {
|
||||||
|
reconcileBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="tenant">
|
||||||
|
<PageHeader
|
||||||
|
:eyebrow="`Tenant · ${tenant.slug}`"
|
||||||
|
:title="tenant.name"
|
||||||
|
:subtitle="`Created ${new Date(tenant.createdAt).toISOString().slice(0, 10)} · ${tenant.domains.length || 0} domain(s)`"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
|
||||||
|
<Badge tone="neutral">{{ tenant.plan }}</Badge>
|
||||||
|
<UiButton variant="secondary">
|
||||||
|
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||||
|
Open workspace
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="tabwrap">
|
||||||
|
<Tabs v-model="activeTab" :items="tabs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<!-- OVERVIEW -->
|
||||||
|
<div v-if="activeTab === 'overview'" class="grid">
|
||||||
|
<Card>
|
||||||
|
<h2>Identity</h2>
|
||||||
|
<dl>
|
||||||
|
<div class="dl-row"><dt>Slug</dt><dd><Mono>{{ tenant.slug }}</Mono></dd></div>
|
||||||
|
<div class="dl-row"><dt>Name</dt><dd>{{ tenant.name }}</dd></div>
|
||||||
|
<div class="dl-row"><dt>Status</dt><dd><Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge></dd></div>
|
||||||
|
<div class="dl-row"><dt>Plan</dt><dd><Badge tone="neutral">{{ tenant.plan }}</Badge></dd></div>
|
||||||
|
<div class="dl-row"><dt>Domains</dt><dd>
|
||||||
|
<Mono v-for="d in tenant.domains" :key="d">{{ d }}</Mono>
|
||||||
|
<Mono v-if="!tenant.domains.length" dim>none</Mono>
|
||||||
|
</dd></div>
|
||||||
|
<div class="dl-row"><dt>Partner</dt><dd>
|
||||||
|
<Mono v-if="tenant.partnerId">{{ tenant.partnerId }}</Mono>
|
||||||
|
<Mono v-else dim>direct (no partner)</Mono>
|
||||||
|
</dd></div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2>Billing</h2>
|
||||||
|
<dl>
|
||||||
|
<div class="dl-row"><dt>Company</dt><dd>{{ tenant.billingInfo.companyName || '—' }}</dd></div>
|
||||||
|
<div class="dl-row"><dt>VAT</dt><dd><Mono :dim="!tenant.billingInfo.vatId">{{ tenant.billingInfo.vatId || '—' }}</Mono></dd></div>
|
||||||
|
<div class="dl-row"><dt>Country</dt><dd><Mono :dim="!tenant.billingInfo.country">{{ tenant.billingInfo.country || '—' }}</Mono></dd></div>
|
||||||
|
<div class="dl-row"><dt>Contact</dt><dd><Mono :dim="!tenant.billingInfo.contactEmail">{{ tenant.billingInfo.contactEmail || '—' }}</Mono></dd></div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USERS -->
|
||||||
|
<div v-else-if="activeTab === 'users'">
|
||||||
|
<Card :pad="0">
|
||||||
|
<table class="rich">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th><th>Role</th><th>Status</th><th>Last login</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="(users ?? []).length === 0" class="empty"><td colspan="4"><span class="empty-inner">No users in this tenant yet.</span></td></tr>
|
||||||
|
<tr v-for="u in (users ?? [])" :key="u._id">
|
||||||
|
<td>
|
||||||
|
<div class="cell-tenant">
|
||||||
|
<div class="cell-name">{{ u.name }}</div>
|
||||||
|
<Mono dim>{{ u.email }}</Mono>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge :tone="u.platformAdmin ? 'accent' : 'neutral'">
|
||||||
|
{{ u.platformAdmin ? 'platform-admin' : u.role }}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge :tone="u.active ? 'ok' : 'bad'" dot>{{ u.active ? 'active' : 'disabled' }}</Badge>
|
||||||
|
</td>
|
||||||
|
<td><Mono dim>{{ u.lastLoginAt ? new Date(u.lastLoginAt).toISOString().slice(0, 16).replace('T', ' ') : '—' }}</Mono></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RESOURCES (real provisioning state) -->
|
||||||
|
<div v-else-if="activeTab === 'resources'" class="grid">
|
||||||
|
<Card>
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>External integrations</h2>
|
||||||
|
<UiButton variant="secondary" :disabled="reconcileBusy" @click="reconcile">
|
||||||
|
{{ reconcileBusy ? 'Reconciling…' : 'Reconcile now' }}
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
<p v-if="reconcileMessage" class="hint">{{ reconcileMessage }}</p>
|
||||||
|
<div class="integrations">
|
||||||
|
<div v-for="k in INTEGRATIONS" :key="k" class="integration">
|
||||||
|
<div class="integration-head">
|
||||||
|
<span class="integration-name">{{ k }}</span>
|
||||||
|
<Badge :tone="INTEGRATION_TONE[tenant.provisioningStatus?.[k] ?? 'pending']" dot>
|
||||||
|
{{ tenant.provisioningStatus?.[k] ?? 'pending' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="k === 'authentik'" class="integration-meta">
|
||||||
|
<Eyebrow>Group ID</Eyebrow>
|
||||||
|
<Mono :dim="!tenant.authentikGroupId">{{ tenant.authentikGroupId || 'not provisioned' }}</Mono>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="k === 'stalwart'" class="integration-meta">
|
||||||
|
<Eyebrow>Mail domain</Eyebrow>
|
||||||
|
<Mono :dim="!tenant.stalwartDomain">{{ tenant.stalwartDomain || 'not provisioned' }}</Mono>
|
||||||
|
</div>
|
||||||
|
<div v-else class="integration-meta">
|
||||||
|
<Eyebrow>Space ID</Eyebrow>
|
||||||
|
<Mono :dim="!tenant.ocisSpaceId">{{ tenant.ocisSpaceId || 'not provisioned' }}</Mono>
|
||||||
|
</div>
|
||||||
|
<div v-if="tenant.provisioningErrors?.[k]" class="integration-error">
|
||||||
|
<Eyebrow>Last error</Eyebrow>
|
||||||
|
<Mono>{{ tenant.provisioningErrors[k] }}</Mono>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BILLING (mock) -->
|
||||||
|
<div v-else-if="activeTab === 'billing'">
|
||||||
|
<Card>
|
||||||
|
<div class="card-head"><h2>Subscriptions & invoices</h2><Badge tone="warn">mock</Badge></div>
|
||||||
|
<p class="hint">Stripe integration ships in a later phase. Right now we show fixtures.</p>
|
||||||
|
<dl class="mt">
|
||||||
|
<div class="dl-row"><dt>Plan</dt><dd><Badge tone="neutral">{{ tenant.plan }}</Badge></dd></div>
|
||||||
|
<div class="dl-row"><dt>MRR</dt><dd><Mono>4 840 DKK</Mono></dd></div>
|
||||||
|
<div class="dl-row"><dt>Next invoice</dt><dd><Mono dim>2026-06-01</Mono></dd></div>
|
||||||
|
<div class="dl-row"><dt>Stripe customer</dt><dd><Mono dim>cus_mock_…</Mono></dd></div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AUDIT (mock) -->
|
||||||
|
<div v-else-if="activeTab === 'audit'">
|
||||||
|
<Card :pad="0">
|
||||||
|
<div class="card-head padded"><h2>Tenant-scoped audit</h2><Badge tone="warn">mock</Badge></div>
|
||||||
|
<table class="rich">
|
||||||
|
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><Mono dim>15:02</Mono></td><td>Anne Baslund</td><td><Mono>tenant.plan_changed</Mono></td><td><Mono dim>{{ tenant.plan }} → enterprise</Mono></td></tr>
|
||||||
|
<tr><td><Mono dim>14:18</Mono></td><td>system</td><td><Mono>alert.triggered</Mono></td><td><Mono dim>authentik p95 spike</Mono></td></tr>
|
||||||
|
<tr><td><Mono dim>10:55</Mono></td><td>system</td><td><Mono>invoice.past_due</Mono></td><td><Mono dim>INV-0522 · 21 d</Mono></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUPPORT (mock) -->
|
||||||
|
<div v-else-if="activeTab === 'support'">
|
||||||
|
<Card>
|
||||||
|
<div class="card-head"><h2>Support tickets</h2><Badge tone="warn">mock</Badge></div>
|
||||||
|
<p class="hint">Support queue ships in a later phase. No real tickets yet.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DANGER (real) -->
|
||||||
|
<div v-else-if="activeTab === 'danger'" class="grid">
|
||||||
|
<Card>
|
||||||
|
<h2 class="danger">Suspend tenant</h2>
|
||||||
|
<p>
|
||||||
|
Locks all user logins to this tenant immediately. Mail flow halts, OCIS shares
|
||||||
|
become read-only. Reversible via Resume.
|
||||||
|
</p>
|
||||||
|
<UiButton
|
||||||
|
variant="danger"
|
||||||
|
:disabled="tenant.status !== 'active'"
|
||||||
|
@click="dangerAction = 'suspend'; dangerError = null"
|
||||||
|
>Suspend tenant</UiButton>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2>Resume tenant</h2>
|
||||||
|
<p>Restores access. Status goes back to active.</p>
|
||||||
|
<UiButton
|
||||||
|
variant="primary"
|
||||||
|
:disabled="tenant.status !== 'suspended'"
|
||||||
|
@click="dangerAction = 'resume'; dangerError = null"
|
||||||
|
>Resume tenant</UiButton>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 class="danger">Soft-delete</h2>
|
||||||
|
<p>
|
||||||
|
Marks the tenant as deleted in Mongo. External resources (Authentik group,
|
||||||
|
Stalwart domain, OCIS space) are NOT removed here — that's the next phase.
|
||||||
|
Recoverable until the cleanup job runs.
|
||||||
|
</p>
|
||||||
|
<UiButton
|
||||||
|
variant="danger"
|
||||||
|
:disabled="tenant.status === 'deleted'"
|
||||||
|
@click="dangerAction = 'delete'; dangerError = null"
|
||||||
|
>Soft-delete tenant</UiButton>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="dangerAction !== null"
|
||||||
|
:eyebrow="`Tenant · ${tenant.slug}`"
|
||||||
|
:title="dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
|
||||||
|
:confirm-label="dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
|
||||||
|
:tone="dangerAction === 'resume' ? 'primary' : 'danger'"
|
||||||
|
:busy="dangerBusy"
|
||||||
|
@close="dangerAction = null"
|
||||||
|
@confirm="confirmDanger"
|
||||||
|
>
|
||||||
|
<p v-if="dangerAction === 'suspend'">
|
||||||
|
All <strong>{{ tenant.name }}</strong> users will lose access on their next request.
|
||||||
|
You can resume at any time — no data is removed.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="dangerAction === 'resume'">
|
||||||
|
Lift the suspension on <strong>{{ tenant.name }}</strong>. Logins resume immediately.
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Mark <strong>{{ tenant.name }}</strong> as deleted. Their data stays in Mongo until
|
||||||
|
the cleanup job runs; external resources stay untouched.
|
||||||
|
</p>
|
||||||
|
<p v-if="dangerError" class="danger-err">{{ dangerError }}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabwrap { padding: 0 40px; }
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
padding: 24px 40px 64px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
h2.danger { color: var(--bad); }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
p.hint { font-size: 12px; color: var(--text-mute); margin-bottom: 0; }
|
||||||
|
.danger-err { color: var(--bad); font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
|
||||||
|
dl { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
dl.mt { margin-top: 14px; }
|
||||||
|
.dl-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
.card-head.padded { padding: 20px 24px 0 24px; }
|
||||||
|
|
||||||
|
.integrations {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.integration-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.integration-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.integration-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.integration-meta > :first-child { width: 80px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.integration-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.rich {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
table.rich thead tr { border-bottom: 1px solid var(--border); }
|
||||||
|
table.rich th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
table.rich tbody tr { border-bottom: 1px solid var(--border); }
|
||||||
|
table.rich tbody tr:last-child { border-bottom: none; }
|
||||||
|
table.rich td { padding: 14px 16px; }
|
||||||
|
table.rich .empty td { padding: 36px 16px; text-align: center; }
|
||||||
|
.empty-inner { color: var(--text-mute); font-size: 13px; }
|
||||||
|
|
||||||
|
.cell-tenant { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.cell-name { font-weight: 500; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Tenant, TenantStatus } from '~/types/tenant'
|
||||||
|
|
||||||
|
const { data: tenants, refresh, pending } = await useFetch<Tenant[]>('/api/tenants', {
|
||||||
|
default: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const statusFilter = ref<'all' | TenantStatus>('all')
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
return (tenants.value ?? []).filter((t) => {
|
||||||
|
if (statusFilter.value !== 'all' && t.status !== statusFilter.value) return false
|
||||||
|
if (!q) return true
|
||||||
|
return t.slug.toLowerCase().includes(q) || t.name.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = computed(() => {
|
||||||
|
const c = { all: 0, active: 0, pending: 0, suspended: 0, deleted: 0 }
|
||||||
|
for (const t of tenants.value ?? []) {
|
||||||
|
c.all++
|
||||||
|
c[t.status]++
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<TenantStatus, 'ok' | 'warn' | 'bad' | 'neutral'> = {
|
||||||
|
active: 'ok',
|
||||||
|
pending: 'warn',
|
||||||
|
suspended: 'bad',
|
||||||
|
deleted: 'neutral',
|
||||||
|
}
|
||||||
|
|
||||||
|
function navTo(t: Tenant) {
|
||||||
|
return navigateTo(`/tenants/${t.slug}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Customers"
|
||||||
|
title="Tenants"
|
||||||
|
:subtitle="`${counts.all} tenants — ${counts.active} active, ${counts.pending} pending, ${counts.suspended} suspended.`"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
|
||||||
|
<template #leading><UiIcon name="chevDown" :size="13" /></template>
|
||||||
|
Refresh
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="primary">
|
||||||
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
|
New tenant
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<div class="filters">
|
||||||
|
<div class="search">
|
||||||
|
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||||
|
<input v-model="search" placeholder="Search slug or name…" />
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
v-for="opt in (['all', 'active', 'pending', 'suspended'] as const)"
|
||||||
|
:key="opt"
|
||||||
|
:class="['chip', { active: statusFilter === opt }]"
|
||||||
|
@click="statusFilter = opt"
|
||||||
|
>
|
||||||
|
{{ opt }}
|
||||||
|
<span class="chip-count">{{ counts[opt] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card :pad="0">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tenant</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Domains</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th class="th-right">Provisioning</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="filtered.length === 0" class="empty">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="empty-inner">
|
||||||
|
<UiIcon name="building" :size="20" stroke="var(--text-mute)" />
|
||||||
|
<span>No tenants match this filter.</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="t in filtered" :key="t._id" class="clickable" @click="navTo(t)">
|
||||||
|
<td>
|
||||||
|
<div class="cell-tenant">
|
||||||
|
<div class="cell-name">{{ t.name }}</div>
|
||||||
|
<Mono dim>{{ t.slug }}</Mono>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><Badge :tone="STATUS_TONE[t.status]" dot>{{ t.status }}</Badge></td>
|
||||||
|
<td><Badge tone="neutral">{{ t.plan }}</Badge></td>
|
||||||
|
<td>
|
||||||
|
<Mono dim>{{ t.domains.length ? t.domains[0] : '—' }}</Mono>
|
||||||
|
<Mono v-if="t.domains.length > 1" dim>(+{{ t.domains.length - 1 }})</Mono>
|
||||||
|
</td>
|
||||||
|
<td><Mono dim>{{ new Date(t.createdAt).toISOString().slice(0, 10) }}</Mono></td>
|
||||||
|
<td class="td-right">
|
||||||
|
<div class="prov-row">
|
||||||
|
<span
|
||||||
|
v-for="k in (['authentik', 'stalwart', 'ocis'] as const)"
|
||||||
|
:key="k"
|
||||||
|
:class="['prov', `prov-${t.provisioningStatus?.[k] ?? 'pending'}`]"
|
||||||
|
:title="`${k}: ${t.provisioningStatus?.[k] ?? 'pending'}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stage {
|
||||||
|
padding: 24px 40px 64px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.search input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.search input::placeholder { color: var(--text-mute); }
|
||||||
|
|
||||||
|
.chips { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover { background: var(--elevated); color: var(--text); }
|
||||||
|
.chip.active {
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
.chip-count {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
th.th-right { text-align: right; }
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
tbody tr.clickable { cursor: pointer; }
|
||||||
|
tbody tr.clickable:hover { background: var(--surface); }
|
||||||
|
tbody tr:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
td.td-right { text-align: right; }
|
||||||
|
|
||||||
|
.cell-tenant { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.cell-name { font-weight: 500; font-size: 13px; }
|
||||||
|
|
||||||
|
.empty td { padding: 48px 16px; text-align: center; }
|
||||||
|
.empty-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prov-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
|
||||||
|
.prov {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.prov-ok { background: var(--ok); }
|
||||||
|
.prov-skipped { background: var(--text-mute); }
|
||||||
|
.prov-error { background: var(--bad); }
|
||||||
|
.prov-pending { background: var(--warn); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
await platformApi(event, `/tenants/${slug}`, { method: 'DELETE' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
return platformApi(event, `/tenants/${slug}`)
|
||||||
|
})
|
||||||
@@ -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, `/tenants/${slug}`, { method: 'PATCH', body })
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
return platformApi(event, `/tenants/${slug}/reconcile`, { method: 'POST' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
return platformApi(event, `/tenants/${slug}/resume`, { method: 'POST' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
return platformApi(event, `/tenants/${slug}/suspend`, { method: 'POST' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
return platformApi(event, `/tenants/${slug}/users`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => platformApi(event, '/tenants'))
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Helper: forward a request to platform-api using the signed-in operator's
|
||||||
|
// access token. Every operator proxy route uses this — it's the only place
|
||||||
|
// we touch the encrypted session.
|
||||||
|
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
const BASE = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
|
||||||
|
export async function platformApi<T = unknown>(
|
||||||
|
event: H3Event,
|
||||||
|
path: string,
|
||||||
|
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await $fetch(`${BASE}${path}`, {
|
||||||
|
method: (init.method as 'GET' | 'POST' | 'PATCH' | 'DELETE') ?? 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
body: init.body,
|
||||||
|
query: init.query,
|
||||||
|
})) as T
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { statusCode?: number; data?: unknown }
|
||||||
|
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Shape returned by /api/tenants — matches Tenant schema on platform-api.
|
||||||
|
// Kept in a separate file so multiple pages can import without copying.
|
||||||
|
|
||||||
|
export type TenantStatus = 'pending' | 'active' | 'suspended' | 'deleted'
|
||||||
|
export type TenantPlan = 'mvp' | 'pro' | 'enterprise'
|
||||||
|
export type IntegrationState = 'pending' | 'ok' | 'error' | 'skipped'
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
_id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
status: TenantStatus
|
||||||
|
plan: TenantPlan
|
||||||
|
domains: string[]
|
||||||
|
partnerId?: string
|
||||||
|
authentikGroupId?: string
|
||||||
|
ocisSpaceId?: string
|
||||||
|
stalwartDomain?: string
|
||||||
|
billingInfo: {
|
||||||
|
companyName?: string
|
||||||
|
vatId?: string
|
||||||
|
country?: string
|
||||||
|
contactEmail?: string
|
||||||
|
}
|
||||||
|
provisioningStatus: {
|
||||||
|
authentik: IntegrationState
|
||||||
|
stalwart: IntegrationState
|
||||||
|
ocis: IntegrationState
|
||||||
|
}
|
||||||
|
provisioningErrors: {
|
||||||
|
authentik?: string
|
||||||
|
stalwart?: string
|
||||||
|
ocis?: string
|
||||||
|
}
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantUser {
|
||||||
|
_id: string
|
||||||
|
authentikSubjectId: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
role: 'owner' | 'admin' | 'member'
|
||||||
|
active: boolean
|
||||||
|
platformAdmin: boolean
|
||||||
|
tenantIds: string[]
|
||||||
|
lastLoginAt?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
+44
-13
@@ -369,20 +369,51 @@ done in order — earlier ones unblock later ones.
|
|||||||
needs `allowedHosts: ['operator.dezky.local']` in `nuxt.config.ts`
|
needs `allowedHosts: ['operator.dezky.local']` in `nuxt.config.ts`
|
||||||
under `vite.server` or every request 403s with a plaintext error.
|
under `vite.server` or every request 403s with a plaintext error.
|
||||||
|
|
||||||
### O.5 · Tenant management (real backend)
|
### O.5 · Tenant management (real backend) ✓
|
||||||
|
|
||||||
- [ ] `pages/tenants/index.vue` — list with status/plan/seats/MRR columns,
|
- [x] `pages/tenants/index.vue` — list with status/plan/domains/created/
|
||||||
filter by partner and status, search by slug/name
|
provisioning-state columns; search by slug or name; status chips
|
||||||
- [ ] `pages/tenants/[slug].vue` — detail view with tabs
|
(all / active / pending / suspended) with live counts; click-through
|
||||||
- [ ] Tab: **Overview** — header card, key stats, partner link
|
to detail
|
||||||
- [ ] Tab: **Users** — list users via `GET /users?tenantSlug=…`
|
- [x] `pages/tenants/[slug].vue` — detail with 7 tabs (`Tabs` primitive)
|
||||||
- [ ] Tab: **Resources** — provisioning status per integration
|
- [x] Tab: **Overview** — Identity card + Billing card with key fields
|
||||||
(Authentik / Stalwart / OCIS), error messages, "Reconcile" button
|
- [x] Tab: **Users** — list users via `GET /api/tenants/:slug/users` (new
|
||||||
- [ ] Tab: **Billing** (mock fixtures)
|
endpoint added to platform-api), lazy-loaded on first tab click
|
||||||
- [ ] Tab: **Audit** (mock fixtures)
|
- [x] Tab: **Resources** — per-integration `Badge` + external handle
|
||||||
- [ ] Tab: **Support** (mock fixtures)
|
(group ID / mail domain / space ID) + last error if any; **Reconcile
|
||||||
- [ ] Tab: **Danger** — suspend, resume, change plan, soft-delete; real
|
now** button re-runs orchestration in place. Iterates the explicit
|
||||||
backend calls, confirmation modals
|
`INTEGRATIONS` array, not the raw Mongoose subdoc keys, so the
|
||||||
|
`_id` field doesn't leak into the UI
|
||||||
|
- [x] Tab: **Billing** (mock — plan + MRR + Stripe IDs as fixtures)
|
||||||
|
- [x] Tab: **Audit** (mock — three sample log entries)
|
||||||
|
- [x] Tab: **Support** (mock — placeholder)
|
||||||
|
- [x] Tab: **Danger zone** — three real-backend cards (Suspend / Resume /
|
||||||
|
Soft-delete) each gated by ConfirmDialog. Suspended verified live:
|
||||||
|
acme → `suspended` in Mongo, then Resumed back to `active`
|
||||||
|
|
||||||
|
### Server proxies added (apps/operator/server/api/tenants/)
|
||||||
|
|
||||||
|
`platform-api.ts` util encapsulates the access-token forwarding. Routes:
|
||||||
|
`index.get`, `[slug]/index.{get,patch,delete}`, `[slug]/users.get`,
|
||||||
|
`[slug]/{suspend,resume,reconcile}.post`. All read the operator's session,
|
||||||
|
forward as bearer to platform-api.
|
||||||
|
|
||||||
|
### New primitives this phase
|
||||||
|
|
||||||
|
`Tabs.vue` (horizontal strip with optional counts), `ConfirmDialog.vue`
|
||||||
|
(Teleport-to-body modal, Escape/backdrop close, danger/primary tone).
|
||||||
|
|
||||||
|
### Gotchas worth noting
|
||||||
|
|
||||||
|
- **Mongoose subdoc `_id` leaks into JSON**: iterating `v-for="(state, k)
|
||||||
|
in tenant.provisioningStatus"` includes `_id`. Iterate an explicit
|
||||||
|
whitelist (`['authentik', 'stalwart', 'ocis']`) instead.
|
||||||
|
- **Fields added to schema after document creation are missing in old
|
||||||
|
docs**: acme was created before `provisioningErrors` existed, so
|
||||||
|
`tenant.provisioningErrors[k]` throws `Cannot read properties of
|
||||||
|
undefined`. Use optional chaining (`tenant.provisioningErrors?.[k]`).
|
||||||
|
- Nitro can throw `Could not load /app/server/api/...` if Vite picks
|
||||||
|
up a new file mid-build. Container restart clears it.
|
||||||
|
|
||||||
### O.6 · Partner management (real backend)
|
### O.6 · Partner management (real backend)
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ export class TenantsController {
|
|||||||
return tenant
|
return tenant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':slug/users')
|
||||||
|
async listUsers(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return this.tenants.listUsersForTenant(slug)
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':slug')
|
@Patch(':slug')
|
||||||
async update(
|
async update(
|
||||||
@Param('slug') slug: string,
|
@Param('slug') slug: string,
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { MongooseModule } from '@nestjs/mongoose'
|
|||||||
import { AuthModule } from '../auth/auth.module.js'
|
import { AuthModule } from '../auth/auth.module.js'
|
||||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
|
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
import { ProvisioningService } from './provisioning.service.js'
|
import { ProvisioningService } from './provisioning.service.js'
|
||||||
import { TenantsController } from './tenants.controller.js'
|
import { TenantsController } from './tenants.controller.js'
|
||||||
import { TenantsService } from './tenants.service.js'
|
import { TenantsService } from './tenants.service.js'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MongooseModule.forFeature([{ name: Tenant.name, schema: TenantSchema }]),
|
MongooseModule.forFeature([
|
||||||
|
{ name: Tenant.name, schema: TenantSchema },
|
||||||
|
{ name: User.name, schema: UserSchema },
|
||||||
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
|||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model, Types } from 'mongoose'
|
import { Model, Types } from 'mongoose'
|
||||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
|
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||||
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||||
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||||
import { ProvisioningService } from './provisioning.service.js'
|
import { ProvisioningService } from './provisioning.service.js'
|
||||||
@@ -10,9 +11,18 @@ import { ProvisioningService } from './provisioning.service.js'
|
|||||||
export class TenantsService {
|
export class TenantsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||||
|
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||||
private readonly provisioning: ProvisioningService,
|
private readonly provisioning: ProvisioningService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
|
||||||
|
const tenant = await this.findOneBySlug(slug)
|
||||||
|
return this.userModel
|
||||||
|
.find({ tenantIds: tenant._id })
|
||||||
|
.sort({ lastLoginAt: -1, createdAt: -1 })
|
||||||
|
.exec()
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateTenantDto): Promise<TenantDocument> {
|
async create(dto: CreateTenantDto): Promise<TenantDocument> {
|
||||||
const exists = await this.tenantModel.exists({ slug: dto.slug })
|
const exists = await this.tenantModel.exists({ slug: dto.slug })
|
||||||
if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)
|
if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)
|
||||||
|
|||||||
Reference in New Issue
Block a user