8e81730372
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
67 lines
1.3 KiB
Vue
67 lines
1.3 KiB
Vue
<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>
|