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:
Ronni Baslund
2026-05-24 07:44:23 +02:00
parent 8e6f73a921
commit 8e81730372
18 changed files with 1107 additions and 14 deletions
+455
View File
@@ -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 &amp; 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>
+258
View File
@@ -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>