114b419a69
Every page header's Refresh button rendered a downward chevron because the icon set had no refresh glyph. Added a circular-arrow 'refresh' icon to UiIcon and pointed all seven Refresh buttons (Overview, Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
259 lines
6.6 KiB
Vue
259 lines
6.6 KiB
Vue
<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="refresh" :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>
|