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.
This commit is contained in:
@@ -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')
|
||||||
|
// <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)
|
||||||
|
}
|
||||||
@@ -75,6 +75,9 @@ export default defineNuxtConfig({
|
|||||||
protocol: 'wss',
|
protocol: 'wss',
|
||||||
clientPort: 443,
|
clientPort: 443,
|
||||||
},
|
},
|
||||||
|
// Vite 7's strict allowedHosts blocks anything not in this list with a
|
||||||
|
// plaintext 403. We serve the portal behind Traefik on app.dezky.local.
|
||||||
|
allowedHosts: ['app.dezky.local'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Forwards to platform-api's POST /flags/evaluate. The platform-api endpoint
|
||||||
|
// requires an explicit tenantSlug; we derive it from the JWT here so portal
|
||||||
|
// consumers don't have to know which tenant they're "in" — the answer is
|
||||||
|
// always the first non-admin group on their token.
|
||||||
|
//
|
||||||
|
// Caller can override by passing { tenantSlug } in the body, but in practice
|
||||||
|
// the portal serves end users with a single tenant in dev.
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
// Group names that belong to platform-wide admin rather than a single tenant.
|
||||||
|
// Kept here (not env-driven) because the value is fixed by the Authentik
|
||||||
|
// bootstrap script — see services/platform-api/src/users/users.controller.ts
|
||||||
|
// where the same name is used as the admin bootstrap group.
|
||||||
|
const ADMIN_GROUPS = new Set(['dezky-platform-admins', 'authentik Admins'])
|
||||||
|
|
||||||
|
function decodeJwtClaims(token: string): Record<string, unknown> {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length < 2) throw new Error('Not a JWT')
|
||||||
|
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
|
||||||
|
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
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' })
|
||||||
|
|
||||||
|
// Allow explicit override; otherwise derive from the JWT.
|
||||||
|
const body = (await readBody(event).catch(() => null)) as { tenantSlug?: string } | null
|
||||||
|
let tenantSlug = body?.tenantSlug
|
||||||
|
if (!tenantSlug) {
|
||||||
|
const claims = decodeJwtClaims(accessToken)
|
||||||
|
const groups = (claims.groups as string[] | undefined) ?? []
|
||||||
|
// Authentik double-lists each group via policy bindings; dedupe + filter.
|
||||||
|
const tenantGroups = Array.from(new Set(groups)).filter((g) => !ADMIN_GROUPS.has(g))
|
||||||
|
tenantSlug = tenantGroups[0]
|
||||||
|
}
|
||||||
|
if (!tenantSlug) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'No tenant slug available for this user' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
return $fetch(`${base}/flags/evaluate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
body: { tenantSlug },
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user