Files
Ronni Baslund 559348f6bc 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.
2026-05-31 17:20:36 +02:00

165 lines
5.8 KiB
Vue

<script setup lang="ts">
// 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
}
interface StorageSummary {
available: boolean
plan: string
usedBytes: number
quotaBytes: number
freeBytes: number
trashBytes: number
driveCount: number
topUsers: StorageTopUser[]
}
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 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>
<div>
<PageHeader
eyebrow="Drev"
title="Storage"
subtitle="Aggregate file storage across your workspace, by user."
/>
<div class="content">
<Card>
<div class="card-head">
<Eyebrow>Aggregate</Eyebrow>
<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>
<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>
<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>Breakdown</Eyebrow>
<div class="card-title">Where it stands</div>
</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>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; max-width: 1200px; }
.card-head { margin-bottom: 16px; }
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
.progress { background: var(--bg); border-radius: 999px; overflow: hidden; }
.progress.thin { height: 6px; }
.progress span { display: block; height: 100%; background: var(--text); }
.progress-legend {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
}
.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 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; }
.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>