559348f6bc
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.
165 lines
5.8 KiB
Vue
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>
|