From 7f8516295c5f99097d2e5322bf515a1791a70e13 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 19:26:55 +0200 Subject: [PATCH] feat(portal): useFeatureFlag composable + /api/flags/evaluate proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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. --- apps/portal/composables/useFeatureFlag.ts | 75 +++++++++++++++++++ apps/portal/nuxt.config.ts | 3 + apps/portal/server/api/flags/evaluate.post.ts | 50 +++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 apps/portal/composables/useFeatureFlag.ts create mode 100644 apps/portal/server/api/flags/evaluate.post.ts diff --git a/apps/portal/composables/useFeatureFlag.ts b/apps/portal/composables/useFeatureFlag.ts new file mode 100644 index 0000000..248b010 --- /dev/null +++ b/apps/portal/composables/useFeatureFlag.ts @@ -0,0 +1,75 @@ +// 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') +// +// +// Multi-key: +// const { flags, ready, refresh } = useFeatureFlags() +// ready → ref 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 +// `