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.
141 lines
4.6 KiB
Vue
141 lines
4.6 KiB
Vue
<script setup lang="ts">
|
|
import type { PlatformUser } from '~/types/user'
|
|
|
|
const { data: users, pending, refresh } = await useFetch<PlatformUser[]>('/api/users', {
|
|
default: () => [],
|
|
})
|
|
|
|
const operators = computed(() => (users.value ?? []).filter((u) => u.platformAdmin))
|
|
|
|
const inviteOpen = ref(false)
|
|
|
|
function lastSeen(u: PlatformUser) {
|
|
if (!u.lastLoginAt) return '—'
|
|
const diff = Date.now() - new Date(u.lastLoginAt).getTime()
|
|
const m = Math.floor(diff / 60_000)
|
|
if (m < 1) return 'active'
|
|
if (m < 60) return `${m} min ago`
|
|
const h = Math.floor(m / 60)
|
|
if (h < 24) return `${h} h ago`
|
|
const d = Math.floor(h / 24)
|
|
return `${d} d ago`
|
|
}
|
|
|
|
async function onInvited() {
|
|
// Refresh the list so the newly-invited operator shows up immediately —
|
|
// platform-api pre-creates the local User doc, so they appear with
|
|
// platformAdmin=true even before their first login.
|
|
await refresh()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Platform"
|
|
title="Operator team"
|
|
:subtitle="`${operators.length} platform admin${operators.length === 1 ? '' : 's'} · membership comes from the dezky-platform-admins Authentik group.`"
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
|
|
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
|
Refresh
|
|
</UiButton>
|
|
<a href="https://auth.dezky.local/if/admin/" target="_blank" rel="noopener" class="link">
|
|
<UiButton variant="secondary">
|
|
<template #leading><UiIcon name="external" :size="13" /></template>
|
|
Manage in Authentik
|
|
</UiButton>
|
|
</a>
|
|
<UiButton variant="primary" @click="inviteOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
|
Invite operator
|
|
</UiButton>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<InviteOperatorModal :open="inviteOpen" @close="inviteOpen = false" @invited="onInvited" />
|
|
|
|
<div class="stage">
|
|
<Card :pad="0">
|
|
<table v-if="operators.length">
|
|
<thead>
|
|
<tr>
|
|
<th>Member</th>
|
|
<th>Email</th>
|
|
<th>Tenants</th>
|
|
<th>Last seen</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="u in operators" :key="u._id">
|
|
<td class="member">
|
|
<Avatar :name="u.name" :size="28" />
|
|
<div>
|
|
<div class="name">{{ u.name }}</div>
|
|
<Mono dim>{{ u.authentikSubjectId.slice(0, 8) }}</Mono>
|
|
</div>
|
|
</td>
|
|
<td><Mono>{{ u.email }}</Mono></td>
|
|
<td>
|
|
<span v-if="u.tenantIds?.length"><Mono>{{ u.tenantIds.length }}</Mono></span>
|
|
<Mono v-else dim>—</Mono>
|
|
</td>
|
|
<td><Mono dim>{{ lastSeen(u) }}</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 platform admins found — add a user to the dezky-platform-admins group in Authentik</Mono>
|
|
</div>
|
|
</Card>
|
|
|
|
<div class="note">
|
|
<UiIcon name="shield" :size="13" />
|
|
<Mono dim>
|
|
Operator access is gated by membership in the <strong>dezky-platform-admins</strong> Authentik
|
|
group plus a token with audience <code>dezky-operator</code>. Both conditions must hold.
|
|
</Mono>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
|
|
.link { text-decoration: none; }
|
|
|
|
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.member { display: flex; align-items: center; gap: 12px; }
|
|
.name { font-weight: 500; font-size: 13px; }
|
|
.empty { padding: 40px 20px; text-align: center; }
|
|
|
|
.note {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border: 1px dashed var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text-mute);
|
|
}
|
|
.note code { font-family: var(--font-mono); color: var(--text-dim); padding: 1px 4px; border-radius: 3px; background: var(--surface); }
|
|
.note strong { color: var(--text); font-weight: 600; }
|
|
</style>
|