559348f6bc
Security & audit (admin) - Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with q/action/outcome/actorEmail/since/before; UI gains search, outcome + time filters, action chips, cursor pagination, and client-side CSV export. - Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute, allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy (membership-gated, audited). Editable, labelled by enforcement status. - MFA: live enrollment overview via GET /tenants/:slug/mfa-status (Authentik countAuthenticators per member). - SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD, scoped to the tenant group. New AuthentikClient methods (provider/app/binding + flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback on partial failure; client secret never stored), GET/POST/DELETE /tenants/:slug/sso-apps. Validated end-to-end against live Authentik. - Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast radius) — to be done as its own reviewed change. Bundled in-progress work that shares the same files (kept together so the tree stays green): - Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed), storage.get proxy, storage.vue. - Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
107 lines
4.0 KiB
TypeScript
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,
|
|
}
|
|
}
|