From aa67ce240ee1a607ca49c2a7d6ecd2b739ac6991 Mon Sep 17 00:00:00 2001 From: SI-Tanbir Date: Sat, 24 Oct 2026 17:53:05 +0600 Subject: [PATCH] fix(webapp): read valid stored org flags key-by-key in mergeOrgFeatureFlags --- .../components/admin/FeatureFlagsDialog.tsx | 9 +++++- apps/webapp/app/v3/featureFlags.server.ts | 7 ++--- apps/webapp/test/storedOrgFlags.test.ts | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 7e2a145f6df..2af59b4d9ce 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -17,6 +17,7 @@ import { FEATURE_FLAG, ORG_LOCKED_FLAG_EFFECT, ORG_LOCKED_FLAGS, + derivedFlagsClearedWith, isUnchangedInvalidFlag, type FeatureFlagKey, type FlagControlType, @@ -96,12 +97,15 @@ export function FeatureFlagsDialog({ const [saveError, setSaveError] = useState(null); const [unlocked, setUnlocked] = useState(false); - const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key); + const isLocked = (key: string) => + ORG_LOCKED_FLAG_EFFECT[key as FeatureFlagKey] === "derived" || + (!unlocked && ORG_LOCKED_FLAGS.includes(key as FeatureFlagKey)); useEffect(() => { if (open && orgId) { // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setSaveError(null); + setUnlocked(false); setOverrides({}); setInitialOverrides({}); loadFeatureFlags(`/admin/api/v2/orgs/${orgId}/feature-flags`); @@ -148,6 +152,9 @@ export function FeatureFlagsDialog({ setOverrides((prev) => { const next = { ...prev }; delete next[key]; + for (const derived of derivedFlagsClearedWith(key)) { + delete next[derived]; + } return next; }); }; diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index a519a5ed356..7ebda15a52a 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -7,7 +7,7 @@ import { FeatureFlagCatalog, GLOBAL_LOCKED_FLAGS, GRACED_FLAG_GROUPS, - validatePartialFeatureFlags, + parseStoredOrgFlags, } from "~/v3/featureFlags"; import { env } from "~/env.server"; import { isAlertsEnabledEnv } from "~/v3/canUseAlertsList"; @@ -400,10 +400,7 @@ export function mergeOrgFeatureFlags( globalFlags: Partial, orgFeatureFlags: unknown ) { - const parsed = orgFeatureFlags - ? validatePartialFeatureFlags(orgFeatureFlags as Record) - : ({ success: false } as const); - return { ...globalFlags, ...(parsed.success ? parsed.data : {}) }; + return { ...globalFlags, ...parseStoredOrgFlags(orgFeatureFlags).valid }; } /** diff --git a/apps/webapp/test/storedOrgFlags.test.ts b/apps/webapp/test/storedOrgFlags.test.ts index a3483e38b02..9657fd6cf65 100644 --- a/apps/webapp/test/storedOrgFlags.test.ts +++ b/apps/webapp/test/storedOrgFlags.test.ts @@ -7,6 +7,7 @@ import { parseStoredOrgFlags, validatePartialFeatureFlags, } from "~/v3/featureFlags"; +import { mergeOrgFeatureFlags } from "~/v3/featureFlags.server"; describe("parseStoredOrgFlags", () => { it("matches validatePartialFeatureFlags for an all-valid blob", () => { @@ -64,6 +65,33 @@ describe("parseStoredOrgFlags", () => { }); }); +describe("mergeOrgFeatureFlags", () => { + it("applies valid org overrides while falling back to global for invalid and unknown keys", () => { + const globalFlags = { + [FEATURE_FLAG.hasLogsPageAccess]: false, + [FEATURE_FLAG.realtimeBackend]: "electric" as const, + [FEATURE_FLAG.hasQueryAccess]: false, + }; + + const merged = mergeOrgFeatureFlags(globalFlags, { + [FEATURE_FLAG.hasLogsPageAccess]: true, + [FEATURE_FLAG.realtimeBackend]: "not-a-backend", + someNewerFlag: true, + }); + + expect(merged).toEqual({ + [FEATURE_FLAG.hasLogsPageAccess]: true, + [FEATURE_FLAG.realtimeBackend]: "electric", + [FEATURE_FLAG.hasQueryAccess]: false, + }); + }); + + it.each([null, undefined, [], "invalid"])("returns global flags when org flags are %j", (raw) => { + const globalFlags = { [FEATURE_FLAG.hasLogsPageAccess]: true }; + expect(mergeOrgFeatureFlags(globalFlags, raw)).toEqual(globalFlags); + }); +}); + describe("ORG_LOCKED_FLAG_EFFECT", () => { it("classifies every org-locked flag and nothing else", () => { expect(Object.keys(ORG_LOCKED_FLAG_EFFECT).sort()).toEqual([...ORG_LOCKED_FLAGS].sort());