Files
Ronni Baslund 7f8516295c feat(portal): useFeatureFlag composable + /api/flags/evaluate proxy
Client-side helper for the portal to consume feature flags. Hits platform-api
through a new portal-side proxy that derives the tenant slug from the
signed-in user's JWT groups — so callers don't pass a slug, they just check
`useFeatureFlag('key')`.

apps/portal/server/api/flags/evaluate.post.ts:
- Reads access token from the nuxt-oidc-auth session
- Decodes the JWT and picks the first non-admin group as the tenant slug
  (admin groups: dezky-platform-admins, "authentik Admins"). Filters
  duplicates Authentik double-lists via policy bindings.
- Forwards { tenantSlug } to platform-api POST /flags/evaluate
- Caller can still pass an explicit tenantSlug in the request body to
  override the auto-derivation (rare).

apps/portal/composables/useFeatureFlag.ts:
- Singleton module-level state shared across every component — one bulk
  eval per session, not one per flag check
- `useFeatureFlag(key)` → ComputedRef<boolean>, lazily triggers the first
  eval, fail-closed (every flag stays false on error)
- `useFeatureFlags()` → { flags, ready, pending, refresh } for the rare
  case where you need the full map or want to re-evaluate (long-lived
  session, admin flipped a flag mid-flight)
- Returns refs that update once the bulk eval lands; gated UI stays
  hidden during the ~25ms round trip

apps/portal/nuxt.config.ts:
- Vite 7 `server.allowedHosts` set to ['app.dezky.local'] — same fix we
  already shipped on the operator side; without it, the proxy returned a
  plaintext 403 "Blocked request" instead of forwarding.

Verified end-to-end: signed in to app.dezky.local, hit /api/flags/evaluate
with no body → 200 with the full truth map (same shape as the operator's
direct eval), latency ~25ms, explicit-slug override returns identical
results.
2026-05-24 19:26:55 +02:00

76 lines
2.5 KiB
TypeScript

// Client-side feature-flag helper. One bulk eval per session, shared across
// every component via module-level state. Hits POST /api/flags/evaluate —
// the portal-side proxy derives the tenant from the signed-in user's JWT
// so callers don't pass a slug.
//
// Usage:
// const enabled = useFeatureFlag('new_inbox_view')
// <NewInbox v-if="enabled" />
//
// Multi-key:
// const { flags, ready, refresh } = useFeatureFlags()
// ready → ref<boolean> true once the first eval has resolved
// flags → ref<{ key: boolean }>
// refresh() → re-fetch (e.g. after an admin flipped a flag while the user is
// online; rare, mostly useful in long-lived sessions)
//
// The composable is intentionally NOT a Promise-returning function. It
// returns refs that update once the bulk eval lands; consumers can
// `<template v-if="enabled">` and the panel appears when the answer comes in.
// Flags evaluated before the first response are reactive `false`, so a
// gated feature stays hidden during the (typically <50ms) round-trip.
type FlagMap = Record<string, boolean>
// Singleton state shared by every caller in the app.
const flags = ref<FlagMap>({})
const ready = ref(false)
const pending = ref(false)
let inflight: Promise<FlagMap> | null = null
async function load(): Promise<FlagMap> {
if (inflight) return inflight
pending.value = true
inflight = $fetch<FlagMap>('/api/flags/evaluate', { method: 'POST' })
.then((map) => {
flags.value = map ?? {}
ready.value = true
return flags.value
})
.catch((err) => {
// Eval failures shouldn't break the app — every flag stays `false`
// (fail-closed). Surface to the console for debugging.
// eslint-disable-next-line no-console
console.warn('[useFeatureFlag] eval failed', err)
ready.value = true
return flags.value
})
.finally(() => {
pending.value = false
inflight = null
})
return inflight
}
export const useFeatureFlags = () => {
// Kick off the first load lazily. Safe to call from setup — `$fetch` will
// resolve client-side; on SSR we just don't have a session yet anyway.
if (import.meta.client && !ready.value && !pending.value) {
void load()
}
return {
flags,
ready,
pending,
refresh: load,
}
}
export const useFeatureFlag = (key: string): ComputedRef<boolean> => {
// Touch the loader so single-key consumers don't have to import useFeatureFlags.
if (import.meta.client && !ready.value && !pending.value) {
void load()
}
return computed(() => flags.value[key] === true)
}