Files
dezky/apps/operator/pages/operator-team.vue
T
Ronni Baslund 9a97945565 feat(operator): invite operator → creates user in Authentik
New "Invite operator" button + modal on /operator-team. Replaces the
bounce-to-Authentik flow with an inline invite that creates the user via
the Authentik API and pre-populates our local User doc so they appear
immediately.

services/platform-api/src/integrations/authentik.client.ts:
  - findUserByEmail(): early-conflict check before we attempt the create
  - createUser(): POST /core/users/ with username = email, internal type,
    is_active, attached to the supplied group PKs
  - addUserToGroup(): kept for tenant-member invites later
  - recoveryLink(): tries POST /core/users/{pk}/recovery/, returns
    undefined when no recovery flow is configured on the Authentik brand
    (we soft-fail and the service falls back to setInitialPassword)
  - setInitialPassword(): POST /core/users/{pk}/set_password/. Returns 204
    No Content so we bypass request<T>'s JSON parser and call fetch
    directly with explicit ok check.

services/platform-api/src/users/users.service.ts:
  - inviteOperator(dto, actor) orchestrates: dedup by email →
    findOrCreate Authentik group → create user in group → pre-create
    local User doc with platformAdmin=true so the list reflects them
    immediately → try recovery link → fall back to temp password →
    record platform.user_invited audit event with handoff method.
  - Return type is { subject, userId, link? | tempPassword? } —
    exactly one credential mode set depending on Authentik config.
  - generateTempPassword(): 16-char with at least one upper/lower/digit/
    symbol, shuffled. Confusable chars (I/O/0/1/l) omitted.
  - Cached platform-admin group ID after first lookup.

services/platform-api/src/users/users.controller.ts:
  - POST /users/invite behind OperatorGuard. Calls the service with
    actor + IP from the JWT/request.

apps/operator:
  - server/api/users/invite.post.ts: standard platformApi proxy.
  - components/InviteOperatorModal.vue: 2-step form. Step 1: name +
    email with client-side validation. Step 2: shows whichever
    credential the backend returned — recovery link OR username+
    temp-password — with copy-to-clipboard buttons and a note about
    SMTP/recovery-flow follow-up paths.
  - pages/operator-team.vue: "Invite operator" replaces "Manage in
    Authentik" as the primary action; Authentik link demoted to
    secondary. Refreshes the list on @invited so the new user shows
    up without a manual reload.

Verified end-to-end against real Authentik:
  - Invite created user pk=7, uid=f22f2bb…, group=dezky-platform-admins,
    is_active=true, temp password set. Modal showed both fields with
    copy buttons; operator-team count went 1 → 2 immediately. Audit
    event recorded (platform.user_invited with handoff='temp-password').
  - Recovery link path is preferred but Authentik has no recovery flow
    configured on the default brand. AuthentikClient.recoveryLink()
    soft-fails on the "No recovery flow set." 400, returns undefined,
    and inviteOperator transparently falls back to set_password. Once
    a recovery flow is configured (Authentik admin → Flows), the link
    path becomes active and the temp-password path stops firing
    without any code changes.

Known follow-ups:
  - Configure Authentik recovery flow so the link path activates
    (one-time admin task, not in code)
  - Outbound SMTP wiring (Phase 5/6) → Authentik can email link/temp
    directly; modal stops showing the credential
  - Deactivate / remove operator from inside the app (currently still
    Authentik UI; defensible until proven needed)
  - Tenant-member invite — similar flow but adds to tenant group
    instead, exposed from /users (global users) or tenant detail
2026-05-24 21:27:46 +02:00

141 lines
4.7 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="chevDown" :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>