feat(portal): real Security & audit page (+ bundled Storage / per-tenant-roles WIP)

Security & audit (admin)
- Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with
  q/action/outcome/actorEmail/since/before; UI gains search, outcome + time
  filters, action chips, cursor pagination, and client-side CSV export.
- Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute,
  allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy
  (membership-gated, audited). Editable, labelled by enforcement status.
- MFA: live enrollment overview via GET /tenants/:slug/mfa-status
  (Authentik countAuthenticators per member).
- SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD,
  scoped to the tenant group. New AuthentikClient methods (provider/app/binding
  + flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback
  on partial failure; client secret never stored), GET/POST/DELETE
  /tenants/:slug/sso-apps. Validated end-to-end against live Authentik.
- Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast
  radius) — to be done as its own reviewed change.

Bundled in-progress work that shares the same files (kept together so the tree
stays green):
- Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed),
  storage.get proxy, storage.vue.
- Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
This commit is contained in:
Ronni Baslund
2026-05-31 17:20:36 +02:00
parent 3288fde693
commit 559348f6bc
27 changed files with 1744 additions and 148 deletions
+102 -46
View File
@@ -1,22 +1,60 @@
<script setup lang="ts">
// Strict port of project/platform-app.jsx `StorageScreen` (lines 970-1020).
// Two-card 1.4fr/1fr layout: aggregate + top users on the left, type breakdown
// on the right. No tabs in the source — just two cards.
// Aggregate file storage for the workspace, read-only. Real data: the summary
// comes from /api/tenants/:slug/storage, which platform-api computes live from
// OCIS libregraph (per-drive quota for the tenant's members).
//
// Layout note: the original design had a "By type" (Documents/Images/Video…)
// card. libregraph exposes per-drive quota but NOT a file-type breakdown, so
// there's no honest source for it — it's replaced with a real aggregate
// breakdown card (allocated/used/free/trash/drives).
interface StorageTopUser {
name: string
email: string
usedBytes: number
}
import { sampleUsersFlat } from '~/data/workspace'
interface StorageSummary {
available: boolean
plan: string
usedBytes: number
quotaBytes: number
freeBytes: number
trashBytes: number
driveCount: number
topUsers: StorageTopUser[]
}
const topUsers = computed(() =>
[...sampleUsersFlat].slice(0, 5).sort((a, b) => b.storage - a.storage),
const { fetchMe } = useMe()
await fetchMe()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { data: storage } = await useFetch<StorageSummary | null>(
() => `/api/tenants/${slug.value}/storage`,
{ key: 'admin-storage', default: () => null, immediate: !!slug.value, watch: [slug] },
)
const typeBreakdown: Array<[string, number, string]> = [
['Documents', 42, 'var(--text)'],
['Images', 24, 'var(--info)'],
['Video', 18, 'var(--warn)'],
['Archives', 9, 'var(--ok)'],
['Other', 7, 'var(--text-mute)'],
]
const available = computed(() => storage.value?.available === true)
const usedPct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0))
const topUsers = computed(() => storage.value?.topUsers ?? [])
const hasUsers = computed(() => topUsers.value.length > 0)
// Scale each user's bar relative to the heaviest user, so the top user fills it.
const maxUserBytes = computed(() => Math.max(1, ...topUsers.value.map((u) => u.usedBytes)))
// Right-hand breakdown rows — all real figures from the summary.
const breakdown = computed(() => {
const s = storage.value
if (!s) return []
return [
['Allocated', formatBytes(s.quotaBytes)],
['Used', formatBytes(s.usedBytes)],
['Free', formatBytes(s.freeBytes)],
['In trash', formatBytes(s.trashBytes)],
['Active drives', String(s.driveCount)],
] as Array<[string, string]>
})
</script>
<template>
@@ -24,52 +62,69 @@ const typeBreakdown: Array<[string, number, string]> = [
<PageHeader
eyebrow="Drev"
title="Storage"
subtitle="Aggregate file storage across your workspace, by user and type."
subtitle="Aggregate file storage across your workspace, by user."
/>
<div class="content">
<Card>
<div class="card-head">
<Eyebrow>Aggregate</Eyebrow>
<div class="card-title">1.4 TB used</div>
<div class="card-sub">64% of 2.2 TB allocated · Business plan</div>
</div>
<div class="progress" style="height: 10px;">
<span style="width: 64%" />
</div>
<div class="progress-legend">
<span>1.4 TB used</span>
<span>820 GB free</span>
<div class="card-title">{{ available ? formatBytes(storage!.usedBytes) + ' used' : 'Storage' }}</div>
<div class="card-sub">
<template v-if="available">
{{ usedPct }}% of {{ formatBytes(storage!.quotaBytes) }} allocated · {{ storage!.plan }} plan
</template>
<template v-else>Storage data unavailable</template>
</div>
</div>
<div class="top-block">
<Eyebrow>Top users</Eyebrow>
<div class="top-list">
<div v-for="u in topUsers" :key="u.id" class="top-row">
<div class="user-cell">
<Avatar :name="u.name" :size="22" />
<span>{{ u.name }}</span>
<template v-if="available">
<div class="progress" style="height: 10px;">
<span :style="{ width: usedPct + '%' }" />
</div>
<div class="progress-legend">
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
</div>
<div class="top-block">
<Eyebrow>Top users</Eyebrow>
<div v-if="hasUsers" class="top-list">
<div v-for="u in topUsers" :key="u.email" class="top-row">
<div class="user-cell">
<Avatar :name="u.name" :size="22" />
<span>{{ u.name }}</span>
</div>
<div class="progress thin">
<span :style="{ width: Math.min(100, (u.usedBytes / maxUserBytes) * 100) + '%' }" />
</div>
<Mono>{{ formatBytes(u.usedBytes) }}</Mono>
</div>
<div class="progress thin"><span :style="{ width: Math.min(100, (u.storage / 50) * 100) + '%' }" /></div>
<Mono>{{ u.storage }} GB</Mono>
</div>
<div v-else class="empty">
<Mono dim>No storage in use yet.</Mono>
</div>
</div>
</template>
<div v-else class="empty">
<Mono dim>Couldn't reach the file storage service. Try again shortly.</Mono>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>By type</Eyebrow>
<div class="card-title">What's taking space</div>
<Eyebrow>Breakdown</Eyebrow>
<div class="card-title">Where it stands</div>
</div>
<div class="types">
<div v-for="[n, p, c] in typeBreakdown" :key="n">
<div class="type-head">
<span>{{ n }}</span>
<span class="pct">{{ p }}%</span>
</div>
<div class="progress thinner"><span :style="{ width: p + '%', background: c }" /></div>
<div v-if="available" class="rows">
<div v-for="[label, value] in breakdown" :key="label" class="row">
<span class="row-label">{{ label }}</span>
<Mono>{{ value }}</Mono>
</div>
</div>
<div v-else class="empty">
<Mono dim>No data available.</Mono>
</div>
</Card>
</div>
</div>
@@ -84,7 +139,6 @@ const typeBreakdown: Array<[string, number, string]> = [
.progress { background: var(--bg); border-radius: 999px; overflow: hidden; }
.progress.thin { height: 6px; }
.progress.thinner { height: 5px; }
.progress span { display: block; height: 100%; background: var(--text); }
.progress-legend {
@@ -98,11 +152,13 @@ const typeBreakdown: Array<[string, number, string]> = [
.top-block { margin-top: 32px; }
.top-list { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.top-row { display: grid; grid-template-columns: 180px 1fr 60px; gap: 12px; align-items: center; }
.top-row { display: grid; grid-template-columns: 180px 1fr 70px; gap: 12px; align-items: center; }
.user-cell { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.top-row > .mono, .top-row :deep(.mono) { font-family: var(--font-mono); font-size: 11px; text-align: right; }
.types { display: flex; flex-direction: column; gap: 12px; }
.type-head { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
.pct { font-family: var(--font-mono); color: var(--text-mute); }
.rows { display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
.row-label { color: var(--text-mute); }
.empty { margin-top: 16px; padding: 12px 0; }
</style>