Files
Ronni Baslund 559348f6bc feat(portal): real Security & audit page (+ bundled Storage / per-tenant-roles WIP)
Security & audit (admin)
- Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with
  q/action/outcome/actorEmail/since/before; UI gains search, outcome + time
  filters, action chips, cursor pagination, and client-side CSV export.
- Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute,
  allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy
  (membership-gated, audited). Editable, labelled by enforcement status.
- MFA: live enrollment overview via GET /tenants/:slug/mfa-status
  (Authentik countAuthenticators per member).
- SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD,
  scoped to the tenant group. New AuthentikClient methods (provider/app/binding
  + flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback
  on partial failure; client secret never stored), GET/POST/DELETE
  /tenants/:slug/sso-apps. Validated end-to-end against live Authentik.
- Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast
  radius) — to be done as its own reviewed change.

Bundled in-progress work that shares the same files (kept together so the tree
stays green):
- Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed),
  storage.get proxy, storage.vue.
- Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
2026-05-31 17:20:36 +02:00

107 lines
4.0 KiB
TypeScript

// Cached fetch of the signed-in user's profile from platform-api (proxied
// via /api/me on the portal server). One state slot per render so all
// callers share the same payload — middleware fetches once, pages read
// from cache, no per-component re-fetch.
interface MeProfile {
_id: string
authentikSubjectId: string
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[]
partnerId?: string
partner?: { _id: string; slug: string; name: string; status: string }
lastLoginAt?: string
}
import type { TenantDoc, SubscriptionDoc } from '~/types/workspace'
interface MeResponse {
profile: MeProfile
tenants: TenantDoc[]
subscriptions: SubscriptionDoc[]
}
export function useMe() {
const state = useState<MeResponse | null>('portal-me', () => null)
async function fetchMe(force = false): Promise<MeResponse | null> {
if (state.value && !force) return state.value
// useRequestFetch on SSR forwards the incoming request's headers
// (including the nuxt-oidc-auth session cookie) when calling the
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
// would 401, the middleware would skip the redirect, and the end-user
// page would flash before client-side rehydration finally redirects.
const fetcher = useRequestFetch()
try {
state.value = await fetcher<MeResponse>('/api/me')
} catch (err) {
// If the access token lapsed, the proxy forwards a stale token and
// platform-api 401s. On the client we can refresh the session silently
// and retry once (same self-heal as useApiFetch for writes). On the
// server we can't trigger the OIDC refresh, so fall through to null and
// let the auth middleware bounce to sign-in.
const status = (err as { statusCode?: number; response?: { status?: number } })
const is401 = status?.statusCode === 401 || status?.response?.status === 401
if (import.meta.client && is401) {
try {
await $fetch('/api/_auth/refresh', { method: 'POST', headers: { Accept: 'text/json' } })
state.value = await fetcher<MeResponse>('/api/me')
return state.value
} catch {
state.value = null
}
} else {
state.value = null
}
}
return state.value
}
const profile = computed<MeProfile | null>(() => state.value?.profile ?? null)
const partner = computed(() => profile.value?.partner ?? null)
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
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,
}
}