Files
dezky/apps/portal/pages/admin/users.vue
T
Ronni Baslund 3288fde693 feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
  leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
  launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.

Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
  invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
  and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
  honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.

Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.

Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
  name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
  with YIQ auto-contrast (readableOn util).

Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
  pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
  drag-select-to-close).
2026-05-31 00:19:34 +02:00

549 lines
22 KiB
Vue

<script setup lang="ts">
// Users & groups. The Users tab is real — workspace members come from
// /api/tenants/:slug/users (platform-api UserDocument). The Groups,
// Invitations and Service-accounts tabs have no backend yet (no Group /
// Invitation / ServiceAccount schema exists), so they render honest
// "coming soon" states rather than fabricated rows.
//
// Mutations (invite, suspend, role change, CSV import) still toast-stub: the
// user-write endpoints are operator-only today, so a customer admin can't
// commit them yet. The data shown is real; the writes are not wired.
import type { TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const { fetchMe } = useMe()
await fetchMe()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { data: users } = await useFetch<TenantUserDoc[]>(
() => `/api/tenants/${slug.value}/users`,
{ key: 'admin-users', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
const query = ref('')
const statusFilter = ref<'all' | 'active' | 'suspended'>('all')
const selected = ref<Set<string>>(new Set())
const openUser = ref<TenantUserDoc | null>(null)
const inviteOpen = ref(false)
const inviteStep = ref(1)
const importOpen = ref(false)
const userStatus = (u: TenantUserDoc): 'active' | 'suspended' => (u.active === false ? 'suspended' : 'active')
const roleLabel = (r: string) => r.charAt(0).toUpperCase() + r.slice(1)
const filteredUsers = computed(() =>
(users.value ?? []).filter((u) => {
if (statusFilter.value !== 'all' && userStatus(u) !== statusFilter.value) return false
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
return true
}),
)
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
s === 'active' ? 'ok' : s === 'suspended' ? 'bad' : 'warn'
function lastSeen(iso?: string): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
const m = Math.floor(ms / 60_000)
if (m < 1) return 'just now'
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)
if (d < 30) return `${d} day${d === 1 ? '' : 's'} ago`
return new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' })
}
function joinedDate(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'
}
function toggleSelect(id: string) {
const s = new Set(selected.value)
if (s.has(id)) s.delete(id)
else s.add(id)
selected.value = s
}
function clearSelection() { selected.value = new Set() }
// Filter chip
type ChipOption = { value: string; label: string }
const statusOptions: ChipOption[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
]
// Bulk-action modals + confirm
const changeRoleOpen = ref(false)
const suspendOpen = ref(false)
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
function sendInvite() {
inviteOpen.value = false
inviteStep.value = 1
toast.ok('Invitation sent to magnus@dezky.com')
}
function applyBulkRole() {
const n = selected.value.size
changeRoleOpen.value = false
toast.ok(`${n} user${n === 1 ? '' : 's'} set to ${roleChoice.value}`)
}
function applyBulkSuspend() {
const n = selected.value.size
suspendOpen.value = false
toast.warn(`${n} user${n === 1 ? '' : 's'} suspended`, 'Sign-in blocked · data preserved')
selected.value = new Set()
}
function bulkExport() {
const n = selected.value.size
toast.info(`Exporting ${n} user${n === 1 ? '' : 's'}`, 'CSV with profile + access columns')
}
// Per-row kebab — open the user detail panel by default.
function rowAction(u: TenantUserDoc, id: string) {
if (id === 'open') openUser.value = u
else if (id === 'reset') toast.info(`Password reset link sent to ${u.email}`)
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
else if (id === 'suspend') toast.warn(`${u.name} suspended`)
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
}
const userRowItems = [
{ id: 'open', label: 'Open profile', icon: 'external' as const },
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
{ id: 'sep1', separator: true },
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Users & groups"
subtitle="Manage workspace members, their access, and group assignments."
>
<template #actions>
<UiButton variant="secondary" @click="importOpen = true">
<template #leading><UiIcon name="upload" :size="14" /></template>
Import CSV
</UiButton>
<UiButton variant="secondary">
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
<UiButton variant="primary" @click="inviteOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
Invite user
</UiButton>
</template>
</PageHeader>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'users', label: 'Users', count: users.length },
{ value: 'groups', label: 'Groups' },
{ value: 'invitations', label: 'Invitations' },
{ value: 'service', label: 'Service accounts' },
]"
/>
</div>
<!-- USERS TAB -->
<div v-if="tab === 'users'" class="content">
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input v-model="query" placeholder="Search by name or email…" />
</div>
<AdminFilterChip label="Status" :options="statusOptions" v-model="statusFilter" />
<div class="spacer" />
<Mono dim>{{ filteredUsers.length }} of {{ users.length }}</Mono>
</div>
<div v-if="selected.size > 0" class="bulk">
<Mono style="color: inherit">{{ selected.size }} selected</Mono>
<div class="spacer" />
<UiButton size="sm" variant="ghost" class="invert" @click="changeRoleOpen = true">Change role</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="bulkExport">Export selected</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="suspendOpen = true">Suspend</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="clearSelection">Clear</UiButton>
</div>
<Card :pad="0">
<table class="users-tbl">
<thead>
<tr>
<th class="check">
<input type="checkbox" :checked="selected.size === filteredUsers.length && filteredUsers.length > 0" @change="(e) => (e.target as HTMLInputElement).checked ? (selected = new Set(filteredUsers.map(u => u._id))) : clearSelection()" />
</th>
<th>Name</th><th>Role</th><th>Status</th><th>Last seen</th><th />
</tr>
</thead>
<tbody>
<tr v-for="u in filteredUsers" :key="u._id" @click="openUser = u">
<td class="check" @click.stop>
<input type="checkbox" :checked="selected.has(u._id)" @change="toggleSelect(u._id)" />
</td>
<td>
<div class="name-cell">
<Avatar :name="u.name" :size="28" />
<div>
<div class="u-name">{{ u.name }}</div>
<Mono dim>{{ u.email }}</Mono>
</div>
</div>
</td>
<td><Badge :tone="u.role === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(u.role) }}</Badge></td>
<td><Badge :tone="statusTone(userStatus(u))" dot>{{ userStatus(u) }}</Badge></td>
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
<td class="right" @click.stop>
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
</td>
</tr>
<tr v-if="filteredUsers.length === 0" class="no-hover">
<td colspan="6" class="empty-row">
<Mono dim>{{ users.length === 0 ? 'No members in this workspace yet.' : 'No users match your filters.' }}</Mono>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- GROUPS TAB no backend yet -->
<div v-else-if="tab === 'groups'" class="content">
<div class="empty-card">
<UiIcon name="users" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">Group management coming soon</div>
<div class="empty-body">Create teams, assign mail aliases and shared resources, and manage memberships in one place. We're wiring this up to your identity provider.</div>
</div>
</div>
<!-- INVITATIONS TAB — no backend yet -->
<div v-else-if="tab === 'invitations'" class="content">
<div class="empty-card">
<UiIcon name="mail" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">Invitations coming soon</div>
<div class="empty-body">Track pending invites, copy activation links, and resend or revoke them here once user provisioning is wired to the workspace.</div>
</div>
</div>
<!-- SERVICE ACCOUNTS TAB — no backend yet -->
<div v-else class="content">
<div class="empty-card">
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">Service accounts coming soon</div>
<div class="empty-body">Service accounts will let scripts and integrations authenticate to your workspace with scoped API tokens.</div>
</div>
</div>
<!-- User detail side panel — read-only profile from real data -->
<SidePanel :open="!!openUser" :eyebrow="openUser?.email || ''" :title="openUser?.name || ''" width="lg" @close="openUser = null">
<div v-if="openUser" class="user-detail">
<div class="ud-head">
<Avatar :name="openUser.name" :size="56" />
<div class="ud-meta">
<div class="ud-name">{{ openUser.name }}</div>
<Mono dim>{{ openUser.email }}</Mono>
<div class="ud-badges">
<Badge :tone="statusTone(userStatus(openUser))" dot>{{ userStatus(openUser) }}</Badge>
<Badge tone="neutral">{{ roleLabel(openUser.role) }}</Badge>
</div>
</div>
</div>
<div class="ud-body">
<dl class="def">
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
<div><dt>Role</dt><dd>{{ roleLabel(openUser.role) }}</dd></div>
<div><dt>Status</dt><dd>{{ userStatus(openUser) }}</dd></div>
<div><dt>Joined</dt><dd>{{ joinedDate(openUser.createdAt) }}</dd></div>
<div><dt>Last sign-in</dt><dd>{{ lastSeen(openUser.lastLoginAt) }}</dd></div>
</dl>
</div>
</div>
<template #footer>
<UiButton variant="danger" @click="openUser && rowAction(openUser, 'force')">
<template #leading><UiIcon name="logout" :size="13" /></template>
Force logout
</UiButton>
<UiButton variant="secondary" @click="openUser && rowAction(openUser, 'reset')">Reset password</UiButton>
</template>
</SidePanel>
<!-- Invite user modal (3 steps) -->
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
<div v-if="inviteStep === 1" class="form-stack">
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
<label class="field"><Eyebrow>Role</Eyebrow>
<div class="radio-row">
<button class="active">Member</button><button>Admin</button>
</div>
</label>
<label class="field"><Eyebrow>License tier</Eyebrow>
<div class="radio-row">
<button>Basic</button><button class="active">Business</button>
</div>
</label>
</div>
<div v-else-if="inviteStep === 2" class="form-stack">
<div>
<Eyebrow>Group memberships</Eyebrow>
<div class="check-stack">
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
<input type="checkbox" :checked="i === 0" /> {{ g }}
</label>
</div>
</div>
<div>
<Eyebrow>Apps</Eyebrow>
<div class="check-stack">
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
<input type="checkbox" checked /> {{ a }}
</label>
</div>
</div>
</div>
<div v-else>
<div class="review-box">
<dl class="def">
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
<div><dt>Role</dt><dd>Member · Business</dd></div>
<div><dt>Groups</dt><dd>Engineering</dd></div>
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
</dl>
</div>
<div class="muted">
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
</template>
</Modal>
<!-- Bulk import modal -->
<Modal :open="importOpen" eyebrow="Users · bulk import" title="Import users from CSV" size="md" @close="importOpen = false">
<div class="import">
<div class="upload-stage">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="upload-text">
<div>Drop a CSV here, or click to browse</div>
<Mono dim>columns: name, email, role, group, license</Mono>
</div>
</div>
<div class="info-box">
<Mono dim>// example</Mono>
<pre class="csv-sample">name,email,role,group,license
Anne Hansen,anne@baslund.dk,owner,Leadership,business
Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="importOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="importOpen = false; toast.ok('22 users imported · 2 skipped')">
<template #leading><UiIcon name="check" :size="13" /></template>
Import users
</UiButton>
</template>
</Modal>
<!-- Bulk · change role -->
<Modal :open="changeRoleOpen" :eyebrow="`${selected.size} selected`" title="Change role" size="md" @close="changeRoleOpen = false">
<div class="form-stack">
<Eyebrow>New role</Eyebrow>
<label v-for="r in ['member', 'admin', 'owner'] as const" :key="r" class="role-row" :class="{ active: roleChoice === r }">
<input type="radio" :value="r" v-model="roleChoice" />
<div>
<div class="role-name">{{ r.charAt(0).toUpperCase() + r.slice(1) }}</div>
<Mono dim>
{{ r === 'member' ? 'Standard access to apps' :
r === 'admin' ? 'Manage users, billing, and settings' :
'Full control — including billing and ownership' }}
</Mono>
</div>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="changeRoleOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="applyBulkRole">Update role</UiButton>
</template>
</Modal>
<!-- Bulk · suspend -->
<ConfirmDialog
:open="suspendOpen"
:eyebrow="`${selected.size} selected`"
:title="`Suspend ${selected.size} user${selected.size === 1 ? '' : 's'}?`"
confirm-label="Suspend"
tone="danger"
@close="suspendOpen = false"
@confirm="applyBulkSuspend"
>
Sign-in will be blocked across mail, files, chat, and meetings. Data is preserved; you can re-enable any time.
</ConfirmDialog>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 16px 40px 64px 40px; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 320px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.spacer { flex: 1; }
.bulk {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 12px;
background: var(--text);
color: var(--bg);
border-radius: 6px;
}
.bulk .invert :deep(button) { color: var(--bg) !important; }
.bulk :deep([data-variant='ghost']) { color: var(--bg); }
.bulk :deep([data-variant='ghost']:hover) { background: rgba(255, 255, 255, 0.06); }
.users-tbl { width: 100%; border-collapse: collapse; }
.users-tbl th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
.users-tbl td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.users-tbl tr { cursor: pointer; }
.users-tbl tr:hover { background: var(--surface); }
.users-tbl tr:last-child td { border-bottom: none; }
.users-tbl .right { text-align: right; }
.users-tbl .check { width: 36px; }
.users-tbl tr.no-hover { cursor: default; }
.users-tbl tr.no-hover:hover { background: transparent; }
.empty-row { padding: 40px 16px; text-align: center; }
.name-cell { display: flex; align-items: center; gap: 12px; }
.u-name { font-weight: 500; font-size: 13px; }
.empty-card {
padding: 60px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
/* User detail */
.user-detail { padding-bottom: 24px; margin: -22px -24px; }
.ud-head { padding: 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; }
.ud-meta { flex: 1; }
.ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); }
.ud-badges { display: flex; gap: 6px; margin-top: 8px; }
.ud-body { padding: 24px; }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
/* Invite modal */
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
.input:focus { border-color: var(--text); }
.radio-row { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
.radio-row button { padding: 6px 14px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
.radio-row button.active { background: var(--text); color: var(--bg); }
.check-stack { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: 13px; }
.check-stack label { display: flex; align-items: center; gap: 8px; }
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
.import { display: flex; flex-direction: column; gap: 14px; }
.upload-stage {
padding: 32px 24px;
background: var(--bg);
border: 2px dashed var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
}
.upload-text { text-align: center; font-size: 13px; }
.info-box {
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
}
.csv-sample {
margin: 8px 0 0 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
white-space: pre-wrap;
}
/* Bulk · role picker */
.role-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.role-row.active { border-color: var(--text); background: var(--bg); }
.role-name { font-size: 13px; font-weight: 500; }
</style>