Files
dezky/apps/portal/pages/admin/users.vue
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00

857 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `UsersScreen` (lines 625-768)
// with FilterChip (770), UserDetailPanel (816), DefList (948), InviteUserModal
// (961), plus GroupsTabRich from platform-admin.jsx (1022), InvitationsTab and
// ServiceAccountsTab (platform-screens.jsx 1090, 1123).
//
// User detail panel tabs follow the source order: Profile · Access · Mail ·
// Files · Activity · Audit (no Danger zone in the source).
import { sampleUsersFlat, groupsFull, sampleAudit } from '~/data/workspace'
type User = (typeof sampleUsersFlat)[number]
const toast = useToast()
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
const query = ref('')
const statusFilter = ref<'all' | 'active' | 'invited' | 'suspended'>('all')
const selected = ref<Set<string>>(new Set())
const openUser = ref<User | null>(null)
const userTab = ref<'profile' | 'access' | 'mail' | 'files' | 'activity' | 'audit'>('profile')
const inviteOpen = ref(false)
const inviteStep = ref(1)
const importOpen = ref(false)
const filteredUsers = computed(() =>
sampleUsersFlat.filter((u) => {
if (statusFilter.value !== 'all' && u.status !== statusFilter.value) return false
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
return true
}),
)
const invites = computed(() => sampleUsersFlat.filter((u) => u.status === 'invited'))
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
s === 'active' ? 'ok' : s === 'invited' ? 'warn' : 'bad'
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() }
watch(openUser, (u) => { if (u) userTab.value = 'profile' })
// Filter chip
type ChipOption = { value: string; label: string }
const statusOptions: ChipOption[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'invited', label: 'Invited' },
{ value: 'suspended', label: 'Suspended' },
]
// Groups tab
const openGroup = ref<typeof groupsFull[number] | null>(null)
const createGroupOpen = ref(false)
// Bulk-action modals + confirm
const assignGroupOpen = ref(false)
const changeRoleOpen = ref(false)
const suspendOpen = ref(false)
const groupChoice = ref<Set<string>>(new Set())
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
function sendInvite() {
inviteOpen.value = false
inviteStep.value = 1
toast.ok('Invitation sent to magnus@dezky.com')
}
function applyBulkGroup() {
const n = selected.value.size
const gs = [...groupChoice.value].join(', ') || '—'
assignGroupOpen.value = false
toast.ok(`${n} user${n === 1 ? '' : 's'} added to: ${gs}`)
groupChoice.value = new Set()
}
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')
}
function toggleGroup(g: string) {
const s = new Set(groupChoice.value)
if (s.has(g)) s.delete(g)
else s.add(g)
groupChoice.value = s
}
// Per-row kebab — open the user detail panel by default.
function rowAction(u: User, 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`)
}
function groupAction(g: typeof groupsFull[number], id: string) {
if (id === 'open') openGroup.value = g
else if (id === 'rename') toast.info(`Rename ${g.name}`)
else if (id === 'duplicate') toast.info(`Duplicated ${g.name}`)
else if (id === 'delete') toast.bad(`${g.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 },
]
const groupRowItems = [
{ id: 'open', label: 'Open group', icon: 'external' as const },
{ id: 'rename', label: 'Rename', icon: 'brush' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete group',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: sampleUsersFlat.length },
{ value: 'groups', label: 'Groups', count: 6 },
{ value: 'invitations', label: 'Invitations', count: 2 },
{ value: 'service', label: 'Service accounts', count: 3 },
]"
/>
</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" />
<button class="chip"><Eyebrow>Role:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Group:</Eyebrow><span>All</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<Mono dim>{{ filteredUsers.length }} of {{ sampleUsersFlat.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="assignGroupOpen = true">Assign group</UiButton>
<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>Group</th><th>Last seen</th><th class="right">Storage</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'">{{ u.role }}</Badge></td>
<td><Badge :tone="statusTone(u.status)" dot>{{ u.status }}</Badge></td>
<td><span class="group-text">{{ u.group }}</span></td>
<td><Mono dim>{{ u.last }}</Mono></td>
<td class="right"><Mono>{{ u.storage > 0 ? `${u.storage} GB` : '—' }}</Mono></td>
<td class="right" @click.stop>
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
</td>
</tr>
</tbody>
</table>
</Card>
<div class="pager">
<Mono dim>Showing 1{{ filteredUsers.length }}</Mono>
<div class="pager-btns">
<UiButton size="sm" variant="secondary">
<template #leading><UiIcon name="chevLeft" :size="12" /></template>
Prev
</UiButton>
<UiButton size="sm" variant="secondary">
Next
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</div>
</div>
</div>
<!-- GROUPS TAB (GroupsTabRich) -->
<div v-else-if="tab === 'groups'" class="content">
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="Search groups…" />
</div>
<button class="chip"><Eyebrow>Sort:</Eyebrow><span>Name</span><UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<div class="spacer" />
<Mono dim>{{ groupsFull.length }} groups</Mono>
<UiButton variant="primary" @click="createGroupOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New group
</UiButton>
</div>
<Card :pad="0">
<table class="users-tbl">
<thead>
<tr><th>Group</th><th>Alias</th><th>Members</th><th>Owner</th><th>Created</th><th /></tr>
</thead>
<tbody>
<tr v-for="g in groupsFull" :key="g.id" @click="openGroup = g">
<td>
<div class="name-cell">
<div class="g-icon"><UiIcon name="users" :size="14" /></div>
<div>
<div class="u-name">{{ g.name }}</div>
<Mono dim>{{ g.description }}</Mono>
</div>
</div>
</td>
<td><Mono>{{ g.alias }}</Mono></td>
<td>
<div class="member-cell">
<UiIcon name="users" :size="12" stroke="var(--text-mute)" />
<Mono>{{ g.members }}</Mono>
</div>
</td>
<td>
<div class="name-cell small">
<Avatar :name="g.owner" :size="22" />
<span>{{ g.owner }}</span>
</div>
</td>
<td><Mono dim>{{ g.created }}</Mono></td>
<td class="right" @click.stop>
<AdminKebabMenu :items="groupRowItems" @select="(id) => groupAction(g, id)" />
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- INVITATIONS TAB -->
<div v-else-if="tab === 'invitations'" class="content">
<Card :pad="0">
<table class="users-tbl">
<thead><tr><th>Recipient</th><th>Sent</th><th>Expires</th><th /></tr></thead>
<tbody>
<tr v-for="u in invites" :key="u.id">
<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><Mono dim>14 May 2026</Mono></td>
<td><Mono dim>21 May 2026</Mono></td>
<td class="right">
<UiButton size="sm" variant="secondary">
<template #leading><UiIcon name="copy" :size="13" /></template>
Copy link
</UiButton>
<UiButton size="sm" variant="secondary">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Resend
</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- SERVICE ACCOUNTS TAB -->
<div v-else class="content">
<div class="empty-card">
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">3 service accounts</div>
<div class="empty-body">Service accounts let scripts and integrations authenticate to your workspace. Manage their API tokens here.</div>
<UiButton variant="primary">
<template #leading><UiIcon name="plus" :size="14" /></template>
New service account
</UiButton>
</div>
</div>
<!-- User detail side panel -->
<SidePanel :open="!!openUser" :eyebrow="openUser?.id || ''" :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(openUser.status)" dot>{{ openUser.status }}</Badge>
<Badge tone="neutral">{{ openUser.role }}</Badge>
<Badge tone="neutral">{{ openUser.group }}</Badge>
</div>
</div>
</div>
<Tabs
v-model="userTab"
:items="[
{ value: 'profile', label: 'Profile' },
{ value: 'access', label: 'Access' },
{ value: 'mail', label: 'Mail' },
{ value: 'files', label: 'Files' },
{ value: 'activity', label: 'Activity' },
{ value: 'audit', label: 'Audit' },
]"
/>
<div class="ud-body">
<template v-if="userTab === 'profile'">
<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>{{ openUser.role }}</dd></div>
<div><dt>Group</dt><dd>{{ openUser.group }}</dd></div>
<div><dt>License</dt><dd>Business · seat 11</dd></div>
<div><dt>Joined</dt><dd>14 January 2026</dd></div>
<div><dt>Locale</dt><dd>da-DK · Europe/Copenhagen</dd></div>
<div><dt>Phone</dt><dd>+45 21 47 88 02</dd></div>
</dl>
</template>
<template v-else-if="userTab === 'access'">
<dl class="def">
<div><dt>MFA</dt><dd><Badge tone="ok" dot>enabled · TOTP</Badge></dd></div>
<div><dt>SSO sessions</dt><dd>2 active</dd></div>
<div><dt>Last sign-in</dt><dd>{{ openUser.last }} · 92.43.118.4 · Copenhagen</dd></div>
<div><dt>Recovery codes</dt><dd>8 of 10 unused</dd></div>
</dl>
<div class="sub-head">Active devices</div>
<div v-for="d in [
{ d: 'MacBook Pro · macOS 14', w: 'Chrome 132', loc: 'Copenhagen', active: '2 min ago' },
{ d: 'iPhone 15 Pro · iOS 18', w: 'dezky Mail', loc: 'Copenhagen', active: '1 h ago' },
]" :key="d.d" class="dev-row">
<UiIcon name="device" :size="18" stroke="var(--text-mute)" />
<div class="dev-meta">
<div class="dev-d">{{ d.d }}</div>
<Mono dim>{{ d.w }} · {{ d.loc }} · {{ d.active }}</Mono>
</div>
<UiButton size="sm" variant="ghost">Revoke</UiButton>
</div>
</template>
<template v-else-if="userTab === 'mail'">
<dl class="def">
<div><dt>Primary address</dt><dd>{{ openUser.email }}</dd></div>
<div><dt>Quota</dt><dd>12.4 GB of 50 GB · 25%</dd></div>
<div><dt>Forwarding</dt><dd>Off</dd></div>
<div><dt>Vacation reply</dt><dd>Off</dd></div>
</dl>
<div class="sub-head">Aliases</div>
<div v-for="a in ['anne.b@dezky.com', 'founder@dezky.com']" :key="a" class="alias-row">
<Mono>{{ a }}</Mono>
<UiButton size="sm" variant="ghost">
<template #leading><UiIcon name="trash" :size="12" /></template>
Remove
</UiButton>
</div>
</template>
<template v-else-if="userTab === 'files'">
<dl class="def">
<div><dt>Quota</dt><dd>12.4 GB of 100 GB · 12%</dd></div>
<div><dt>Shared by user</dt><dd>14 items</dd></div>
<div><dt>Shared with user</dt><dd>23 items</dd></div>
</dl>
</template>
<template v-else-if="userTab === 'activity'">
<div class="activity-list">
<div v-for="a in sampleAudit.slice(0, 6)" :key="a.id" class="activity-row">
<Mono dim>{{ a.when }}</Mono>
<span class="activity-action">{{ a.action }}</span>
<Mono dim>{{ a.ip }}</Mono>
</div>
</div>
</template>
<template v-else>
<div class="empty-tab">
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">No changes recorded yet</div>
<div class="empty-body">Edits to this user's settings will appear here.</div>
</div>
</template>
</div>
</div>
<template #footer>
<UiButton variant="danger">
<template #leading><UiIcon name="logout" :size="13" /></template>
Force logout
</UiButton>
<UiButton variant="secondary">Reset password</UiButton>
<UiButton variant="primary">Save changes</UiButton>
</template>
</SidePanel>
<!-- Group detail side panel -->
<SidePanel :open="!!openGroup" eyebrow="Group" :title="openGroup?.name || ''" width="lg" @close="openGroup = null">
<div v-if="openGroup" class="user-detail">
<div class="ud-head">
<div class="g-icon big"><UiIcon name="users" :size="22" /></div>
<div class="ud-meta">
<div class="ud-name">{{ openGroup.name }}</div>
<Mono dim>{{ openGroup.alias }}</Mono>
</div>
</div>
<div class="ud-body">
<dl class="def">
<div><dt>Members</dt><dd>{{ openGroup.members }}</dd></div>
<div><dt>Owner</dt><dd>{{ openGroup.owner }}</dd></div>
<div><dt>Created</dt><dd>{{ openGroup.created }}</dd></div>
<div><dt>Description</dt><dd>{{ openGroup.description }}</dd></div>
</dl>
</div>
</div>
<template #footer>
<UiButton variant="danger">
<template #leading><UiIcon name="trash" :size="13" /></template>
Delete group
</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" @click="openGroup = null">Save changes</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 · assign group -->
<Modal :open="assignGroupOpen" :eyebrow="`${selected.size} selected`" title="Add to groups" size="md" @close="assignGroupOpen = false">
<div class="form-stack">
<Eyebrow>Pick one or more groups</Eyebrow>
<div class="check-stack">
<label v-for="g in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales', 'Leadership']" :key="g">
<input type="checkbox" :checked="groupChoice.has(g)" @change="toggleGroup(g)" />
{{ g }}
</label>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="assignGroupOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="groupChoice.size === 0" @click="applyBulkGroup">
<template #leading><UiIcon name="check" :size="13" /></template>
Add to groups
</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[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>
<!-- Create group modal -->
<Modal :open="createGroupOpen" eyebrow="Groups" title="New group" size="md" @close="createGroupOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Group name</Eyebrow><input class="input" placeholder="Engineering" /></label>
<label class="field"><Eyebrow>Mail alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Product engineering team" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="createGroupOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="createGroupOpen = false; toast.ok('Group created')">Create group</UiButton>
</template>
</Modal>
</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); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.chip span { font-weight: 500; }
.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; }
.name-cell { display: flex; align-items: center; gap: 12px; }
.name-cell.small { gap: 8px; }
.u-name { font-weight: 500; font-size: 13px; }
.group-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); }
.member-cell { display: flex; align-items: center; gap: 6px; }
.g-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
}
.g-icon.big { width: 44px; height: 44px; border-radius: 10px; color: var(--text-dim); border: 1px solid var(--border); }
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; font-size: 12px; color: var(--text-mute); }
.pager-btns { display: flex; gap: 4px; }
.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); }
.sub-head { font-size: 13px; font-weight: 600; margin: 24px 0 8px; }
.dev-row {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.dev-meta { flex: 1; }
.dev-d { font-size: 13px; font-weight: 500; }
.alias-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.activity-list { font-family: var(--font-mono); font-size: 12px; }
.activity-row {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.activity-action { color: var(--text); }
.empty-tab { text-align: center; padding: 60px 20px; }
/* 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>