feat(operator): visual-only screens with real-data overview (O.7)
- Overview (pages/index.vue): KPIs from real /tenants /partners /users, status meter, recent + needs-follow-up tables. Mock activity stream and incident banner overlay come from data/fixtures.ts. - Operator team: real GET /users filtered to platformAdmin === true, with last-seen + tenant counts. - Users (global): real read with All/Admins/Inactive views and search. - Infrastructure / Feature flags / Audit: mock fixtures only — wiring to real backends (Prometheus, OpenFeature, append-only audit) is tracked as follow-ups in OPERATOR-PLAN.md. - Placeholder pages (support/billing/reports/settings) via OpPlaceholder. - Shared: Stat, MetricCell, OpPlaceholder components, /api/users proxy, PlatformUser type. - .gitignore: scope the docker volumes data/ rule so apps/*/data/ is tracked again (operator carries mock fixtures there).
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import type { PlatformUser } from '~/types/user'
|
||||
|
||||
const { data: users, pending, refresh } = await useFetch<PlatformUser[]>('/api/users', {
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
const view = ref<'all' | 'admins' | 'inactive'>('all')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return (users.value ?? []).filter((u) => {
|
||||
if (view.value === 'admins' && !u.platformAdmin) return false
|
||||
if (view.value === 'inactive' && u.active) return false
|
||||
if (!q) return true
|
||||
return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const counts = computed(() => ({
|
||||
all: users.value?.length ?? 0,
|
||||
admins: (users.value ?? []).filter((u) => u.platformAdmin).length,
|
||||
inactive: (users.value ?? []).filter((u) => !u.active).length,
|
||||
}))
|
||||
|
||||
function lastSeen(u: PlatformUser) {
|
||||
if (!u.lastLoginAt) return '—'
|
||||
const diff = Date.now() - new Date(u.lastLoginAt).getTime()
|
||||
const d = Math.floor(diff / 86_400_000)
|
||||
if (d > 0) return `${d} d ago`
|
||||
const h = Math.floor(diff / 3_600_000)
|
||||
if (h > 0) return `${h} h ago`
|
||||
const m = Math.floor(diff / 60_000)
|
||||
if (m > 0) return `${m} min ago`
|
||||
return 'active'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Identity"
|
||||
title="Users (global)"
|
||||
:subtitle="`${counts.all} users across all tenants. ${counts.admins} platform admin${counts.admins === 1 ? '' : 's'}.`"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
|
||||
<template #leading><UiIcon name="chevDown" :size="13" /></template>
|
||||
Refresh
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="stage">
|
||||
<div class="toolbar">
|
||||
<div class="views">
|
||||
<button :class="['chip', { on: view === 'all' }]" type="button" @click="view = 'all'">All <Mono dim>{{ counts.all }}</Mono></button>
|
||||
<button :class="['chip', { on: view === 'admins' }]" type="button" @click="view = 'admins'">Admins <Mono dim>{{ counts.admins }}</Mono></button>
|
||||
<button :class="['chip', { on: view === 'inactive' }]" type="button" @click="view = 'inactive'">Inactive <Mono dim>{{ counts.inactive }}</Mono></button>
|
||||
</div>
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="13" />
|
||||
<input v-model="search" placeholder="email, name…" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table v-if="filtered.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Tenants</th>
|
||||
<th>Last login</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in filtered" :key="u._id">
|
||||
<td class="user">
|
||||
<Avatar :name="u.name" :size="26" />
|
||||
<div>
|
||||
<div class="name">{{ u.name }}</div>
|
||||
<Mono dim>{{ u.authentikSubjectId.slice(0, 12) }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ u.email }}</Mono></td>
|
||||
<td><Mono>{{ u.tenantIds?.length ?? 0 }}</Mono></td>
|
||||
<td><Mono dim>{{ lastSeen(u) }}</Mono></td>
|
||||
<td>
|
||||
<Badge v-if="u.platformAdmin" tone="accent" dot>platform admin</Badge>
|
||||
<Mono v-else dim>tenant user</Mono>
|
||||
</td>
|
||||
<td>
|
||||
<Badge :tone="u.active ? 'ok' : 'neutral'" dot>{{ u.active ? 'active' : 'inactive' }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">
|
||||
<Mono dim>// no users match the current filter</Mono>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.views { display: flex; gap: 6px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover { color: var(--text); }
|
||||
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.chip.on :deep(.mono) { color: var(--bg); opacity: 0.7; }
|
||||
|
||||
.search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-mute);
|
||||
min-width: 280px;
|
||||
}
|
||||
.search input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 20px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td { padding: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
|
||||
td.user { display: flex; align-items: center; gap: 10px; }
|
||||
.name { font-weight: 500; font-size: 13px; }
|
||||
.empty { padding: 40px 20px; text-align: center; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user