0840efb759
Operator sign-out hardcoded the dev Authentik end-session URL, so prod logout landed on auth.dezky.local. Mirror the portal's env-driven pattern (NUXT_PUBLIC_AUTH_URL/NUXT_PUBLIC_OPERATOR_URL with .local fallbacks). Expose authUrl/operatorUrl via public runtimeConfig and use them for the Authentik admin links and the cosmetic host labels (sidebar, eyebrows, auth-page hints). Portal: signed-out + webmail copy now derive their hosts from runtime config (new public.mailUrl, NUXT_PUBLIC_MAIL_URL in prod).
142 lines
4.7 KiB
Vue
142 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
const authUrl = useRuntimeConfig().public.authUrl
|
|
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="`${authUrl}/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>
|