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