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:
@@ -52,6 +52,12 @@ STALWART_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||
# OCIS
|
||||
# ────────────────────────────────────────
|
||||
OCIS_ADMIN_PASSWORD=changeme_use_openssl_rand
|
||||
# Dedicated OCIS service user (Authentik) used by platform-api to read drive
|
||||
# quotas for the Storage page via an OIDC password grant. Must exist in
|
||||
# Authentik, have access to the OCIS application, and hold the OCIS admin role
|
||||
# (required to list all drives). See docs/NEXT-STEPS.md.
|
||||
OCIS_SVC_USERNAME=svc-platform-api
|
||||
OCIS_SVC_PASSWORD=changeme_use_openssl_rand
|
||||
|
||||
# ────────────────────────────────────────
|
||||
# Collabora
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<div class="sso-intro">
|
||||
Connect external applications via OIDC or SAML. dezky's Authentik instance is the source of truth for identity.
|
||||
<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>
|
||||
|
||||
@@ -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,51 +62,68 @@ 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 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: 64%" />
|
||||
<span :style="{ width: usedPct + '%' }" />
|
||||
</div>
|
||||
<div class="progress-legend">
|
||||
<span>1.4 TB used</span>
|
||||
<span>820 GB free</span>
|
||||
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
|
||||
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
|
||||
</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 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.storage / 50) * 100) + '%' }" /></div>
|
||||
<Mono>{{ u.storage }} GB</Mono>
|
||||
<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>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 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 class="progress thinner"><span :style="{ width: p + '%', background: c }" /></div>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<Mono dim>No data available.</Mono>
|
||||
</div>
|
||||
</Card>
|
||||
</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}` },
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 0–100. 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)))
|
||||
}
|
||||
@@ -555,6 +555,15 @@ services:
|
||||
# /ingest/stalwart/webhook. Both ends read the same env var.
|
||||
STALWART_WEBHOOK_SECRET: ${STALWART_WEBHOOK_SECRET}
|
||||
OCIS_API_URL: https://files.dezky.local
|
||||
# Service-user auth for libregraph read calls (drive quotas powering the
|
||||
# customer-admin Storage page). OCIS has no backend service-account grant
|
||||
# and trusts a single issuer, so we run an OIDC password grant against the
|
||||
# SAME provider OCIS trusts (client `ocis-web`) as a dedicated service user
|
||||
# that holds the OCIS admin role. See docs/NEXT-STEPS.md.
|
||||
OCIS_OIDC_TOKEN_URL: https://auth.dezky.local/application/o/token/
|
||||
OCIS_OIDC_CLIENT_ID: ocis-web
|
||||
OCIS_SVC_USERNAME: ${OCIS_SVC_USERNAME}
|
||||
OCIS_SVC_PASSWORD: ${OCIS_SVC_PASSWORD}
|
||||
# JWT validation against Authentik for portal-issued access tokens.
|
||||
# Issuers are comma-separated — each Authentik OAuth provider issues tokens
|
||||
# with its own per-app issuer URL, so we accept both portal and operator.
|
||||
|
||||
@@ -21,6 +21,12 @@ export class AuthentikClient {
|
||||
this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
|
||||
}
|
||||
|
||||
// Public Authentik origin (no /api/v3) — for building user-facing OIDC URLs
|
||||
// like the per-app issuer / .well-known discovery document.
|
||||
get publicBase(): string {
|
||||
return this.base.replace(/\/api\/v3\/?$/, '')
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
...init,
|
||||
@@ -313,6 +319,126 @@ export class AuthentikClient {
|
||||
})
|
||||
this.logger.log(`Set brand ${brandUuid} flow_recovery → ${flowUuid}`)
|
||||
}
|
||||
|
||||
// ── SSO apps: customer registers external apps using Dezky as the IdP ─────
|
||||
// We create an OAuth2/OIDC Provider + Application in Authentik and bind the
|
||||
// tenant's group to the application so only that workspace's members can use
|
||||
// it. Provider pk is a number; Application pk is a uuid (slug is the human id).
|
||||
|
||||
// Find a flow pk by designation, preferring a slug substring (e.g. the
|
||||
// explicit-consent authorization flow) and falling back to the first match.
|
||||
async findFlowPk(designation: string, preferSlugIncludes?: string): Promise<string | undefined> {
|
||||
const res = await this.request<{ results: AuthentikFlow[] }>(
|
||||
`/flows/instances/?designation=${encodeURIComponent(designation)}`,
|
||||
)
|
||||
if (!res.results.length) return undefined
|
||||
if (preferSlugIncludes) {
|
||||
const pref = res.results.find((f) => f.slug.includes(preferSlugIncludes))
|
||||
if (pref) return pref.pk
|
||||
}
|
||||
return res.results[0].pk
|
||||
}
|
||||
|
||||
async findSigningKeyPk(): Promise<string | undefined> {
|
||||
const res = await this.request<{ results: Array<{ pk: string; private_key_available?: boolean }> }>(
|
||||
`/crypto/certificatekeypairs/?has_key=true`,
|
||||
)
|
||||
return res.results.find((k) => k.private_key_available)?.pk ?? res.results[0]?.pk
|
||||
}
|
||||
|
||||
// The three standard OIDC scope mappings, resolved by Authentik's stable
|
||||
// `managed` identifiers (pks differ per instance).
|
||||
async findOidcScopeMappingPks(): Promise<string[]> {
|
||||
const res = await this.request<{ results: Array<{ pk: string; managed?: string }> }>(
|
||||
`/propertymappings/provider/scope/`,
|
||||
)
|
||||
const wanted = new Set([
|
||||
'goauthentik.io/providers/oauth2/scope-openid',
|
||||
'goauthentik.io/providers/oauth2/scope-email',
|
||||
'goauthentik.io/providers/oauth2/scope-profile',
|
||||
])
|
||||
return res.results.filter((m) => m.managed && wanted.has(m.managed)).map((m) => m.pk)
|
||||
}
|
||||
|
||||
async createOAuth2Provider(input: {
|
||||
name: string
|
||||
redirectUris: string[]
|
||||
clientType?: 'confidential' | 'public'
|
||||
}): Promise<{ pk: number; clientId: string; clientSecret: string }> {
|
||||
const [authorizationFlow, invalidationFlow, signingKey, scopeMappings] = await Promise.all([
|
||||
this.findFlowPk('authorization', 'explicit-consent'),
|
||||
this.findFlowPk('invalidation', 'provider-invalidation'),
|
||||
this.findSigningKeyPk(),
|
||||
this.findOidcScopeMappingPks(),
|
||||
])
|
||||
if (!authorizationFlow) throw new Error('No Authentik authorization flow available')
|
||||
const body: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
authorization_flow: authorizationFlow,
|
||||
client_type: input.clientType ?? 'confidential',
|
||||
redirect_uris: input.redirectUris.map((url) => ({ matching_mode: 'strict', url })),
|
||||
property_mappings: scopeMappings,
|
||||
sub_mode: 'hashed_user_id',
|
||||
}
|
||||
if (invalidationFlow) body.invalidation_flow = invalidationFlow
|
||||
if (signingKey) body.signing_key = signingKey
|
||||
const p = await this.request<{ pk: number; client_id: string; client_secret: string }>(
|
||||
'/providers/oauth2/',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
)
|
||||
this.logger.log(`Created Authentik OAuth2 provider "${input.name}" (pk=${p.pk})`)
|
||||
return { pk: p.pk, clientId: p.client_id, clientSecret: p.client_secret }
|
||||
}
|
||||
|
||||
async createApplication(input: {
|
||||
name: string
|
||||
slug: string
|
||||
providerPk: number
|
||||
group?: string
|
||||
}): Promise<{ pk: string; slug: string }> {
|
||||
const app = await this.request<{ pk: string; slug: string }>('/core/applications/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
provider: input.providerPk,
|
||||
group: input.group ?? '',
|
||||
}),
|
||||
})
|
||||
this.logger.log(`Created Authentik application "${input.slug}" (pk=${app.pk})`)
|
||||
return { pk: app.pk, slug: app.slug }
|
||||
}
|
||||
|
||||
// A binding with a group and no policy = allow that group. Scopes the app to
|
||||
// the tenant's workspace members.
|
||||
async bindGroupToApplication(appPk: string, groupPk: string): Promise<void> {
|
||||
await this.request('/policies/bindings/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target: appPk, group: groupPk, order: 0, enabled: true }),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteApplication(slug: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/core/applications/${slug}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
})
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Authentik DELETE application ${slug} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOAuth2Provider(pk: number): Promise<void> {
|
||||
const res = await fetch(`${this.base}/providers/oauth2/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
})
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Authentik DELETE provider ${pk} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthentikFlow {
|
||||
|
||||
@@ -1,20 +1,151 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
// OCIS provisioning is stubbed for now. Real implementation needs:
|
||||
// 1. Service-to-service auth via OIDC client_credentials (or admin user)
|
||||
// 2. Call the libregraph /graph/v1.0/drives endpoint to create a project space
|
||||
// 3. Assign the space to the tenant's group / users
|
||||
// Phase 4 ships the orchestration; OCIS hooks up in a follow-up.
|
||||
// A libregraph quota object as returned on each drive. All byte counts are
|
||||
// integers; `total` of 0 means "unlimited" in OCIS (the default in dev).
|
||||
export interface OcisQuota {
|
||||
total?: number
|
||||
used?: number
|
||||
remaining?: number
|
||||
deleted?: number
|
||||
state?: string
|
||||
}
|
||||
|
||||
// A libregraph drive. We only model the fields the storage summary needs.
|
||||
export interface OcisDrive {
|
||||
id: string
|
||||
name?: string
|
||||
driveType?: string // 'personal' | 'project' | 'virtual' | 'mountpoint'
|
||||
quota?: OcisQuota
|
||||
owner?: { user?: { id?: string; displayName?: string } }
|
||||
}
|
||||
|
||||
// A libregraph user. `mail` is the join key back to our Mongo User docs.
|
||||
export interface OcisUser {
|
||||
id: string
|
||||
displayName?: string
|
||||
mail?: string
|
||||
}
|
||||
|
||||
// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed —
|
||||
// it needs the project-space create call (see docs/NEXT-STEPS.md). The READ
|
||||
// layer below is real: it lists per-drive quota for the customer-admin Storage
|
||||
// page via the libregraph /graph/v1.0 API.
|
||||
//
|
||||
// Auth: OCIS has no built-in service-account/client-credentials grant for
|
||||
// backend access (ownCloud devs: "one needs to go through OIDC authentication
|
||||
// to obtain an access token"), and it trusts exactly one issuer. So we run an
|
||||
// OIDC Resource-Owner-Password grant against the SAME Authentik provider OCIS
|
||||
// trusts (client `ocis-web`), as a dedicated service user that holds the OCIS
|
||||
// admin role (required to list all drives). The short-lived token is cached and
|
||||
// refreshed in memory. Basic auth (PROXY_ENABLE_BASIC_AUTH) doesn't resolve the
|
||||
// IDM admin in our external-IdP setup, hence this route.
|
||||
@Injectable()
|
||||
export class OcisClient {
|
||||
private readonly logger = new Logger(OcisClient.name)
|
||||
private readonly base: string
|
||||
private readonly tokenUrl?: string
|
||||
private readonly clientId?: string
|
||||
private readonly clientSecret?: string
|
||||
private readonly username?: string
|
||||
private readonly password?: string
|
||||
private readonly scope: string
|
||||
|
||||
// In-memory token cache. Tokens live minutes; we re-grant when within the
|
||||
// skew window. Never persisted — same lifecycle as the process.
|
||||
private token?: string
|
||||
private tokenExpiresAt = 0
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.base = config.getOrThrow<string>('OCIS_API_URL')
|
||||
this.tokenUrl = config.get<string>('OCIS_OIDC_TOKEN_URL') || undefined
|
||||
this.clientId = config.get<string>('OCIS_OIDC_CLIENT_ID') || undefined
|
||||
this.clientSecret = config.get<string>('OCIS_OIDC_CLIENT_SECRET') || undefined
|
||||
this.username = config.get<string>('OCIS_SVC_USERNAME') || undefined
|
||||
this.password = config.get<string>('OCIS_SVC_PASSWORD') || undefined
|
||||
this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email'
|
||||
}
|
||||
|
||||
// True once we have everything needed to mint a token. When false the read
|
||||
// methods short-circuit so the Storage page renders an "unavailable" state
|
||||
// instead of erroring.
|
||||
get configured(): boolean {
|
||||
return !!(this.tokenUrl && this.clientId && this.username && this.password)
|
||||
}
|
||||
|
||||
// ROPC (password) grant against the ocis provider's token endpoint. Cached
|
||||
// until ~30s before expiry. The resulting token's issuer matches
|
||||
// OCIS_OIDC_ISSUER and its preferred_username maps to the service user.
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.token && Date.now() < this.tokenExpiresAt) return this.token
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: this.clientId!,
|
||||
username: this.username!,
|
||||
password: this.password!,
|
||||
scope: this.scope,
|
||||
})
|
||||
// Public clients (ocis-web) omit the secret; included only if configured
|
||||
// (e.g. a confidential service provider).
|
||||
if (this.clientSecret) body.set('client_secret', this.clientSecret)
|
||||
|
||||
const res = await fetch(this.tokenUrl!, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`OCIS token grant → ${res.status}: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const json = (await res.json()) as { access_token?: string; expires_in?: number }
|
||||
if (!json.access_token) throw new Error('OCIS token grant returned no access_token')
|
||||
this.token = json.access_token
|
||||
this.tokenExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000
|
||||
return this.token
|
||||
}
|
||||
|
||||
private async request<T>(path: string): Promise<T> {
|
||||
const token = await this.getToken()
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
// A 401 likely means a stale cached token; drop it so the next call
|
||||
// re-grants. (One retry is enough; callers degrade gracefully on error.)
|
||||
if (res.status === 401) {
|
||||
this.token = undefined
|
||||
this.tokenExpiresAt = 0
|
||||
}
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`OCIS GET ${path} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
// List all drives, optionally filtered with an OData $filter expression
|
||||
// (e.g. `driveType eq 'personal'`). Requires the OCIS admin role. libregraph
|
||||
// caps the page at 100 items; a tenant's personal drives stay well under that.
|
||||
async listDrives(filter?: string): Promise<OcisDrive[]> {
|
||||
if (!this.configured) return []
|
||||
const qs = filter ? `?$filter=${encodeURIComponent(filter)}` : ''
|
||||
const body = await this.request<{ value?: OcisDrive[] }>(`/graph/v1.0/drives${qs}`)
|
||||
return body.value ?? []
|
||||
}
|
||||
|
||||
// List all OCIS users so we can map a drive's owner id back to an email and
|
||||
// join against our tenant membership.
|
||||
async listUsers(): Promise<OcisUser[]> {
|
||||
if (!this.configured) return []
|
||||
const body = await this.request<{ value?: OcisUser[] }>('/graph/v1.0/users')
|
||||
return body.value ?? []
|
||||
}
|
||||
|
||||
// ── Provisioning (stubbed) ────────────────────────────────────────────────
|
||||
// Real implementation needs POST /graph/v1.0/drives { name, driveType:
|
||||
// 'project' } to create a space and assign it to the tenant's group / users.
|
||||
// Phase 4 ships the orchestration; this hooks up in a follow-up.
|
||||
async ensureSpace(slug: string): Promise<{ id: string }> {
|
||||
this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`)
|
||||
return { id: `stub-${slug}` }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type TenantSsoAppDocument = HydratedDocument<TenantSsoApp>
|
||||
|
||||
// An external application the tenant has registered to use Dezky (Authentik) as
|
||||
// its identity provider. We mirror the Authentik objects here (provider +
|
||||
// application refs) so the portal can list/manage without re-querying Authentik;
|
||||
// the client_secret is never stored — it's shown once at creation time.
|
||||
@Schema({ collection: 'tenant_sso_apps', timestamps: true })
|
||||
export class TenantSsoApp {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, trim: true })
|
||||
name!: string
|
||||
|
||||
// 'oidc' for now (SAML support can join later without a schema change).
|
||||
@Prop({ enum: ['oidc', 'saml'], default: 'oidc' })
|
||||
protocol!: 'oidc' | 'saml'
|
||||
|
||||
// OIDC client id (safe to display). Secret is intentionally not persisted.
|
||||
@Prop()
|
||||
clientId?: string
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
redirectUris!: string[]
|
||||
|
||||
// Authentik handles — used for delete/reconcile.
|
||||
@Prop()
|
||||
authentikAppSlug?: string
|
||||
|
||||
@Prop()
|
||||
authentikAppPk?: string // application uuid
|
||||
|
||||
@Prop({ type: Number })
|
||||
authentikProviderPk?: number
|
||||
}
|
||||
|
||||
export const TenantSsoAppSchema = SchemaFactory.createForClass(TenantSsoApp)
|
||||
@@ -78,6 +78,29 @@ export class Tenant {
|
||||
contactEmail?: string
|
||||
}
|
||||
|
||||
// Customer-managed security policy. Stored intent; enforcement is wired
|
||||
// incrementally (MFA enrollment is read live; MFA/geo/session enforcement
|
||||
// via Authentik lands in a later stage). mfaMode: 'all' | 'admins' |
|
||||
// 'optional'. Timeouts in minutes/hours. allowedCountries = ISO alpha-2;
|
||||
// ipAllowlist = CIDR strings.
|
||||
@Prop({
|
||||
type: {
|
||||
mfaMode: { type: String, enum: ['all', 'admins', 'optional'], default: 'optional' },
|
||||
sessionIdleMinutes: { type: Number, min: 0 },
|
||||
sessionAbsoluteHours: { type: Number, min: 0 },
|
||||
allowedCountries: { type: [String], default: undefined },
|
||||
ipAllowlist: { type: [String], default: undefined },
|
||||
},
|
||||
default: () => ({ mfaMode: 'optional' }),
|
||||
})
|
||||
securityPolicy!: {
|
||||
mfaMode: 'all' | 'admins' | 'optional'
|
||||
sessionIdleMinutes?: number
|
||||
sessionAbsoluteHours?: number
|
||||
allowedCountries?: string[]
|
||||
ipAllowlist?: string[]
|
||||
}
|
||||
|
||||
// Per-integration provisioning state. Each one is updated independently when its
|
||||
// upstream API call succeeds or fails — orchestration is best-effort, not atomic.
|
||||
@Prop({
|
||||
|
||||
@@ -5,7 +5,11 @@ export type UserDocument = HydratedDocument<User>
|
||||
|
||||
export type UserRole = 'owner' | 'admin' | 'member'
|
||||
|
||||
@Schema({ collection: 'users', timestamps: true })
|
||||
// toObject must flatten Maps too: meWithPartner() returns user.toObject() for
|
||||
// partner-staff, and JSON.stringify on a raw Map yields {} — which would drop
|
||||
// tenantRoles from the /me payload. toJSON already flattens by default; this
|
||||
// makes toObject() agree so the field survives in every serialization path.
|
||||
@Schema({ collection: 'users', timestamps: true, toObject: { flattenMaps: true } })
|
||||
export class User {
|
||||
// Authentik subject claim — stable identity across login sessions.
|
||||
@Prop({ required: true, unique: true, index: true })
|
||||
@@ -30,10 +34,24 @@ export class User {
|
||||
@Prop({ required: true, trim: true })
|
||||
name!: string
|
||||
|
||||
// Role is per-user globally for the MVP. Refine to per-tenant later if needed.
|
||||
// Legacy global role + the fallback when a tenant has no explicit entry in
|
||||
// tenantRoles below. For partner staff this is their partner-org role (used
|
||||
// by the partner last-admin guard). For tenant users it's superseded by the
|
||||
// per-tenant entry. Always resolve a tenant-scoped role via roleForTenant(),
|
||||
// never by reading this field directly.
|
||||
@Prop({ enum: ['owner', 'admin', 'member'], default: 'member' })
|
||||
role!: UserRole
|
||||
|
||||
// Per-tenant role overrides, keyed by stringified tenant ObjectId. A present
|
||||
// key wins for that tenant; absence falls back to the global `role` above
|
||||
// (so existing single-role users keep their role everywhere with no
|
||||
// migration). This lets one user be 'admin' of tenant A and 'member' of
|
||||
// tenant B — the flat global role couldn't express that, and re-inviting an
|
||||
// existing user into a second tenant used to silently keep their first
|
||||
// tenant's role. Resolve via roleForTenant().
|
||||
@Prop({ type: Map, of: String, default: undefined })
|
||||
tenantRoles?: Map<string, UserRole>
|
||||
|
||||
@Prop({ default: true })
|
||||
active!: boolean
|
||||
|
||||
@@ -59,3 +77,19 @@ export class User {
|
||||
}
|
||||
|
||||
export const UserSchema = SchemaFactory.createForClass(User)
|
||||
|
||||
// Single source of truth for tenant-scoped role resolution. A per-tenant entry
|
||||
// in tenantRoles wins; absent, fall back to the legacy global `role`, then
|
||||
// 'member'. Backend and frontend (apps/portal useMe.roleForTenant) must keep
|
||||
// this precedence in sync. Accepts both a hydrated Map and a plain object (e.g.
|
||||
// a lean() result or a JSON-deserialized doc).
|
||||
export function roleForTenant(
|
||||
user: Pick<User, 'role' | 'tenantRoles'>,
|
||||
tenantId: Types.ObjectId | string,
|
||||
): UserRole {
|
||||
const key = String(tenantId)
|
||||
const roles = user.tenantRoles
|
||||
const fromMap =
|
||||
roles instanceof Map ? roles.get(key) : (roles as Record<string, UserRole> | undefined)?.[key]
|
||||
return fromMap ?? user.role ?? 'member'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, IsUrl, MaxLength, MinLength } from 'class-validator'
|
||||
|
||||
// Register an external OIDC app that uses Dezky as its identity provider.
|
||||
export class CreateSsoAppDto {
|
||||
@IsString() @MinLength(1) @MaxLength(80)
|
||||
name!: string
|
||||
|
||||
// One or more exact redirect URIs the app will use after sign-in.
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(20)
|
||||
@IsUrl({ require_tld: false, require_protocol: true }, { each: true })
|
||||
redirectUris!: string[]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator'
|
||||
|
||||
// Customer-editable security policy. Stored intent; enforcement is wired
|
||||
// incrementally. Narrow — never touches plan/status/partner.
|
||||
export class UpdateSecurityPolicyDto {
|
||||
@IsOptional() @IsEnum(['all', 'admins', 'optional'])
|
||||
mfaMode?: 'all' | 'admins' | 'optional'
|
||||
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1440)
|
||||
sessionIdleMinutes?: number
|
||||
|
||||
@IsOptional() @IsInt() @Min(0) @Max(8760)
|
||||
sessionAbsoluteHours?: number
|
||||
|
||||
// ISO alpha-2 country codes (uppercase). Empty array clears the allow-list.
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(250) @IsString({ each: true }) @MaxLength(2, { each: true })
|
||||
allowedCountries?: string[]
|
||||
|
||||
// CIDR / IP strings. Lightly validated as strings; format-checked at the
|
||||
// enforcement layer when geo/IP wiring lands.
|
||||
@IsOptional() @IsArray() @ArrayMaxSize(200) @IsString({ each: true }) @MaxLength(64, { each: true })
|
||||
ipAllowlist?: string[]
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { OcisClient } from '../integrations/ocis.client.js'
|
||||
import { TenantsService } from './tenants.service.js'
|
||||
|
||||
const GIB = 1024 ** 3
|
||||
|
||||
// Human-facing plan labels, mirroring composables/useTenant.ts so the portal
|
||||
// shows the same names everywhere.
|
||||
const PLAN_LABEL: Record<string, string> = {
|
||||
mvp: 'Starter',
|
||||
pro: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
|
||||
// Fallback "allocated" per plan, used only when OCIS reports unlimited drives
|
||||
// (quota.total === 0, the dev default). Once per-plan storage lands as real
|
||||
// config (Price schema or tenant override), this map goes away.
|
||||
const PLAN_QUOTA_BYTES: Record<string, number> = {
|
||||
mvp: 100 * GIB,
|
||||
pro: 1024 * GIB,
|
||||
enterprise: 5 * 1024 * GIB,
|
||||
}
|
||||
|
||||
export interface StorageTopUser {
|
||||
name: string
|
||||
email: string
|
||||
usedBytes: number
|
||||
}
|
||||
|
||||
export interface StorageSummary {
|
||||
// False when OCIS is unreachable / basic auth isn't configured — the page
|
||||
// renders an "unavailable" state rather than zeros that look like real data.
|
||||
available: boolean
|
||||
plan: string
|
||||
usedBytes: number
|
||||
quotaBytes: number
|
||||
freeBytes: number
|
||||
trashBytes: number
|
||||
driveCount: number
|
||||
topUsers: StorageTopUser[]
|
||||
}
|
||||
|
||||
// Computes a tenant's aggregate storage live by joining its Dezky users to
|
||||
// their OCIS personal drives. Read-only, no persistence — every call hits
|
||||
// libregraph. Mapping key is email (OCIS users are auto-provisioned from
|
||||
// Authentik's preferred_username and carry the same mail).
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name)
|
||||
|
||||
constructor(
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly ocis: OcisClient,
|
||||
) {}
|
||||
|
||||
async getStorageSummary(slug: string): Promise<StorageSummary> {
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
const planKey = tenant.plan ?? 'mvp'
|
||||
const planLabel = PLAN_LABEL[planKey] ?? planKey
|
||||
|
||||
const empty: StorageSummary = {
|
||||
available: false,
|
||||
plan: planLabel,
|
||||
usedBytes: 0,
|
||||
quotaBytes: PLAN_QUOTA_BYTES[planKey] ?? 0,
|
||||
freeBytes: 0,
|
||||
trashBytes: 0,
|
||||
driveCount: 0,
|
||||
topUsers: [],
|
||||
}
|
||||
|
||||
if (!this.ocis.configured) return empty
|
||||
|
||||
const members = await this.tenants.listUsersForTenant(slug)
|
||||
const memberByEmail = new Map(
|
||||
members.map((m) => [m.email.toLowerCase(), m]),
|
||||
)
|
||||
|
||||
let ocisUsers
|
||||
let personalDrives
|
||||
try {
|
||||
;[ocisUsers, personalDrives] = await Promise.all([
|
||||
this.ocis.listUsers(),
|
||||
this.ocis.listDrives("driveType eq 'personal'"),
|
||||
])
|
||||
} catch (err) {
|
||||
// OCIS down, wrong credentials, basic auth disabled — degrade gracefully.
|
||||
this.logger.warn(`Storage summary for "${slug}" falling back to unavailable: ${String(err)}`)
|
||||
return empty
|
||||
}
|
||||
|
||||
// OCIS user id → email, so we can attribute each drive's owner to a member.
|
||||
const emailByOcisId = new Map(
|
||||
ocisUsers
|
||||
.filter((u) => u.mail)
|
||||
.map((u) => [u.id, u.mail!.toLowerCase()]),
|
||||
)
|
||||
|
||||
let usedBytes = 0
|
||||
let trashBytes = 0
|
||||
let quotaFromDrives = 0
|
||||
let driveCount = 0
|
||||
const usedByEmail = new Map<string, number>()
|
||||
|
||||
for (const drive of personalDrives) {
|
||||
const ownerId = drive.owner?.user?.id
|
||||
const email = ownerId ? emailByOcisId.get(ownerId) : undefined
|
||||
if (!email || !memberByEmail.has(email)) continue // not a member of this tenant
|
||||
|
||||
const used = drive.quota?.used ?? 0
|
||||
usedBytes += used
|
||||
trashBytes += drive.quota?.deleted ?? 0
|
||||
quotaFromDrives += drive.quota?.total ?? 0
|
||||
driveCount += 1
|
||||
usedByEmail.set(email, (usedByEmail.get(email) ?? 0) + used)
|
||||
}
|
||||
|
||||
// Prefer real OCIS-reported allocation; fall back to the plan map when
|
||||
// drives are unlimited (total 0).
|
||||
const quotaBytes = quotaFromDrives > 0 ? quotaFromDrives : (PLAN_QUOTA_BYTES[planKey] ?? 0)
|
||||
|
||||
const topUsers: StorageTopUser[] = [...usedByEmail.entries()]
|
||||
.map(([email, used]) => ({
|
||||
email,
|
||||
name: memberByEmail.get(email)?.name ?? email,
|
||||
usedBytes: used,
|
||||
}))
|
||||
.sort((a, b) => b.usedBytes - a.usedBytes)
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
available: true,
|
||||
plan: planLabel,
|
||||
usedBytes,
|
||||
quotaBytes,
|
||||
freeBytes: Math.max(0, quotaBytes - usedBytes),
|
||||
trashBytes,
|
||||
driveCount,
|
||||
topUsers,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { AuthentikClient } from '../integrations/authentik.client.js'
|
||||
import { TenantSsoApp, type TenantSsoAppDocument } from '../schemas/tenant-sso-app.schema.js'
|
||||
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import type { CreateSsoAppDto } from './dto/create-sso-app.dto.js'
|
||||
|
||||
export interface SsoAppView {
|
||||
id: string
|
||||
name: string
|
||||
protocol: 'oidc' | 'saml'
|
||||
clientId?: string
|
||||
redirectUris: string[]
|
||||
issuer?: string
|
||||
wellKnownUrl?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// Customer-managed SSO apps (Dezky as IdP). Creates an Authentik OAuth2 provider
|
||||
// + application bound to the tenant's group; mirrors the refs in Mongo so the
|
||||
// portal lists/deletes without re-querying Authentik.
|
||||
@Injectable()
|
||||
export class TenantSsoService {
|
||||
private readonly logger = new Logger(TenantSsoService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(TenantSsoApp.name) private readonly model: Model<TenantSsoAppDocument>,
|
||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||
private readonly authentik: AuthentikClient,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private toView(d: TenantSsoAppDocument): SsoAppView {
|
||||
const issuer = d.authentikAppSlug
|
||||
? `${this.authentik.publicBase}/application/o/${d.authentikAppSlug}/`
|
||||
: undefined
|
||||
return {
|
||||
id: String(d._id),
|
||||
name: d.name,
|
||||
protocol: d.protocol,
|
||||
clientId: d.clientId,
|
||||
redirectUris: d.redirectUris ?? [],
|
||||
issuer,
|
||||
wellKnownUrl: issuer ? `${issuer}.well-known/openid-configuration` : undefined,
|
||||
createdAt: (d as TenantSsoAppDocument & { createdAt?: Date }).createdAt?.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
async list(tenant: TenantDocument): Promise<SsoAppView[]> {
|
||||
const docs = await this.model.find({ tenantId: tenant._id }).sort({ createdAt: -1 }).exec()
|
||||
return docs.map((d) => this.toView(d))
|
||||
}
|
||||
|
||||
// Ensure the tenant has an Authentik group to scope apps to; provisions one
|
||||
// on demand for tenants created before group provisioning existed.
|
||||
private async ensureGroupPk(tenant: TenantDocument): Promise<string> {
|
||||
if (tenant.authentikGroupId) return tenant.authentikGroupId
|
||||
const group = await this.authentik.ensureGroup(tenant.slug)
|
||||
tenant.authentikGroupId = group.pk
|
||||
await tenant.save()
|
||||
return group.pk
|
||||
}
|
||||
|
||||
private kebab(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'app'
|
||||
}
|
||||
|
||||
private async uniqueSlug(base: string): Promise<string> {
|
||||
let slug = base
|
||||
let n = 1
|
||||
while (await this.model.exists({ authentikAppSlug: slug })) {
|
||||
n += 1
|
||||
slug = `${base}-${n}`
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
async create(
|
||||
tenant: TenantDocument,
|
||||
dto: CreateSsoAppDto,
|
||||
actor?: AuditActor,
|
||||
): Promise<SsoAppView & { clientSecret: string }> {
|
||||
const groupPk = await this.ensureGroupPk(tenant)
|
||||
const slug = await this.uniqueSlug(`dezky-${tenant.slug}-${this.kebab(dto.name)}`)
|
||||
|
||||
// Create provider → application → group binding. On any failure, roll back
|
||||
// whatever was created so we don't leave orphaned Authentik objects.
|
||||
let providerPk: number | undefined
|
||||
let appCreated = false
|
||||
try {
|
||||
const provider = await this.authentik.createOAuth2Provider({
|
||||
name: `${tenant.name} · ${dto.name}`,
|
||||
redirectUris: dto.redirectUris,
|
||||
})
|
||||
providerPk = provider.pk
|
||||
const app = await this.authentik.createApplication({
|
||||
name: dto.name,
|
||||
slug,
|
||||
providerPk: provider.pk,
|
||||
group: tenant.slug,
|
||||
})
|
||||
appCreated = true
|
||||
await this.authentik.bindGroupToApplication(app.pk, groupPk)
|
||||
|
||||
const doc = await this.model.create({
|
||||
tenantId: tenant._id,
|
||||
name: dto.name,
|
||||
protocol: 'oidc',
|
||||
clientId: provider.clientId,
|
||||
redirectUris: dto.redirectUris,
|
||||
authentikAppSlug: slug,
|
||||
authentikAppPk: app.pk,
|
||||
authentikProviderPk: provider.pk,
|
||||
})
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.sso_app_created',
|
||||
resourceType: 'tenant',
|
||||
resourceId: String(tenant._id),
|
||||
resourceName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { app: dto.name, slug },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
// client_secret is returned once, never persisted.
|
||||
return { ...this.toView(doc), clientSecret: provider.clientSecret }
|
||||
} catch (err) {
|
||||
if (appCreated) await this.authentik.deleteApplication(slug).catch(() => {})
|
||||
if (providerPk != null) await this.authentik.deleteOAuth2Provider(providerPk).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async remove(tenant: TenantDocument, id: string, actor?: AuditActor): Promise<void> {
|
||||
const doc = await this.model.findOne({ _id: id, tenantId: tenant._id }).exec()
|
||||
if (!doc) throw new NotFoundException('SSO app not found')
|
||||
|
||||
if (doc.authentikAppSlug) await this.authentik.deleteApplication(doc.authentikAppSlug).catch((e) => {
|
||||
this.logger.warn(`SSO delete: app ${doc.authentikAppSlug} → ${e instanceof Error ? e.message : e}`)
|
||||
})
|
||||
if (doc.authentikProviderPk != null) await this.authentik.deleteOAuth2Provider(doc.authentikProviderPk).catch((e) => {
|
||||
this.logger.warn(`SSO delete: provider ${doc.authentikProviderPk} → ${e instanceof Error ? e.message : e}`)
|
||||
})
|
||||
await doc.deleteOne()
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.sso_app_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: String(tenant._id),
|
||||
resourceName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { app: doc.name },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,15 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { CreateSsoAppDto } from './dto/create-sso-app.dto.js'
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||
import { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
|
||||
import { UpdateSecurityPolicyDto } from './dto/update-security-policy.dto.js'
|
||||
import { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||
import { StorageService } from './storage.service.js'
|
||||
import { TenantBrandingService } from './tenant-branding.service.js'
|
||||
import { TenantSsoService } from './tenant-sso.service.js'
|
||||
import { TenantsService } from './tenants.service.js'
|
||||
|
||||
// Build the audit actor from a resolved User doc + the originating request.
|
||||
@@ -48,6 +52,8 @@ export class TenantsController {
|
||||
private readonly actor: ActorService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly branding: TenantBrandingService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly sso: TenantSsoService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -86,6 +92,20 @@ export class TenantsController {
|
||||
return this.tenants.listUsersForTenant(slug)
|
||||
}
|
||||
|
||||
// Aggregate storage usage for the customer-admin Storage page. Same membership
|
||||
// gate as GET :slug/users — any member of the tenant can read their own
|
||||
// workspace's storage. Computed live from OCIS libregraph (read-only); if OCIS
|
||||
// is unreachable the summary comes back with available=false.
|
||||
@Get(':slug/storage')
|
||||
async getStorage(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.storage.getStorageSummary(slug)
|
||||
}
|
||||
|
||||
// Tenant-scoped audit slice for the customer-admin dashboard. Same membership
|
||||
// gate as GET :slug — any member of the tenant can read their own workspace's
|
||||
// activity. This is the portal-accessible counterpart to the operator-only
|
||||
@@ -96,6 +116,12 @@ export class TenantsController {
|
||||
@Param('slug') slug: string,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('q') q?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('outcome') outcome?: string,
|
||||
@Query('actorEmail') actorEmail?: string,
|
||||
@Query('since') since?: string,
|
||||
@Query('before') before?: string,
|
||||
) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
@@ -103,9 +129,17 @@ export class TenantsController {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
const parsed = limit ? Number.parseInt(limit, 10) : undefined
|
||||
// Always pinned to this tenant's slug — the filters only ever narrow within
|
||||
// it, never widen across tenants.
|
||||
return this.audit.list({
|
||||
tenantSlug: slug,
|
||||
limit: Number.isFinite(parsed) ? parsed : undefined,
|
||||
q: q || undefined,
|
||||
action: action || undefined,
|
||||
outcome: outcome === 'success' || outcome === 'failure' ? outcome : undefined,
|
||||
actorEmail: actorEmail || undefined,
|
||||
since: since ? new Date(since) : undefined,
|
||||
before: before ? new Date(before) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +203,76 @@ export class TenantsController {
|
||||
return this.branding.put(tenant, dto, auditActor(user, req))
|
||||
}
|
||||
|
||||
// Security policy (stored intent; enforcement wired incrementally).
|
||||
@Patch(':slug/security-policy')
|
||||
async updateSecurityPolicy(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: UpdateSecurityPolicyDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.tenants.updateSecurityPolicy(slug, dto, auditActor(user, req))
|
||||
}
|
||||
|
||||
// Live MFA-enrollment overview for the workspace.
|
||||
@Get(':slug/mfa-status')
|
||||
async mfaStatus(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.tenants.mfaStatusForTenant(slug)
|
||||
}
|
||||
|
||||
// SSO apps — Dezky as IdP. Creates real Authentik OAuth2 providers +
|
||||
// applications scoped to the tenant group. Membership-gated.
|
||||
@Get(':slug/sso-apps')
|
||||
async listSsoApps(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.sso.list(tenant)
|
||||
}
|
||||
|
||||
@Post(':slug/sso-apps')
|
||||
async createSsoApp(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: CreateSsoAppDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
return this.sso.create(tenant, dto, auditActor(user, req))
|
||||
}
|
||||
|
||||
@Delete(':slug/sso-apps/:id')
|
||||
@HttpCode(204)
|
||||
async deleteSsoApp(
|
||||
@Param('slug') slug: string,
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const user = await this.actor.resolve(jwt)
|
||||
const tenant = await this.tenants.findOneBySlug(slug)
|
||||
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||
}
|
||||
await this.sso.remove(tenant, id, auditActor(user, req))
|
||||
}
|
||||
|
||||
@Delete(':slug')
|
||||
@HttpCode(204)
|
||||
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
|
||||
|
||||
@@ -6,10 +6,13 @@ import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { PricesModule } from '../prices/prices.module.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
|
||||
import { TenantSsoApp, TenantSsoAppSchema } from '../schemas/tenant-sso-app.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { ProvisioningService } from './provisioning.service.js'
|
||||
import { StorageService } from './storage.service.js'
|
||||
import { TenantBrandingService } from './tenant-branding.service.js'
|
||||
import { TenantSsoService } from './tenant-sso.service.js'
|
||||
import { TenantsController } from './tenants.controller.js'
|
||||
import { TenantsService } from './tenants.service.js'
|
||||
|
||||
@@ -23,6 +26,7 @@ import { TenantsService } from './tenants.service.js'
|
||||
// lookup goes through PricesService for the soft-active filter.
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: TenantBranding.name, schema: TenantBrandingSchema },
|
||||
{ name: TenantSsoApp.name, schema: TenantSsoAppSchema },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
@@ -30,7 +34,7 @@ import { TenantsService } from './tenants.service.js'
|
||||
PricesModule,
|
||||
],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService, ProvisioningService, TenantBrandingService],
|
||||
providers: [TenantsService, ProvisioningService, TenantBrandingService, StorageService, TenantSsoService],
|
||||
exports: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { AuthentikClient } from '../integrations/authentik.client.js'
|
||||
import { StripeClient } from '../integrations/stripe.client.js'
|
||||
import { PricesService } from '../prices/prices.service.js'
|
||||
import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.js'
|
||||
@@ -17,6 +18,7 @@ import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
|
||||
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||
import type { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
|
||||
import type { UpdateSecurityPolicyDto } from './dto/update-security-policy.dto.js'
|
||||
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||
import { ProvisioningService } from './provisioning.service.js'
|
||||
|
||||
@@ -32,6 +34,7 @@ export class TenantsService {
|
||||
private readonly audit: AuditService,
|
||||
private readonly prices: PricesService,
|
||||
private readonly stripe: StripeClient,
|
||||
private readonly authentik: AuthentikClient,
|
||||
) {}
|
||||
|
||||
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
|
||||
@@ -278,6 +281,76 @@ export class TenantsService {
|
||||
return tenant
|
||||
}
|
||||
|
||||
// Narrow update of the security policy (stored intent). Dotted $set so each
|
||||
// provided field changes independently; arrays replace wholesale.
|
||||
async updateSecurityPolicy(
|
||||
slug: string,
|
||||
dto: UpdateSecurityPolicyDto,
|
||||
actor?: AuditActor,
|
||||
): Promise<TenantDocument> {
|
||||
const set: Record<string, unknown> = {}
|
||||
if (dto.mfaMode !== undefined) set['securityPolicy.mfaMode'] = dto.mfaMode
|
||||
if (dto.sessionIdleMinutes !== undefined) set['securityPolicy.sessionIdleMinutes'] = dto.sessionIdleMinutes
|
||||
if (dto.sessionAbsoluteHours !== undefined) set['securityPolicy.sessionAbsoluteHours'] = dto.sessionAbsoluteHours
|
||||
if (dto.allowedCountries !== undefined) {
|
||||
set['securityPolicy.allowedCountries'] = dto.allowedCountries.map((c) => c.toUpperCase())
|
||||
}
|
||||
if (dto.ipAllowlist !== undefined) set['securityPolicy.ipAllowlist'] = dto.ipAllowlist
|
||||
|
||||
const tenant = await this.tenantModel
|
||||
.findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
|
||||
.exec()
|
||||
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.security_policy_updated',
|
||||
resourceType: 'tenant',
|
||||
resourceId: String(tenant._id),
|
||||
resourceName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { fields: Object.keys(set) },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return tenant
|
||||
}
|
||||
|
||||
// Live MFA-enrollment overview for the workspace. Reads each member's
|
||||
// Authentik authenticator count (TOTP/WebAuthn/static); a count > 0 = enrolled.
|
||||
// Best-effort per user — a failed Authentik lookup counts as not-enrolled
|
||||
// rather than failing the whole call.
|
||||
async mfaStatusForTenant(slug: string): Promise<{
|
||||
total: number
|
||||
enrolled: number
|
||||
members: Array<{ id: string; name: string; email: string; role: string; enrolled: boolean }>
|
||||
}> {
|
||||
const tenant = await this.findOneBySlug(slug)
|
||||
const users = await this.userModel
|
||||
.find({ tenantIds: tenant._id, active: { $ne: false } })
|
||||
.sort({ name: 1 })
|
||||
.exec()
|
||||
|
||||
const members = await Promise.all(
|
||||
users.map(async (u) => {
|
||||
let enrolled = false
|
||||
if (u.authentikUserPk != null) {
|
||||
try {
|
||||
enrolled = (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
|
||||
} catch {
|
||||
enrolled = false
|
||||
}
|
||||
}
|
||||
return { id: String(u._id), name: u.name, email: u.email, role: u.role, enrolled }
|
||||
}),
|
||||
)
|
||||
return {
|
||||
total: members.length,
|
||||
enrolled: members.filter((m) => m.enrolled).length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
|
||||
const result = await this.tenantModel
|
||||
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
|
||||
|
||||
@@ -59,12 +59,20 @@ export class UsersService {
|
||||
if (exists) throw new ConflictException(`User ${dto.authentikSubjectId} already exists`)
|
||||
|
||||
const tenantIds = await this.resolveTenantIds(dto.tenantSlugs ?? [])
|
||||
const role = dto.role ?? 'member'
|
||||
// Seed the per-tenant role for every tenant this user is created into, so
|
||||
// their effective role is explicit from the start rather than relying on
|
||||
// the global-role fallback. Omitted when there are no tenants.
|
||||
const tenantRoles = tenantIds.length
|
||||
? Object.fromEntries(tenantIds.map((id) => [String(id), role]))
|
||||
: undefined
|
||||
return this.userModel.create({
|
||||
authentikSubjectId: dto.authentikSubjectId,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
role: dto.role ?? 'member',
|
||||
role,
|
||||
tenantIds,
|
||||
tenantRoles,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -645,7 +653,11 @@ export class UsersService {
|
||||
.findOneAndUpdate(
|
||||
{ authentikSubjectId: existing.uid },
|
||||
{
|
||||
$set: { email: existing.email },
|
||||
// tenantRoles via $set (not $setOnInsert) so an EXISTING user
|
||||
// invited as admin to this tenant actually becomes admin here,
|
||||
// without disturbing their role in other tenants. The global
|
||||
// `role` stays $setOnInsert as the legacy/first-tenant fallback.
|
||||
$set: { email: existing.email, [`tenantRoles.${tenant._id}`]: 'admin' },
|
||||
$setOnInsert: {
|
||||
name: existing.name || dto.name,
|
||||
role: 'admin',
|
||||
@@ -688,7 +700,8 @@ export class UsersService {
|
||||
.findOneAndUpdate(
|
||||
{ authentikSubjectId: created.uid },
|
||||
{
|
||||
$set: { email: dto.email, name: dto.name },
|
||||
// Per-tenant admin role via $set (see attach branch above).
|
||||
$set: { email: dto.email, name: dto.name, [`tenantRoles.${tenant._id}`]: 'admin' },
|
||||
$setOnInsert: { role: 'admin', active: true, platformAdmin: false },
|
||||
$addToSet: { tenantIds: tenant._id },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user