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.
170 lines
5.3 KiB
Vue
170 lines
5.3 KiB
Vue
<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="refresh" :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>
|