3288fde693
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).
549 lines
22 KiB
Vue
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>
|