feat(portal): real OCIS storage data via refresh-token service auth

The Storage page + endpoint landed earlier but had no working OCIS
backend credential. OCIS has no service-account/client-credentials grant
and trusts a single issuer, and basic auth resolves no user in our
external-IdP setup — so authenticate OcisClient via an OIDC
refresh-token bootstrap instead:

- One-time headless login of svc-platform-api against the ocis provider
  (public client ocis-web, issuer .../o/ocis/) yields a refresh token,
  persisted in Mongo (ocis_credentials) and rotated on every use.
- OcisClient mints access tokens with the refresh_token grant; the
  service user holds the OCIS admin role (OCIS_ADMIN_USER_ID) so
  libregraph ListAllDrives works.
- scripts/bootstrap-ocis.mjs re-runs the bootstrap if the token lapses.
- Dashboard Plan card gains a storage capacity bar beside seats;
  hidden when storage is unavailable.
- compose + .env.example: OCIS service OIDC env and admin user id.
- docs/NEXT-STEPS: document the mechanism and the dead-end alternatives.
This commit is contained in:
Ronni Baslund
2026-05-31 21:29:17 +02:00
parent 559348f6bc
commit f8618b2bbc
8 changed files with 335 additions and 60 deletions
+37 -4
View File
@@ -3,10 +3,11 @@
// `AdminDashboard`, but the data is real: workspace identity, seats, spend,
// plan and recent admin events all come from /api/me + /api/tenants/:slug/*.
//
// Sections without a real backend source yet (storage usage, mail-flow health,
// "open issues" like DMARC/failed-login heuristics) were removed rather than
// faked — they return when their backends (OCIS metrics, Stalwart metrics, a
// domain-health checker) exist.
// Storage usage is real now too (OCIS libregraph via /tenants/:slug/storage) —
// shown as a second capacity bar in the Plan card. Sections still without a
// backend (mail-flow health, "open issues" like DMARC/failed-login heuristics)
// stay removed rather than faked until Stalwart metrics / a domain-health
// checker exist.
import type { IconName } from '~/components/UiIcon.vue'
import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace'
@@ -30,6 +31,20 @@ const { data: auditRaw } = await useFetch<AuditEventDoc[]>(
{ key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] },
)
// Aggregate storage usage (OCIS) — second capacity bar in the Plan card.
interface StorageSummary {
available: boolean
usedBytes: number
quotaBytes: number
freeBytes: number
}
const { data: storage } = await useFetch<StorageSummary | null>(
() => `/api/tenants/${slug.value}/storage`,
{ key: 'admin-dash-storage', default: () => null, immediate: !!slug.value, watch: [slug] },
)
const storageAvailable = computed(() => storage.value?.available === true)
const storagePct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0))
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value))
const seatPct = computed(() =>
@@ -149,6 +164,7 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
</div>
<div class="progress-block">
<div class="bar-label">Seats</div>
<div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div>
<div class="progress-legend">
<span>{{ seatsUsed }} active</span>
@@ -156,6 +172,15 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
</div>
</div>
<div v-if="storageAvailable" class="progress-block">
<div class="bar-label">Storage</div>
<div class="progress-bar"><span :style="{ width: `${storagePct}%` }" /></div>
<div class="progress-legend">
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
</div>
</div>
<div class="seats-cta">
<div class="seats-cta-text">
Approaching limit? You can add seats in single increments billed prorated.
@@ -335,6 +360,14 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
/* License progress */
.progress-block { margin-bottom: 16px; }
.bar-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-mute);
margin-bottom: 8px;
}
.progress-bar {
height: 8px;
background: var(--bg);