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
+37 -6
View File
@@ -9,6 +9,9 @@ interface MeProfile {
email: string
name: string
role: string
// Per-tenant role overrides keyed by tenantId; absent keys fall back to
// `role`. Serialized from platform-api's User.tenantRoles Map.
tenantRoles?: Record<string, 'owner' | 'admin' | 'member'>
active: boolean
platformAdmin: boolean
tenantIds: string[]
@@ -65,11 +68,39 @@ export function useMe() {
const partner = computed(() => profile.value?.partner ?? null)
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
// Customer admin of their own workspace — gates access to the /admin surface.
// `role` is 'owner' | 'admin' | 'member' from platform-api (User.role).
const isTenantAdmin = computed(
() => profile.value?.role === 'owner' || profile.value?.role === 'admin',
)
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, isTenantAdmin, fetchMe }
const isAdminRole = (r: string | undefined) => r === 'owner' || r === 'admin'
// Effective role for a specific tenant — mirrors platform-api roleForTenant():
// a per-tenant entry wins, else the legacy global `role`, else 'member'.
function roleForTenant(tenantId: string): 'owner' | 'admin' | 'member' {
const p = profile.value
return p?.tenantRoles?.[tenantId] ?? (p?.role as 'owner' | 'admin' | 'member') ?? 'member'
}
function isTenantAdminOf(tenantId: string): boolean {
return isAdminRole(roleForTenant(tenantId))
}
// Gates the /admin surface: true if the user administers AT LEAST ONE of
// their tenants. Per-tenant enforcement of *which* workspace they may admin
// happens once a tenant is in context (backend membership + roleForTenant).
// For existing single-role data this is identical to the old global check.
const isTenantAdmin = computed(() => {
const p = profile.value
if (!p) return false
if (p.tenantIds.length) return p.tenantIds.some((t) => isTenantAdminOf(t))
return isAdminRole(p.role)
})
return {
state,
profile,
partner,
isPartnerStaff,
isPlatformAdmin,
isTenantAdmin,
roleForTenant,
isTenantAdminOf,
fetchMe,
}
}
+496 -80
View File
@@ -1,42 +1,135 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `SecurityScreen` (lines 2187-2310)
// and RadioBig (line 2311). Two tabs: Security · Audit log. Same cards, same
// copy, same SSO apps, same audit-log column structure with sample rows.
// Security & audit. Audit log: real (filterable, paginated, CSV export).
// Security tab: MFA-enrollment overview is a live read; the policy (MFA mode,
// session timeouts, allowed countries, IP allow-list) is saved as real intent —
// enforcement via Authentik is wired in a later stage, so each control is
// labelled with its enforcement status. SSO apps are still coming soon.
import { sampleAudit } from '~/data/workspace'
import type { AuditEventDoc, MfaStatus, SsoApp, SsoAppCreated } from '~/types/workspace'
const tab = ref<'security' | 'audit'>('security')
const mfa = ref<'all' | 'admins' | 'optional'>('admins')
const toast = useToast()
const addCountryOpen = ref(false)
const newAllowCountry = ref('')
function ssoAction(name: string, id: string) {
if (id === 'configure') toast.info(`Configure ${name}`)
else if (id === 'test') toast.info(`Sending test sign-in to ${name}`)
else if (id === 'rotate') toast.info(`Rotating certificate for ${name}`)
else if (id === 'disconnect') toast.warn(`${name} disconnected`)
}
const ssoItems = [
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
{ id: 'test', label: 'Send test sign-in', icon: 'key' as const },
{ id: 'rotate', label: 'Rotate certificate', icon: 'refresh' as const },
{ id: 'sep1', separator: true },
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
// ── Audit log (real) ─────────────────────────────────────────────────────
const { fetchMe } = useMe()
await fetchMe()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const search = ref('')
const actionFilter = ref('') // action prefix, e.g. 'billing'
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
const sinceFilter = ref<'' | '1d' | '7d' | '30d'>('')
const QUICK_ACTIONS = [
{ label: 'All', value: '' },
{ label: 'Users', value: 'user' },
{ label: 'Billing', value: 'billing' },
{ label: 'Branding', value: 'tenant.branding' },
{ label: 'Auth', value: 'authentik' },
{ label: 'Tenant', value: 'tenant' },
]
function removeCountry(c: string) {
toast.info(`${c} removed from allow-list`)
function sinceIso(v: string): string | undefined {
if (!v) return undefined
const days = v === '1d' ? 1 : v === '7d' ? 7 : 30
return new Date(Date.now() - days * 86_400_000).toISOString()
}
const ssoApps = [
{ n: 'Notion', p: 'SAML', s: 'ok' as const },
{ n: 'Figma', p: 'SAML', s: 'ok' as const },
{ n: 'Linear', p: 'OIDC', s: 'ok' as const },
{ n: 'GitHub', p: 'OIDC', s: 'warn' as const },
]
const params = computed<Record<string, string>>(() => {
const p: Record<string, string> = { limit: '200' }
if (search.value.trim()) p.q = search.value.trim()
if (actionFilter.value) p.action = actionFilter.value
if (outcomeFilter.value) p.outcome = outcomeFilter.value
const since = sinceIso(sinceFilter.value)
if (since) p.since = since
return p
})
const { data: auditPage } = await useFetch<AuditEventDoc[]>(
() => `/api/tenants/${slug.value}/audit`,
{ key: 'admin-audit', default: () => [], query: params, immediate: !!slug.value, watch: [slug] },
)
const olderPages = ref<AuditEventDoc[]>([])
const reachedEnd = ref(false)
const loadingMore = ref(false)
const auditRows = computed(() => [...(auditPage.value ?? []), ...olderPages.value])
// New filter set → drop accumulated older pages.
watch(params, () => {
olderPages.value = []
reachedEnd.value = false
})
async function loadMore() {
const last = auditRows.value[auditRows.value.length - 1]
if (!last || loadingMore.value) return
loadingMore.value = true
try {
const next = await $fetch<AuditEventDoc[]>(`/api/tenants/${slug.value}/audit`, {
query: { ...params.value, before: last.at },
})
if (!next.length) reachedEnd.value = true
else olderPages.value.push(...next)
} finally {
loadingMore.value = false
}
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
}
function actorName(e: AuditEventDoc): string {
return e.actorType === 'system' ? 'system' : e.actorEmail ?? '—'
}
function targetOf(e: AuditEventDoc): string {
return e.resourceName ?? e.resourceId ?? ''
}
function auditTone(e: AuditEventDoc): 'info' | 'warn' | 'bad' {
if (e.outcome === 'failure') return 'bad'
if (/suspend|delete|terminat|revok|disable/.test(e.action)) return 'warn'
return 'info'
}
function exportCsv() {
const rows = auditRows.value
if (!rows.length) {
toast.info('Nothing to export', 'No events match the current filters')
return
}
const esc = (s: string) => `"${String(s ?? '').replace(/"/g, '""')}"`
const header = ['Time', 'Actor', 'Action', 'Target', 'Outcome', 'IP']
const lines = rows.map((e) =>
[fmtTime(e.at), actorName(e), e.action, targetOf(e), e.outcome, e.actorIp ?? ''].map(esc).join(','),
)
const csv = [header.map(esc).join(','), ...lines].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-${slug.value}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
toast.ok(`Exported ${rows.length} events`, 'CSV · current view')
}
// ── Security policy (real, stored intent) ────────────────────────────────
const { request } = useApiFetch()
// Live MFA-enrollment overview.
const { data: mfaStatus } = await useFetch<MfaStatus>(
() => `/api/tenants/${slug.value}/mfa-status`,
{ key: 'admin-mfa', default: () => ({ total: 0, enrolled: 0, members: [] }), immediate: !!slug.value, watch: [slug] },
)
const notEnrolled = computed(() => (mfaStatus.value?.members ?? []).filter((m) => !m.enrolled))
const mfaPct = computed(() => {
const t = mfaStatus.value?.total ?? 0
return t ? Math.round(((mfaStatus.value?.enrolled ?? 0) / t) * 100) : 0
})
const mfaOptions = [
{ v: 'all' as const, label: 'Required for everyone', d: 'All members must enroll TOTP or WebAuthn at next sign-in.' },
@@ -44,7 +137,120 @@ const mfaOptions = [
{ v: 'optional' as const, label: 'Optional', d: 'No enforcement. Not recommended for compliance work.' },
]
const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
// Editable policy, seeded from the tenant doc; persisted via PATCH.
const policy = reactive({
mfaMode: 'optional' as 'all' | 'admins' | 'optional',
sessionIdleMinutes: 30,
sessionAbsoluteHours: 24,
allowedCountries: [] as string[],
ipAllowlist: [] as string[],
})
function seedPolicy() {
const p = tenant.value?.securityPolicy
policy.mfaMode = p?.mfaMode ?? 'optional'
policy.sessionIdleMinutes = p?.sessionIdleMinutes ?? 30
policy.sessionAbsoluteHours = p?.sessionAbsoluteHours ?? 24
policy.allowedCountries = [...(p?.allowedCountries ?? [])]
policy.ipAllowlist = [...(p?.ipAllowlist ?? [])]
}
seedPolicy()
watch(tenant, seedPolicy)
const savingPolicy = ref(false)
async function savePolicy() {
if (!slug.value) return
savingPolicy.value = true
try {
await request(`/api/tenants/${slug.value}/security-policy`, { method: 'PATCH', body: { ...policy } })
await fetchMe(true)
toast.ok('Security policy saved')
} catch (e: unknown) {
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
toast.bad('Could not save policy', Array.isArray(msg) ? msg.join(', ') : msg)
} finally {
savingPolicy.value = false
}
}
function removeCountry(c: string) {
policy.allowedCountries = policy.allowedCountries.filter((x) => x !== c)
}
function addCountry() {
const c = newAllowCountry.value.trim().toUpperCase()
if (c && !policy.allowedCountries.includes(c)) policy.allowedCountries.push(c)
addCountryOpen.value = false
newAllowCountry.value = ''
}
const newIp = ref('')
function addIp() {
const ip = newIp.value.trim()
if (ip && !policy.ipAllowlist.includes(ip)) policy.ipAllowlist.push(ip)
newIp.value = ''
}
function removeIp(ip: string) {
policy.ipAllowlist = policy.ipAllowlist.filter((x) => x !== ip)
}
// ── SSO apps (Dezky as IdP) — real Authentik OIDC providers/applications ──
const { data: ssoApps, refresh: refreshSso } = await useFetch<SsoApp[]>(
() => `/api/tenants/${slug.value}/sso-apps`,
{ key: 'admin-sso', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const ssoOpen = ref(false)
const ssoName = ref('')
const ssoRedirects = ref('')
const creatingSso = ref(false)
const ssoCreated = ref<SsoAppCreated | null>(null)
function openSso() {
ssoName.value = ''
ssoRedirects.value = ''
ssoCreated.value = null
ssoOpen.value = true
}
async function createSso() {
const redirectUris = ssoRedirects.value.split('\n').map((s) => s.trim()).filter(Boolean)
if (!ssoName.value.trim() || redirectUris.length === 0) return
creatingSso.value = true
try {
ssoCreated.value = await request<SsoAppCreated>(`/api/tenants/${slug.value}/sso-apps`, {
method: 'POST',
body: { name: ssoName.value.trim(), redirectUris },
})
await refreshSso()
toast.ok('SSO app created')
} catch (e: unknown) {
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
toast.bad('Could not create SSO app', Array.isArray(msg) ? msg.join(', ') : msg)
} finally {
creatingSso.value = false
}
}
const ssoDeleteId = ref<string | null>(null)
const ssoDeleteName = computed(() => (ssoApps.value ?? []).find((a) => a.id === ssoDeleteId.value)?.name ?? '')
async function confirmDeleteSso() {
const id = ssoDeleteId.value
if (!id) return
try {
await request(`/api/tenants/${slug.value}/sso-apps/${id}`, { method: 'DELETE' })
await refreshSso()
toast.ok('SSO app removed')
} catch (e: unknown) {
const msg = (e as { data?: { message?: string } })?.data?.message
toast.bad('Could not remove SSO app', msg)
} finally {
ssoDeleteId.value = null
}
}
function copyText(text?: string) {
if (!text) return
navigator.clipboard?.writeText(text)
toast.ok('Copied to clipboard')
}
</script>
<template>
@@ -52,8 +258,15 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
<PageHeader
eyebrow="Compliance"
title="Security & audit"
subtitle="Policies, identity controls, and a tamper-evident log of every administrative action."
/>
subtitle="Identity controls, network policy, and a full log of every administrative action."
>
<template #actions>
<UiButton v-if="tab === 'security'" variant="primary" :disabled="savingPolicy || !slug" @click="savePolicy">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ savingPolicy ? 'Saving…' : 'Save policy' }}
</UiButton>
</template>
</PageHeader>
<div class="tab-wrap">
<Tabs
@@ -71,78 +284,127 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
<Eyebrow>Identity</Eyebrow>
<div class="card-title">Multi-factor authentication</div>
</div>
<!-- Live enrollment overview -->
<div class="mfa-overview">
<div class="mfa-stat">
<div class="mfa-num">{{ mfaStatus.enrolled }} / {{ mfaStatus.total }}</div>
<Mono dim>members enrolled</Mono>
</div>
<div class="mfa-bar-wrap">
<div class="mfa-bar"><span :style="{ width: `${mfaPct}%` }" /></div>
<Mono dim>{{ mfaPct }}% of active members have TOTP or WebAuthn</Mono>
</div>
</div>
<div v-if="notEnrolled.length" class="mfa-missing">
<Mono dim>Not enrolled:</Mono>
<span class="missing-names">{{ notEnrolled.map((m) => m.name || m.email).join(', ') }}</span>
</div>
<div class="sub-head">
<Eyebrow>Enforcement policy</Eyebrow>
<Badge tone="warn" dot>rolling out</Badge>
</div>
<div class="radio-big">
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: mfa === o.v }">
<span class="radio-dot"><span v-if="mfa === o.v" /></span>
<input type="radio" :value="o.v" v-model="mfa" />
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: policy.mfaMode === o.v }">
<span class="radio-dot"><span v-if="policy.mfaMode === o.v" /></span>
<input type="radio" :value="o.v" v-model="policy.mfaMode" />
<div>
<div class="radio-label">{{ o.label }}</div>
<div class="radio-d">{{ o.d }}</div>
</div>
</label>
</div>
<div class="enforce-note">
<UiIcon name="shield" :size="13" stroke="var(--text-mute)" />
<span>Saved now; automatic enforcement through your identity provider is being rolled out.</span>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Sessions</Eyebrow>
<div class="card-title">Session policy</div>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Sessions</Eyebrow>
<div class="card-title">Session policy</div>
</div>
<Badge tone="neutral" dot>not enforced yet</Badge>
</div>
<div class="grid-2">
<label class="field"><Eyebrow>Idle timeout</Eyebrow>
<div class="input-faux">
<input value="30 minutes" />
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
</div>
<label class="field"><Eyebrow>Idle timeout (minutes)</Eyebrow>
<input class="input" type="number" min="0" max="1440" v-model.number="policy.sessionIdleMinutes" />
</label>
<label class="field"><Eyebrow>Absolute timeout</Eyebrow>
<div class="input-faux">
<input value="24 hours" />
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
</div>
<label class="field"><Eyebrow>Absolute timeout (hours)</Eyebrow>
<input class="input" type="number" min="0" max="8760" v-model.number="policy.sessionAbsoluteHours" />
</label>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Network</Eyebrow>
<div class="card-title">Geo-fencing & allow-lists</div>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Network</Eyebrow>
<div class="card-title">Geo-fencing & allow-lists</div>
</div>
<Badge tone="neutral" dot>not enforced yet</Badge>
</div>
<div class="field">
<Eyebrow>Allowed countries</Eyebrow>
<div class="chip-row">
<Badge v-for="c in countries" :key="c" tone="neutral">
<Badge v-for="c in policy.allowedCountries" :key="c" tone="neutral">
{{ c }}
<button class="badge-x" @click="removeCountry(c)" aria-label="Remove country">
<UiIcon name="x" :size="10" />
</button>
</Badge>
<Mono v-if="policy.allowedCountries.length === 0" dim>Any country allowed</Mono>
<UiButton size="sm" variant="ghost" @click="addCountryOpen = true">
<template #leading><UiIcon name="plus" :size="12" /></template>
Add country
</UiButton>
</div>
</div>
<div class="field" style="margin-top: 16px">
<Eyebrow>IP allow-list (CIDR)</Eyebrow>
<div class="chip-row">
<Badge v-for="ip in policy.ipAllowlist" :key="ip" tone="neutral">
<Mono>{{ ip }}</Mono>
<button class="badge-x" @click="removeIp(ip)" aria-label="Remove IP range">
<UiIcon name="x" :size="10" />
</button>
</Badge>
<Mono v-if="policy.ipAllowlist.length === 0" dim>No IP restriction</Mono>
</div>
<div class="ip-add">
<input class="input" v-model="newIp" placeholder="e.g. 203.0.113.0/24" @keyup.enter="addIp" />
<UiButton size="sm" variant="secondary" :disabled="!newIp.trim()" @click="addIp">Add</UiButton>
</div>
</div>
</Card>
<Card>
<div class="card-head">
<Eyebrow>SSO</Eyebrow>
<div class="card-title">dezky as identity provider</div>
</div>
<div class="sso-intro">
Connect external applications via OIDC or SAML. dezky's Authentik instance is the source of truth for identity.
<div class="card-head card-head-inline">
<div>
<Eyebrow>SSO</Eyebrow>
<div class="card-title">dezky as identity provider</div>
<div class="card-sub">Let external apps sign your team in with dezky via OpenID Connect.</div>
</div>
<UiButton size="sm" variant="primary" @click="openSso">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add app
</UiButton>
</div>
<div class="sso-list">
<div v-for="a in ssoApps" :key="a.n" class="sso-row">
<div class="sso-icon">{{ a.n[0] }}</div>
<div v-for="a in ssoApps" :key="a.id" class="sso-row">
<div class="sso-icon">{{ a.name[0]?.toUpperCase() }}</div>
<div class="sso-meta">
<div class="sso-name">{{ a.n }}</div>
<Mono dim>{{ a.p }} · provisioned</Mono>
<div class="sso-name">{{ a.name }}</div>
<Mono dim>{{ a.protocol.toUpperCase() }} · {{ a.clientId }}</Mono>
</div>
<Badge :tone="a.s" dot>{{ a.s === 'ok' ? 'connected' : 'cert expiring' }}</Badge>
<AdminKebabMenu :items="ssoItems" @select="(id) => ssoAction(a.n, id)" />
<Badge tone="ok" dot>connected</Badge>
<button class="icon-del" title="Remove" @click="ssoDeleteId = a.id"><UiIcon name="trash" :size="14" /></button>
</div>
<div v-if="(ssoApps?.length ?? 0) === 0" class="sso-empty">
<Mono dim>No SSO apps yet. Add one to let an external app authenticate with dezky.</Mono>
</div>
</div>
</Card>
@@ -152,17 +414,36 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input placeholder="action.type, actor, target…" />
<input v-model="search" placeholder="action, actor, target…" />
</div>
<button class="chip"><Eyebrow>Actor:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Action:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<button class="chip"><Eyebrow>Last:</Eyebrow> <span>7 days</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
<select v-model="outcomeFilter" class="select">
<option value="">Any outcome</option>
<option value="success">Success</option>
<option value="failure">Failure</option>
</select>
<select v-model="sinceFilter" class="select">
<option value="">All time</option>
<option value="1d">Last 24h</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
<div class="spacer" />
<UiButton variant="secondary" @click="toast.info('Exporting audit log', 'CSV · last 7 days · ~4,218 events')">
<UiButton variant="secondary" @click="exportCsv">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
</div>
<div class="action-chips">
<button
v-for="qa in QUICK_ACTIONS"
:key="qa.value"
class="achip"
:class="{ on: actionFilter === qa.value }"
@click="actionFilter = qa.value"
>{{ qa.label }}</button>
</div>
<Card :pad="0">
<table class="audit-table">
<thead>
@@ -176,25 +457,33 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
</tr>
</thead>
<tbody>
<tr v-for="a in sampleAudit" :key="a.id">
<td><Mono>{{ a.when }}</Mono></td>
<tr v-for="a in auditRows" :key="a._id">
<td><Mono>{{ fmtTime(a.at) }}</Mono></td>
<td>
<div class="actor-cell">
<Avatar v-if="a.actor !== 'system'" :name="a.actor" :size="22" />
<Avatar v-if="a.actorType !== 'system'" :name="actorName(a)" :size="22" />
<div v-else class="sys">sys</div>
<span>{{ a.actor }}</span>
<span>{{ actorName(a) }}</span>
</div>
</td>
<td><Mono>{{ a.action }}</Mono></td>
<td class="target">{{ a.target }}</td>
<td><Mono dim>{{ a.ip }}</Mono></td>
<td class="right"><Badge :tone="a.tone" dot>{{ a.tone }}</Badge></td>
<td class="target">{{ targetOf(a) }}</td>
<td><Mono dim>{{ a.actorIp || '—' }}</Mono></td>
<td class="right"><Badge :tone="auditTone(a)" dot>{{ a.outcome }}</Badge></td>
</tr>
<tr v-if="auditRows.length === 0" class="no-hover">
<td colspan="6" class="empty-row"><Mono dim>No audit events match your filters.</Mono></td>
</tr>
</tbody>
</table>
</Card>
<div class="retention">
<Mono dim>// retention · 365 days · tamper-evident · last verified 14:32:01 today</Mono>
<div class="audit-foot">
<Mono dim>{{ auditRows.length }} event{{ auditRows.length === 1 ? '' : 's' }} shown</Mono>
<UiButton v-if="!reachedEnd && auditRows.length > 0" size="sm" variant="secondary" :disabled="loadingMore" @click="loadMore">
{{ loadingMore ? 'Loading' : 'Load older' }}
</UiButton>
<Mono v-else-if="auditRows.length > 0" dim>· end of log</Mono>
</div>
</div>
@@ -207,15 +496,61 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
</div>
<template #footer>
<UiButton variant="ghost" @click="addCountryOpen = false">Cancel</UiButton>
<UiButton
variant="primary"
:disabled="!newAllowCountry"
@click="addCountryOpen = false; toast.ok(`Country ${newAllowCountry} added`); newAllowCountry = ''"
>
<UiButton variant="primary" :disabled="!newAllowCountry" @click="addCountry">
Add
</UiButton>
</template>
</Modal>
<!-- Add SSO app modal -->
<Modal :open="ssoOpen" eyebrow="Security · SSO" :title="ssoCreated ? 'SSO app created' : 'Add SSO app'" size="md" @close="ssoOpen = false">
<!-- Step 1: form -->
<div v-if="!ssoCreated" class="form-stack">
<label class="field"><Eyebrow>App name</Eyebrow>
<input class="input" v-model="ssoName" placeholder="e.g. Internal Wiki" />
</label>
<label class="field"><Eyebrow>Redirect URIs (one per line)</Eyebrow>
<textarea v-model="ssoRedirects" class="ta" rows="3" placeholder="https://app.example.com/oauth/callback" />
</label>
<div class="enforce-note">
<UiIcon name="key" :size="13" stroke="var(--text-mute)" />
<span>Creates an OpenID Connect app in your identity provider, accessible only to this workspace.</span>
</div>
</div>
<!-- Step 2: credentials (shown once) -->
<div v-else class="form-stack">
<div class="cred-warn">
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
<span>Copy the client secret now it won't be shown again.</span>
</div>
<div class="cred-row"><Eyebrow>Client ID</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.clientId }}</Mono><button class="copy" @click="copyText(ssoCreated.clientId)"><UiIcon name="copy" :size="12" /></button></div></div>
<div class="cred-row"><Eyebrow>Client secret</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.clientSecret }}</Mono><button class="copy" @click="copyText(ssoCreated.clientSecret)"><UiIcon name="copy" :size="12" /></button></div></div>
<div class="cred-row"><Eyebrow>Discovery URL</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.wellKnownUrl }}</Mono><button class="copy" @click="copyText(ssoCreated.wellKnownUrl)"><UiIcon name="copy" :size="12" /></button></div></div>
</div>
<template #footer>
<template v-if="!ssoCreated">
<UiButton variant="ghost" @click="ssoOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="creatingSso || !ssoName.trim() || !ssoRedirects.trim()" @click="createSso">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ creatingSso ? 'Creating' : 'Create app' }}
</UiButton>
</template>
<UiButton v-else variant="primary" @click="ssoOpen = false">Done</UiButton>
</template>
</Modal>
<!-- Delete SSO app confirm -->
<ConfirmDialog
:open="!!ssoDeleteId"
eyebrow="Security · SSO"
:title="`Remove ${ssoDeleteName}?`"
confirm-label="Remove app"
tone="danger"
@close="ssoDeleteId = null"
@confirm="confirmDeleteSso"
>
The app's OpenID Connect provider is deleted from your identity provider. Anyone using it to sign in will lose access immediately.
</ConfirmDialog>
</div>
</template>
@@ -342,6 +677,36 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
.chip span { font-weight: 500; }
.spacer { flex: 1; }
.select {
height: 36px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.action-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.achip {
padding: 5px 12px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
font-family: inherit;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
}
.achip:hover { color: var(--text); }
.achip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.audit-foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
.no-hover td { cursor: default; }
.empty-row { text-align: center; padding: 40px 16px; }
.audit-table { width: 100%; border-collapse: collapse; }
.audit-table thead th {
text-align: left;
@@ -397,4 +762,55 @@ const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
.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); }
/* MFA overview */
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.mfa-overview { display: flex; align-items: center; gap: 24px; margin-bottom: 12px; }
.mfa-stat { flex-shrink: 0; }
.mfa-num { font-family: var(--font-display); font-weight: 600; font-size: 28px; letter-spacing: -0.01em; }
.mfa-bar-wrap { flex: 1; min-width: 0; }
.mfa-bar { height: 8px; background: var(--bg); border-radius: 999px; overflow: hidden; margin-bottom: 6px; }
.mfa-bar span { display: block; height: 100%; background: var(--ok); }
.mfa-missing { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; padding: 10px 12px; background: var(--bg); border-radius: 6px; font-size: 12px; }
.missing-names { color: var(--text-dim); }
.sub-head { display: flex; align-items: center; gap: 8px; margin: 20px 0 10px; }
.enforce-note { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 12px; color: var(--text-mute); }
.soon-box {
display: flex; gap: 10px; align-items: flex-start; padding: 14px;
background: var(--bg); border: 1px dashed var(--border-hi, var(--border));
border-radius: 6px; font-size: 12px; color: var(--text-dim); line-height: 1.5;
}
.ip-add { display: flex; gap: 8px; margin-top: 10px; }
.ip-add .input { flex: 1; }
/* SSO */
.sso-empty { padding: 16px 0; }
.icon-del {
background: transparent; border: 1px solid var(--border); border-radius: 6px;
width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
color: var(--text-mute); cursor: pointer;
}
.icon-del:hover { color: var(--bad); border-color: var(--bad); }
.ta {
width: 100%; box-sizing: border-box; padding: 10px 12px; background: var(--surface);
border: 1px solid var(--border); border-radius: 6px; font-family: var(--font-mono);
font-size: 12px; color: var(--text); outline: none; resize: vertical; line-height: 1.6;
}
.ta:focus { border-color: var(--text); }
.cred-warn {
display: flex; gap: 10px; align-items: center; padding: 12px;
background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.2);
border-radius: 6px; font-size: 12px; color: var(--text-dim);
}
.cred-row { display: flex; flex-direction: column; gap: 6px; }
.cred-val {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
font-size: 12px; overflow: hidden;
}
.cred-val :deep(.mono) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.copy { background: transparent; border: none; color: var(--text-mute); cursor: pointer; flex-shrink: 0; padding: 2px; }
.copy:hover { color: var(--text); }
</style>
+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>
@@ -1,9 +1,12 @@
// Tenant-scoped audit slice for the customer-admin dashboard. Proxies
// GET /tenants/:slug/audit with the signed-in user's access token. The
// platform-api enforces tenant membership and filters strictly by tenantSlug.
// Tenant-scoped audit slice for the customer-admin Security & audit page.
// Proxies GET /tenants/:slug/audit with the signed-in user's access token and
// forwards the filter/pagination params. platform-api enforces tenant
// membership and pins the query to this tenant's slug.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
const PASS_THROUGH = ['limit', 'q', 'action', 'outcome', 'actorEmail', 'since', 'before'] as const
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
@@ -11,10 +14,15 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const { limit } = getQuery(event)
const incoming = getQuery(event)
const query: Record<string, string> = {}
for (const k of PASS_THROUGH) {
const v = incoming[k]
if (v != null && v !== '') query[k] = String(v)
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/audit`, {
headers: { Authorization: `Bearer ${accessToken}` },
query: limit ? { limit } : undefined,
query,
})
})
@@ -0,0 +1,18 @@
// Live MFA-enrollment overview for the workspace. Proxies GET
// /tenants/:slug/mfa-status; platform-api enforces tenant membership and reads
// each member's enrollment from Authentik.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mfa-status`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,20 @@
// Save the workspace security policy (stored intent). Proxies PATCH
// /tenants/:slug/security-policy; platform-api enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/security-policy`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,20 @@
// Remove an SSO app. Proxies DELETE /tenants/:slug/sso-apps/:id; platform-api
// deletes the Authentik application + provider and enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const id = getRouterParam(event, 'id')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/sso-apps/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,17 @@
// List the tenant's SSO apps. Proxies GET /tenants/:slug/sso-apps;
// platform-api enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/sso-apps`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,21 @@
// Register a new SSO app (Dezky as IdP). Proxies POST /tenants/:slug/sso-apps
// with { name, redirectUris }; platform-api creates the Authentik provider +
// application and returns the client credentials (secret shown once).
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/sso-apps`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,19 @@
// Aggregate storage usage for the customer-admin Storage page. Proxies
// GET /tenants/:slug/storage with the signed-in user's access token;
// platform-api enforces tenant membership and computes the summary live from
// OCIS libregraph.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/storage`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
+34
View File
@@ -14,6 +14,38 @@ export interface TenantBillingInfo {
contactEmail?: string
}
// Customer security policy (stored intent; enforcement wired incrementally).
export interface TenantSecurityPolicy {
mfaMode: 'all' | 'admins' | 'optional'
sessionIdleMinutes?: number
sessionAbsoluteHours?: number
allowedCountries?: string[]
ipAllowlist?: string[]
}
// Live MFA-enrollment overview from GET /tenants/:slug/mfa-status.
export interface MfaStatus {
total: number
enrolled: number
members: Array<{ id: string; name: string; email: string; role: string; enrolled: boolean }>
}
// An SSO app (Dezky as IdP) from GET /tenants/:slug/sso-apps.
export interface SsoApp {
id: string
name: string
protocol: 'oidc' | 'saml'
clientId?: string
redirectUris: string[]
issuer?: string
wellKnownUrl?: string
createdAt?: string
}
// POST response also carries the one-time client secret.
export interface SsoAppCreated extends SsoApp {
clientSecret: string
}
// A tenant as returned by GET /tenants (findByIds for the signed-in user).
export interface TenantDoc {
_id: string
@@ -28,6 +60,7 @@ export interface TenantDoc {
industry?: string
brandColor?: string
billingInfo?: TenantBillingInfo
securityPolicy?: TenantSecurityPolicy
createdAt?: string
updatedAt?: string
}
@@ -115,6 +148,7 @@ export interface AuditEventDoc {
at: string
actorType: 'user' | 'system'
actorEmail?: string
actorIp?: string
action: string
outcome: 'success' | 'failure'
resourceType?: string
+21
View File
@@ -0,0 +1,21 @@
// Byte formatting for storage figures. Binary units (GiB/TiB) to match what
// OCIS reports. Auto-imported by Nuxt (utils/ is scanned by default).
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
// Human-readable size, e.g. 1610612736 → "1.5 GB". Picks the largest unit that
// keeps the number readable; trims trailing ".0".
export function formatBytes(bytes: number, decimals = 1): string {
if (!bytes || bytes < 0) return '0 GB'
const i = Math.min(UNITS.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
const value = bytes / 1024 ** i
const fixed = value.toFixed(decimals)
return `${fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed} ${UNITS[i]}`
}
// Integer percentage of used vs total, clamped to 0100. Returns 0 when total
// is 0 (unlimited) to avoid NaN in width styles.
export function percent(used: number, total: number): number {
if (!total || total <= 0) return 0
return Math.min(100, Math.max(0, Math.round((used / total) * 100)))
}