Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+103
View File
@@ -0,0 +1,103 @@
import { useMemo, type ReactElement, type ReactNode } from 'react';
import type { FeatureKey } from '../constants/featureKeys';
import { featureService, isFeatureEnabled } from './featureService';
import { useFeatureVersion } from './useFeature';
type GateMode = 'all' | 'any';
/**
* Resolves a set of keys against the current config, re-evaluating whenever a
* source layer changes. An empty key list resolves to `false` (fail closed).
*/
const useGateResult = (keys: readonly FeatureKey[], mode: GateMode): boolean => {
const version = useFeatureVersion();
const signature = keys.join('|');
return useMemo(() => {
if (keys.length === 0) {
return false;
}
return mode === 'any'
? keys.some((key) => isFeatureEnabled(key))
: keys.every((key) => isFeatureEnabled(key));
// `signature` stands in for `keys` so an inline array literal does not thrash the memo.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature, mode, version]);
};
type FeatureGateProps = {
/** Feature(s) guarding this subtree. */
feature: FeatureKey | readonly FeatureKey[];
/**
* How to combine multiple keys. `all` (default) requires every key; `any`
* requires at least one. Irrelevant for a single key.
*/
mode?: GateMode;
children: ReactNode;
};
/**
* Renders its children only while the feature is enabled, and renders **nothing**
* otherwise.
*
* <FeatureGate feature={FeatureKey.PAYMENTS_BENEFICIARIES_ADD}>
* <AddBeneficiaryButton />
* </FeatureGate>
*
* There is deliberately no `fallback` / placeholder prop. A disabled feature must
* leave no trace in the UI — no greyed-out control, no "coming soon" copy, no
* hidden-but-present node — so the only two outcomes are the children or `null`.
*
* This hides UI; it does not neuter a handler some other path still holds. Guard
* the handler with `useFeatureAction` / `guardFeature`, and the screen with
* `FeatureRoute`.
*/
export const FeatureGate = ({
feature,
mode = 'all',
children,
}: FeatureGateProps): ReactElement | null => {
const keys = useMemo(
() => (Array.isArray(feature) ? (feature as FeatureKey[]) : [feature as FeatureKey]),
[feature],
);
return useGateResult(keys, mode) ? <>{children}</> : null;
};
type DynamicFeatureGateProps = {
/**
* Feature reference as a raw string — from JSON-driven UI or a remote payload.
* An unknown string hides the subtree; omitting the prop leaves it visible, so
* untagged components render exactly as before.
*/
feature?: string;
children: ReactNode;
};
/**
* String-keyed gate for JSON-driven UI, where the feature name arrives as data
* rather than as a compile-time constant:
*
* { "type": "button", "label": "Create User", "feature": "agent.customers.create" }
*
* Prefer `FeatureGate` with a `FeatureKey` wherever the key is known at compile
* time; this exists only for genuinely dynamic content.
*/
export const DynamicFeatureGate = ({
feature,
children,
}: DynamicFeatureGateProps): ReactElement | null => {
const version = useFeatureVersion();
const enabled = useMemo(
() => feature === undefined || featureService.isEnabledKey(feature),
// `version` is the signal that the service's mutable state changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
[feature, version],
);
return enabled ? <>{children}</> : null;
};
+159
View File
@@ -0,0 +1,159 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react';
import type { FeatureKey } from '../constants/featureKeys';
import { featureService, isFeatureEnabled } from './featureService';
import { FeatureSource, type FeatureOverrides } from './types';
import { useFeatureVersion } from './useFeature';
type FeatureContextValue = {
/** Imperative check, for callbacks that should not subscribe to re-renders. */
isEnabled: (key: FeatureKey) => boolean;
/** False until remote/tenant sources have been resolved (or skipped). */
ready: boolean;
/** Bumps whenever any source layer changes. */
version: number;
};
const FeatureContext = createContext<FeatureContextValue | undefined>(undefined);
type FeatureProviderProps = PropsWithChildren<{
/**
* Loads the remote layer (Firebase Remote Config, config API, …). Resolving to
* `undefined` leaves the layer untouched. Failures are swallowed so the app
* always falls back to the compiled-in defaults rather than blocking startup.
*/
loadRemoteOverrides?: () => Promise<FeatureOverrides | undefined>;
/** Loads per-tenant/role overrides once the session is known. */
loadTenantOverrides?: () => Promise<FeatureOverrides | undefined>;
/**
* Applies configuration that the one-loader-per-layer slots above cannot
* express, and runs *before* them.
*
* Two cases need it. The CBS config endpoint returns the org-wide and
* role-scoped switches in a single response, so one fetch has to write two
* layers; and the persisted local overrides have to be read back into the user
* layer, which is not a network load at all. Both own their own layer writes,
* so this returns nothing.
*
* Failures are swallowed here too — a feature layer that cannot be loaded must
* degrade to the layers beneath it, never block the app from starting.
*/
hydrate?: () => Promise<void>;
}>;
/**
* Wires the feature service into the React tree.
*
* The hooks work without this provider — they subscribe to the service directly
* — so mounting it is only required for two things: resolving the async source
* layers at startup, and exposing `ready` for callers that want to hold UI back
* until remote config has landed.
*
* In development it also validates the static config and logs any key that is
* declared without config, or whose dot path implies a parent that does not
* exist (which would silently make it un-gateable by that parent).
*/
export const FeatureProvider = ({
children,
loadRemoteOverrides,
loadTenantOverrides,
hydrate,
}: FeatureProviderProps) => {
const version = useFeatureVersion();
const [ready, setReady] = useState(!loadRemoteOverrides && !loadTenantOverrides && !hydrate);
useEffect(() => {
if (!__DEV__) {
return;
}
const issues = featureService.validate();
if (issues.length > 0) {
console.warn(
`[features] ${issues.length} configuration issue(s):\n` +
issues.map((issue) => ` • [${issue.problem}] ${issue.detail}`).join('\n'),
);
}
}, []);
useEffect(() => {
let cancelled = false;
const run = async () => {
if (hydrate) {
try {
await hydrate();
} catch (error) {
if (__DEV__) {
console.warn('[features] Hydration failed; using defaults.', error);
}
}
}
const layers: Array<[FeatureSource, (() => Promise<FeatureOverrides | undefined>) | undefined]> =
[
[FeatureSource.Remote, loadRemoteOverrides],
[FeatureSource.Tenant, loadTenantOverrides],
];
for (const [source, loader] of layers) {
if (!loader) {
continue;
}
try {
const overrides = await loader();
if (!cancelled && overrides) {
featureService.setSource(source, overrides);
}
} catch (error) {
if (__DEV__) {
console.warn(`[features] Failed to load "${source}" overrides; using defaults.`, error);
}
}
}
if (!cancelled) {
setReady(true);
}
};
// `run` swallows its own failures, so there is nothing to catch here.
run();
return () => {
cancelled = true;
};
}, [loadRemoteOverrides, loadTenantOverrides, hydrate]);
const value = useMemo<FeatureContextValue>(
() => ({ isEnabled: isFeatureEnabled, ready, version }),
[ready, version],
);
return <FeatureContext.Provider value={value}>{children}</FeatureContext.Provider>;
};
/**
* Access to the feature context. Throws when used outside the provider, which
* catches wiring mistakes early — most components should use `useFeature`
* instead, which needs no provider.
*/
export const useFeatureContext = (): FeatureContextValue => {
const context = useContext(FeatureContext);
if (!context) {
throw new Error('useFeatureContext must be used within a <FeatureProvider>.');
}
return context;
};
+65
View File
@@ -0,0 +1,65 @@
import { FeatureKey } from '../constants/featureKeys';
/**
* The features the shipped admin switchboard may touch.
*
* The dev screen lists all 182 keys; this list is deliberately much shorter,
* because the two screens answer different questions. The dev screen is for
* "does gating work at all", and every key belongs there. This one is for a
* member of staff shutting a product down in the field, so it holds only the
* switches that make sense as an operational decision — product areas and the
* transactional actions inside them.
*
* Two kinds of key are kept off it on purpose:
*
* - **Anything under `auth`.** Turning off sign-in or registration from inside
* the app locks the handset out of the app, including out of this screen. That
* belongs to remote config, where it can be undone from the server.
* - **`profile.featureFlags` itself**, for the same reason: a switchboard that
* can switch itself off is a one-way door.
*
* Every entry is a real product decision, and each is grouped as it should
* appear in the UI.
*/
export const ADMIN_TOGGLEABLE_FEATURES: ReadonlyArray<{
title: string;
keys: readonly FeatureKey[];
}> = [
{
title: 'Payments',
keys: [
FeatureKey.PAYMENTS,
FeatureKey.PAYMENTS_TRANSFER,
FeatureKey.PAYMENTS_BENEFICIARIES_ADD,
],
},
{
title: 'Cards',
keys: [FeatureKey.CARDS, FeatureKey.CARDS_CONTROLS_FREEZE, FeatureKey.CARDS_APPLY_CREDIT],
},
{
title: 'Lending & cover',
keys: [FeatureKey.LOANS, FeatureKey.LOANS_APPLY, FeatureKey.INSURANCE, FeatureKey.INSURANCE_BUY],
},
{
title: 'Deposits & services',
keys: [
FeatureKey.SERVICES,
FeatureKey.SERVICES_DEPOSITS,
FeatureKey.SERVICES_DEPOSITS_OPEN_FD,
FeatureKey.SERVICES_DEPOSITS_OPEN_RD,
],
},
{
title: 'Home & discovery',
keys: [
FeatureKey.HOME_ACCOUNTS_MMID,
FeatureKey.SEARCH_GLOBAL,
FeatureKey.NOTIFICATIONS_LIST,
],
},
{
title: 'Field agent app',
keys: [FeatureKey.AGENT, FeatureKey.AGENT_COLLECTIONS],
},
];
+291
View File
@@ -0,0 +1,291 @@
import { FeatureKey } from '../constants/featureKeys';
import type { FeatureDefinition, FeatureOverrides } from './types';
/**
* Default feature configuration — the single source of truth compiled into the
* bundle.
*
* This is a flat `Record<FeatureKey, …>` rather than a nested object on purpose:
* the `Record` makes TypeScript reject the file if any declared key is missing
* config, which a nested object literal cannot guarantee. The hierarchy is not
* lost — it lives in the dot-notation keys themselves (`services.cheque.hub` is
* a child of `services.cheque`), and the service derives parents from that path.
*
* Rules:
* - Disabling a parent disables every descendant, whatever their own flag says.
* e.g. `services.cheque = false` hides the cheque hub, book request, stop
* payment, tracking and positive pay in one edit.
* - Entries are grouped and indented to read like the tree they represent.
*/
export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
// ---------------------------------------------------------------- Auth ----
[FeatureKey.AUTH]: { enabled: true, description: 'Authentication module' },
[FeatureKey.AUTH_LOGIN]: { enabled: true },
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: { enabled: true, description: 'Agent login tab' },
[FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true },
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
[FeatureKey.AUTH_REGISTER]: {
enabled: true,
description: 'Device registration: lookup, OTP, MPIN setup',
},
[FeatureKey.AUTH_LOGIN_CREATE_ACCOUNT]: { enabled: true },
[FeatureKey.AUTH_LOGOUT]: { enabled: true },
// ---------------------------------------------------------------- Home ----
[FeatureKey.HOME]: { enabled: true, description: 'Home tab' },
[FeatureKey.HOME_ACCOUNTS]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_CAROUSEL]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_MINI_STATEMENT]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_DETAILS]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_EPASSBOOK]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_REKYC]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_VIDEO_KYC]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_MMID]: {
enabled: true,
description: 'Accounts and MMID, for receiving money by IMPS',
},
[FeatureKey.HOME_QUICK_ACTIONS]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_TRANSFER]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_BILL_PAY]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_AIRTIME]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_LOANS]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_QUICK_ACTIONS_STATS]: { enabled: true },
[FeatureKey.HOME_SERVICES]: { enabled: true },
[FeatureKey.HOME_SERVICES_TRANSFER]: { enabled: true },
[FeatureKey.HOME_SERVICES_SELF_TRANSFER]: { enabled: true },
[FeatureKey.HOME_SERVICES_BILL_PAY]: { enabled: true },
[FeatureKey.HOME_SERVICES_COLLECT_REQUEST]: { enabled: true },
[FeatureKey.HOME_SERVICES_MANAGE_PAYEE]: { enabled: true },
[FeatureKey.HOME_SERVICES_SUPPORT]: { enabled: true },
[FeatureKey.HOME_SERVICES_INSURANCE]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_SERVICES_DEPOSITS]: { enabled: true },
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_INSIGHTS]: { enabled: true },
[FeatureKey.HOME_PROMO_BANNER]: { enabled: true },
// -------------------------------------------------------------- Search ----
[FeatureKey.SEARCH]: { enabled: true },
[FeatureKey.SEARCH_GLOBAL]: { enabled: true },
// ------------------------------------------------------- Notifications ----
[FeatureKey.NOTIFICATIONS]: { enabled: true },
[FeatureKey.NOTIFICATIONS_LIST]: { enabled: true },
[FeatureKey.NOTIFICATIONS_MARK_READ]: { enabled: true },
// ------------------------------------------------------------ Payments ----
[FeatureKey.PAYMENTS]: { enabled: true, description: 'Payments tab' },
[FeatureKey.PAYMENTS_HUB]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES_LIST]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES_ADD]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES_DELETE]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_IMPS]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_NEFT]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
[FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
[FeatureKey.PAYMENTS_TRANSFER_INTERNAL]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_SUBMIT]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_MOBILE]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_ACCOUNT]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_OWN]: { enabled: true },
[FeatureKey.PAYMENTS_MMID]: {
enabled: true,
description: 'The MMIDs on this profile, for receiving over IMPS',
},
[FeatureKey.PAYMENTS_MMID_CANCEL]: { enabled: true },
[FeatureKey.PAYMENTS_MMID_GENERATE]: { enabled: true },
[FeatureKey.PAYMENTS_UPI]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: false, reason: 'Not in the target screen set' },
// --------------------------------------------------------------- Cards ----
[FeatureKey.CARDS]: { enabled: false, description: 'Cards tab', reason: 'Not in the target screen set' },
[FeatureKey.CARDS_OVERVIEW]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_TRANSACTIONS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_ATM]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_APPLY_CREDIT]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: false, reason: 'Not in the target screen set' },
// ------------------------------------------------------------ Services ----
[FeatureKey.SERVICES]: { enabled: true, description: 'Services / hub tab' },
[FeatureKey.SERVICES_HUB]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_OVERVIEW]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_DETAILS]: { enabled: true },
// A customer may look at the deposits they hold, but cannot start a new
// one from the app. Both the route and every control that reaches it are
// gated on these, so switching them on is all it takes to offer it.
[FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
[FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
[FeatureKey.SERVICES_DEPOSITS_CALCULATOR]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_HUB]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_TRACKING]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_REQUESTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_STANDING_INSTRUCTIONS]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_FORM_15G15H]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_NOMINATION]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_BRANCH_LOCATOR]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS_LIST]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS_DETAIL]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_HUB]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_SCHEME_DETAIL]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_ENROLLMENT]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_DEAF]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_DEAF_CLAIM]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_DICGC]: { enabled: true },
// Not built yet — these two only raise a "coming soon" toast today, so they
// are off by default rather than advertising something that does not exist.
[FeatureKey.SERVICES_SUPPORT_CHAT]: { enabled: true },
[FeatureKey.SERVICES_HELP_CENTER]: { enabled: true },
[FeatureKey.SERVICES_BALANCE_ENQUIRY]: {
enabled: false,
reason: 'No CBS endpoint returns a balance',
},
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
// Service-desk entry points to screens the home and profile stacks already
// own. Switching one off takes the tile away, not the screen.
[FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true },
[FeatureKey.SERVICES_MPIN_CHANGE]: { enabled: true },
// --------------------------------------------------------------- Loans ----
[FeatureKey.LOANS]: { enabled: false, description: 'Loans module', reason: 'Not in the target screen set' },
[FeatureKey.LOANS_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_TYPES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_ELIGIBILITY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_APPLY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_TRACKING]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_EMI_PAYMENT]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_FORECLOSURE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_HISTORY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
// ----------------------------------------------------------- Insurance ----
[FeatureKey.INSURANCE]: { enabled: false, description: 'Insurance module', reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_BUY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_POLICIES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_RENEWALS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_CLAIMS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_COMPARISON]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: false, reason: 'Not in the target screen set' },
// ------------------------------------------------------------- Profile ----
[FeatureKey.PROFILE]: { enabled: true, description: 'Profile tab' },
[FeatureKey.PROFILE_MAIN]: { enabled: true },
[FeatureKey.PROFILE_EDIT]: { enabled: true },
[FeatureKey.PROFILE_ACCOUNTS]: { enabled: true },
[FeatureKey.PROFILE_ACCOUNTS_ADD]: { enabled: true },
[FeatureKey.PROFILE_BENEFICIARIES_SHORTCUT]: { enabled: true },
[FeatureKey.PROFILE_STATEMENTS_SHORTCUT]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS_LANGUAGE]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS_NOTIFICATIONS]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS_PRIVACY]: { enabled: true },
[FeatureKey.PROFILE_SECURITY]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_CHANGE_PIN]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_BIOMETRIC]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_TWO_FACTOR]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_DEVICES]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_LOGOUT_ALL]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_HIDE_BALANCE]: { enabled: true },
[FeatureKey.PROFILE_SUPPORT]: { enabled: true },
[FeatureKey.PROFILE_LEGAL]: { enabled: true },
[FeatureKey.PROFILE_ABOUT]: { enabled: true },
[FeatureKey.PROFILE_FEATURE_FLAGS]: {
// Off by default, on purpose. Enable it through remote config for the
// tenants whose staff need it; a switchboard shipped on by default would
// be reachable by every customer.
enabled: false,
description: 'Operational feature switchboard (admin)',
},
// --------------------------------------------------------------- Agent ----
[FeatureKey.AGENT]: { enabled: true, description: 'Field-agent application' },
[FeatureKey.AGENT_DASHBOARD]: { enabled: true },
[FeatureKey.AGENT_DASHBOARD_MAIN]: { enabled: true },
[FeatureKey.AGENT_DASHBOARD_ROUTE]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_LIST]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_DETAIL]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_CREATE]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_EDIT]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_DELETE]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_INSTALLMENT_HISTORY]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS_DAILY]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS_HISTORY]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS_RECEIPT]: { enabled: true },
[FeatureKey.AGENT_LOANS]: { enabled: true },
[FeatureKey.AGENT_LOANS_LIST]: { enabled: true },
[FeatureKey.AGENT_LOANS_CREATE_REQUEST]: { enabled: true },
[FeatureKey.AGENT_LOANS_ACCOUNT_OPENING]: { enabled: true },
[FeatureKey.AGENT_LOANS_COMMISSION]: { enabled: true },
[FeatureKey.AGENT_PROFILE]: { enabled: true },
[FeatureKey.AGENT_PROFILE_MAIN]: { enabled: true },
[FeatureKey.AGENT_PROFILE_SMS_HISTORY]: { enabled: true },
[FeatureKey.AGENT_PROFILE_CHANGE_PASSWORD]: { enabled: true },
};
/**
* Build-time / environment overrides, applied on top of the defaults.
*
* Keep this keyed off `env` (or `__DEV__`) rather than editing the defaults, so
* a environment-specific switch never leaks into production config.
*/
export const ENVIRONMENT_FEATURE_OVERRIDES: FeatureOverrides = {};
+124
View File
@@ -0,0 +1,124 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { toFeatureKey, type FeatureKey } from '../constants/featureKeys';
import { featureService } from './featureService';
import { FeatureSource, type FeatureOverrides } from './types';
const STORAGE_KEY = 'bankcbs.featureOverrides';
/**
* Device-local manual overrides — the `user` layer.
*
* Held in AsyncStorage rather than the Keychain: these are switches someone set
* on this handset, not a secret, and they must be readable before sign-in so the
* app renders the same way on a cold start as it did when they were set.
*
* They deliberately survive logout. Redux is wiped on sign-out (so no customer
* data leaks between sessions) but a tester's overrides are configuration, not
* session data, and losing them on every sign-out would make them useless.
* `featureRemoteConfig` handles the tenant layer, which *is* session-scoped.
*
* A kill from the bank still wins over anything set here — see
* `AUTHORITATIVE_FEATURE_SOURCES`.
*/
/**
* Drops anything that is not a currently declared key.
*
* Necessary because stored overrides outlive the build that wrote them: a key
* that was renamed or removed in an app update would otherwise sit in the layer
* forever, matching nothing and impossible to find in the toggle UI.
*/
const narrowToKnownKeys = (raw: unknown): FeatureOverrides => {
if (typeof raw !== 'object' || raw === null) {
return {};
}
const result: FeatureOverrides = {};
for (const [candidate, value] of Object.entries(raw as Record<string, unknown>)) {
const key = toFeatureKey(candidate);
if (key && typeof value === 'boolean') {
result[key] = value;
} else if (__DEV__) {
console.warn(`[features] Dropping stored override "${candidate}" — not a declared key.`);
}
}
return result;
};
const persist = async (overrides: FeatureOverrides) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
} catch (error) {
// A failed write must not take the toggle with it: the in-memory layer is
// already updated, so the switch works for this session and simply does not
// survive a restart.
if (__DEV__) {
console.warn('[features] Could not persist local overrides.', error);
}
}
};
export const featureOverrides = {
/**
* Loads stored overrides into the user layer. Resolves even on failure — a
* corrupt or unreadable store must fall back to the compiled-in defaults
* rather than block startup.
*/
async restore(): Promise<FeatureOverrides> {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (!stored) {
return {};
}
const overrides = narrowToKnownKeys(JSON.parse(stored));
featureService.setSource(FeatureSource.User, overrides);
return overrides;
} catch (error) {
if (__DEV__) {
console.warn('[features] Could not read local overrides; using defaults.', error);
}
return {};
}
},
/** Current local overrides. */
current(): FeatureOverrides {
return featureService.getSource(FeatureSource.User);
},
/** Pins one feature on or off locally, and persists it. */
async set(key: FeatureKey, value: boolean): Promise<void> {
featureService.patchSource(FeatureSource.User, key, value);
await persist(this.current());
},
/**
* Removes a local pin so the feature follows the layers below it again.
* Distinct from `set(key, false)` — that pins it off, this stops overriding.
*/
async clear(key: FeatureKey): Promise<void> {
featureService.patchSource(FeatureSource.User, key, undefined);
await persist(this.current());
},
/** Drops every local override. The "reset to defaults" action. */
async resetAll(): Promise<void> {
featureService.setSource(FeatureSource.User, {});
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch (error) {
if (__DEV__) {
console.warn('[features] Could not clear stored overrides.', error);
}
}
},
};
+226
View File
@@ -0,0 +1,226 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { toFeatureKey } from '../constants/featureKeys';
import { apiClient } from '../services/api/apiClient';
import { endpoints } from '../services/api/endpoints';
import { featureService } from './featureService';
import { FeatureSource, type FeatureOverrides } from './types';
import type { FeatureConfigResult } from '../types/cbs';
const CACHE_KEY = 'bankcbs.featureConfig.lastKnown';
/**
* Server-driven feature switches — the `remote` and `tenant` layers.
*
* Two things here are load-bearing rather than incidental:
*
* **The last successful payload is cached and re-applied on launch.** Without it
* a handset that starts offline would fall back to the flags compiled into the
* bundle — which is exactly how a feature the bank killed during an incident
* comes *back on* for anyone who restarts the app on a bad connection. Caching
* the instruction is what makes the kill switch survive being offline.
*
* **Unknown keys are dropped, not trusted.** The server may legitimately
* publish keys for a newer app release; this build must ignore those rather than
* store strings that match nothing. Combined with fail-closed resolution, a
* malformed payload can only ever hide features, never reveal them.
*/
interface CachedConfig {
version: number;
overrides: FeatureOverrides;
roleOverrides: FeatureOverrides;
fetchedAt: number;
}
/** Narrows a server payload to declared keys, discarding anything else. */
const narrow = (raw: Record<string, boolean> | undefined, label: string): FeatureOverrides => {
if (!raw || typeof raw !== 'object') {
return {};
}
const result: FeatureOverrides = {};
const unknown: string[] = [];
for (const [candidate, value] of Object.entries(raw)) {
const key = toFeatureKey(candidate);
if (key && typeof value === 'boolean') {
result[key] = value;
} else {
unknown.push(candidate);
}
}
if (unknown.length > 0 && __DEV__) {
console.warn(
`[features] Ignored ${unknown.length} unrecognised key(s) in ${label}: ${unknown.join(', ')}`,
);
}
return result;
};
const readCache = async (): Promise<CachedConfig | null> => {
try {
const stored = await AsyncStorage.getItem(CACHE_KEY);
if (!stored) {
return null;
}
const parsed = JSON.parse(stored) as CachedConfig;
return {
version: typeof parsed.version === 'number' ? parsed.version : 0,
// Re-narrow on read: the cache may have been written by an older build
// whose key set has since changed.
overrides: narrow(parsed.overrides as Record<string, boolean>, 'cached config'),
roleOverrides: narrow(parsed.roleOverrides as Record<string, boolean>, 'cached role config'),
fetchedAt: typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : 0,
};
} catch {
return null;
}
};
const writeCache = async (config: CachedConfig) => {
try {
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(config));
} catch (error) {
if (__DEV__) {
console.warn('[features] Could not cache feature config.', error);
}
}
};
/**
* Fixture used while `useMockServer` is on, since `/features/config` has no
* backend behind it yet.
*
* Deliberately empty. A fixture that switched features off would make mock mode
* differ from a fresh install for reasons nobody could find — to exercise a kill
* switch locally, use the dev toggle screen, or add an entry here temporarily:
*
* overrides: { 'loans.apply': false }
*/
const MOCK_CONFIG: FeatureConfigResult = {
version: 0,
overrides: {},
};
const fetchConfig = async (): Promise<FeatureConfigResult> => {
const response = await apiClient.get<FeatureConfigResult>(endpoints.features.config);
return response.data;
};
export const featureRemoteConfig = {
/**
* Applies the cached configuration, if any. Call before the first fetch so a
* cold start on a dead connection still honours the bank's last instruction
* instead of reverting to bundle defaults.
*/
async applyCached(): Promise<boolean> {
const cached = await readCache();
if (!cached) {
return false;
}
featureService.setSource(FeatureSource.Remote, cached.overrides);
featureService.setSource(FeatureSource.Tenant, cached.roleOverrides);
return true;
},
/**
* Fetches the live configuration and applies it to the remote and tenant
* layers.
*
* Returns `false` when nothing could be applied, having left the existing
* layers untouched — a failed refresh must never widen access, so the last
* known configuration stays in force rather than falling back to defaults.
*
* Safe to call unauthenticated: the response then carries no `roleOverrides`
* and the tenant layer is left as it is.
*/
async refresh({ useMock }: { useMock: boolean }): Promise<boolean> {
try {
const result = useMock ? MOCK_CONFIG : await fetchConfig();
const overrides = narrow(result.overrides, 'feature config');
const hasRoleScope = result.roleOverrides !== undefined;
const roleOverrides = narrow(result.roleOverrides, 'role feature config');
featureService.setSource(FeatureSource.Remote, overrides);
// Only touch the tenant layer when the response actually carried a role
// scope. An unauthenticated fetch omits it, and clearing the layer then
// would drop role rules that a previous authenticated fetch had applied.
if (hasRoleScope) {
featureService.setSource(FeatureSource.Tenant, roleOverrides);
}
await writeCache({
version: result.version ?? 0,
overrides,
roleOverrides: hasRoleScope
? roleOverrides
: featureService.getSource(FeatureSource.Tenant),
fetchedAt: Date.now(),
});
if (__DEV__) {
const killed = Object.entries(overrides).filter(([, on]) => !on).length;
console.log(
`[features] Applied remote config v${result.version ?? 0} — ` +
`${Object.keys(overrides).length} override(s), ${killed} disabled.`,
);
}
return true;
} catch (error) {
if (__DEV__) {
console.warn('[features] Feature config refresh failed; keeping current config.', error);
}
return false;
}
},
/**
* Clears the role-scoped layer on sign-out. The org-wide layer stays: it
* applies to the sign-in screen too, and it is not session data.
*/
clearRoleScope() {
featureService.setSource(FeatureSource.Tenant, {});
},
/**
* Forgets the signed-out customer's entitlements for good, keeping the bank's
* org-wide instruction.
*
* `clearRoleScope` only drops the in-memory layer. The cache on disk still
* held `roleOverrides`, and `applyCached` re-applies both layers on the next
* cold start — so one customer's role scope came back and sat over whoever
* launched the app next. That is the leak this closes.
*
* The org-wide half is rewritten rather than deleted, deliberately. It is the
* bank's kill switch, and it is cached precisely so a handset that starts
* offline still honours a feature the bank killed during an incident instead
* of reverting to whatever the bundle was built with. Dropping the file would
* turn a sign-out into a way to switch those features back on.
*/
async forgetRoleScope() {
featureService.setSource(FeatureSource.Tenant, {});
const cached = await readCache();
if (!cached || Object.keys(cached.roleOverrides).length === 0) {
return;
}
await writeCache({ ...cached, roleOverrides: {} });
},
};
+262
View File
@@ -0,0 +1,262 @@
import { FeatureKey, getFeatureAncestors, getParentFeatureKey, toFeatureKey } from '../constants/featureKeys';
import {
featureService,
filterEnabled,
guardFeature,
isFeatureEnabled,
isFeatureKeyEnabled,
} from './featureService';
import { FeatureSource } from './types';
describe('feature keys', () => {
it('derives the parent from the dot path', () => {
expect(getParentFeatureKey(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(
FeatureKey.SERVICES_CHEQUE,
);
expect(getParentFeatureKey(FeatureKey.SERVICES_CHEQUE)).toBe(FeatureKey.SERVICES);
});
it('returns undefined for a root key', () => {
expect(getParentFeatureKey(FeatureKey.SERVICES)).toBeUndefined();
});
it('lists ancestors nearest-first', () => {
expect(getFeatureAncestors(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toEqual([
FeatureKey.SERVICES_CHEQUE,
FeatureKey.SERVICES,
]);
});
it('narrows only strings that are declared keys', () => {
expect(toFeatureKey('services.cheque')).toBe(FeatureKey.SERVICES_CHEQUE);
expect(toFeatureKey('services.nope')).toBeUndefined();
expect(toFeatureKey(undefined)).toBeUndefined();
expect(toFeatureKey('')).toBeUndefined();
});
});
describe('featureService', () => {
afterEach(() => {
featureService.resetSources();
});
it('has a valid static configuration', () => {
expect(featureService.validate()).toEqual([]);
});
it('enables a feature whose config and ancestors are all on', () => {
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(true);
});
it('disables every descendant when a parent is turned off', () => {
featureService.setSource(FeatureSource.Remote, { [FeatureKey.SERVICES_CHEQUE]: false });
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE)).toBe(false);
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_HUB)).toBe(false);
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(false);
// Siblings under a different parent are untouched.
expect(isFeatureEnabled(FeatureKey.SERVICES_PROTECTION_DEAF)).toBe(true);
});
it('cannot re-enable a child while its parent is off', () => {
featureService.setSource(FeatureSource.Remote, {
[FeatureKey.SERVICES_CHEQUE]: false,
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: true,
});
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(false);
});
it('disables a single child without touching its parent or siblings', () => {
featureService.setSource(FeatureSource.Remote, {
[FeatureKey.AGENT_CUSTOMERS_CREATE]: false,
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
});
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS)).toBe(true);
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_LIST)).toBe(true);
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_EDIT)).toBe(true);
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_CREATE)).toBe(false);
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_DELETE)).toBe(false);
});
it('fails closed on an unknown key', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(isFeatureEnabled('totally.made.up' as FeatureKey)).toBe(false);
expect(isFeatureKeyEnabled('totally.made.up')).toBe(false);
expect(isFeatureKeyEnabled(undefined)).toBe(false);
warn.mockRestore();
});
describe('source priority', () => {
it('lets a higher-priority source win', () => {
// Nothing authoritative has an opinion here, so ordinary precedence
// applies and the user layer — the highest — decides.
featureService.setSource(FeatureSource.Environment, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true);
});
// The kill switch. A device-local override must not be able to re-enable
// something the bank has shut down, even though the user layer outranks the
// remote one for every other purpose.
it('does not let a local override lift a remote kill', () => {
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
expect(featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toMatchObject({
enabled: false,
killedBy: FeatureSource.Remote,
locallyToggleable: false,
});
});
it('does not let a local override lift a tenant kill', () => {
featureService.setSource(FeatureSource.Tenant, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
});
// The asymmetry is deliberate: switching a feature off locally is always
// safe, so an authoritative `true` stays a preference rather than a mandate.
it('still lets a local override switch off a remotely enabled feature', () => {
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
expect(featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS).locallyToggleable).toBe(true);
});
it('reports a feature held down by a disabled ancestor as not locally toggleable', () => {
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER]: false });
const resolution = featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS);
expect(resolution.enabled).toBe(false);
// Its own flag is untouched — it is the parent that is off, so flipping
// this row would achieve nothing and the UI must say so.
expect(resolution.ownFlag).toBe(true);
expect(resolution.blockedBy).toBe(FeatureKey.PAYMENTS_TRANSFER);
expect(resolution.locallyToggleable).toBe(false);
});
it('patches a single key without discarding the rest of the layer', () => {
featureService.setSource(FeatureSource.User, {
[FeatureKey.PAYMENTS_TRANSFER_IMPS]: false,
[FeatureKey.NOTIFICATIONS]: false,
});
featureService.patchSource(FeatureSource.User, FeatureKey.PAYMENTS_TRANSFER_IMPS, undefined);
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true);
// The toggle screens patch one row per tap; losing the others would make
// every switch reset the whole layer.
expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS)).toBe(false);
});
it('falls back to the next source down when a layer is cleared', () => {
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
featureService.clearSource(FeatureSource.User);
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
});
it('returns to the compiled defaults after reset', () => {
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
featureService.resetSources();
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true);
});
it('refuses to replace the default layer', () => {
expect(() => featureService.setSource(FeatureSource.Default, {})).toThrow();
});
});
it('notifies subscribers when config changes', () => {
const listener = jest.fn();
const unsubscribe = featureService.subscribe(listener);
featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: false });
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: true });
expect(listener).toHaveBeenCalledTimes(1);
});
it('invalidates cached results when a source changes', () => {
expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS_LIST)).toBe(true);
featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: false });
expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS_LIST)).toBe(false);
});
});
describe('guardFeature', () => {
afterEach(() => {
featureService.resetSources();
});
it('runs the action while the feature is enabled', () => {
const action = jest.fn(() => 'ran');
const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action);
expect(guarded()).toBe('ran');
expect(action).toHaveBeenCalledTimes(1);
});
it('does not run the action — or fire its API call — when disabled', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const action = jest.fn();
const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action);
featureService.setSource(FeatureSource.Remote, {
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
});
expect(guarded()).toBeUndefined();
expect(action).not.toHaveBeenCalled();
warn.mockRestore();
});
});
describe('filterEnabled', () => {
afterEach(() => {
featureService.resetSources();
});
const items = [
{ id: 'protection', feature: FeatureKey.SERVICES_PROTECTION_HUB },
{ id: 'cheque', feature: FeatureKey.SERVICES_CHEQUE_HUB },
{ id: 'untagged' },
];
it('keeps enabled and untagged items', () => {
expect(filterEnabled(items).map((item) => item.id)).toEqual([
'protection',
'cheque',
'untagged',
]);
});
it('drops items whose feature is disabled', () => {
featureService.setSource(FeatureSource.Remote, { [FeatureKey.SERVICES_CHEQUE]: false });
expect(filterEnabled(items).map((item) => item.id)).toEqual(['protection', 'untagged']);
});
it('drops items whose feature string is not a declared key', () => {
expect(filterEnabled([{ id: 'bogus', feature: 'not.a.key' }])).toEqual([]);
});
});
+415
View File
@@ -0,0 +1,415 @@
import {
ALL_FEATURE_KEYS,
FeatureKey,
getFeatureAncestors,
getParentFeatureKey,
toFeatureKey,
} from '../constants/featureKeys';
import { DEFAULT_FEATURE_CONFIG, ENVIRONMENT_FEATURE_OVERRIDES } from './featureConfig';
import {
AUTHORITATIVE_FEATURE_SOURCES,
FEATURE_SOURCE_PRIORITY,
FeatureSource,
type FeatureConfig,
type FeatureConfigIssue,
type FeatureOverrides,
type FeatureResolution,
} from './types';
/**
* Central feature-flag engine.
*
* Single instance for the whole app (exported as `featureService`). It owns:
* - the registry built from the static config,
* - the stack of override layers (environment → remote → tenant → user),
* - a resolution cache, invalidated whenever any layer changes,
* - a listener set so React can re-render on remote-config updates.
*
* Resolution is *fail closed*: an unknown key, or a key whose config is missing,
* resolves to `false`. In a banking app an unrecognised flag must hide the
* feature, never reveal it.
*/
class FeatureService {
private readonly registry = new Map<FeatureKey, FeatureConfig>();
private readonly layers = new Map<FeatureSource, FeatureOverrides>();
private readonly cache = new Map<FeatureKey, boolean>();
private readonly listeners = new Set<() => void>();
/** Bumped on every config change; used as the `useSyncExternalStore` snapshot. */
private version = 0;
constructor() {
this.buildRegistry();
this.layers.set(FeatureSource.Environment, { ...ENVIRONMENT_FEATURE_OVERRIDES });
}
// ------------------------------------------------------------- registry ---
private buildRegistry() {
this.registry.clear();
for (const key of ALL_FEATURE_KEYS) {
const definition = DEFAULT_FEATURE_CONFIG[key];
this.registry.set(key, {
key,
enabled: definition.enabled,
parent: getParentFeatureKey(key),
});
}
}
/**
* Verifies the configuration is internally consistent. Every declared key must
* have config, and every non-root key must have a declared parent (otherwise a
* typo like `loans.aply.submit` would silently become a root feature that no
* parent can ever switch off).
*
* Called from `FeatureProvider` in development.
*/
validate(): FeatureConfigIssue[] {
const issues: FeatureConfigIssue[] = [];
for (const key of ALL_FEATURE_KEYS) {
if (!(key in DEFAULT_FEATURE_CONFIG)) {
issues.push({
key,
problem: 'missing-config',
detail: `"${key}" is declared in FeatureKey but absent from DEFAULT_FEATURE_CONFIG.`,
});
}
const lastDot = key.lastIndexOf('.');
if (lastDot !== -1 && !toFeatureKey(key.slice(0, lastDot))) {
issues.push({
key,
problem: 'missing-parent',
detail: `"${key}" implies a parent "${key.slice(
0,
lastDot,
)}" which is not a declared FeatureKey.`,
});
}
}
for (const configured of Object.keys(DEFAULT_FEATURE_CONFIG)) {
if (!toFeatureKey(configured)) {
issues.push({
key: configured,
problem: 'unknown-key',
detail: `DEFAULT_FEATURE_CONFIG has "${configured}" which is not a FeatureKey.`,
});
}
}
return issues;
}
// ----------------------------------------------------------- resolution ---
/**
* The feature's own flag, before considering ancestors: the value from the
* highest-priority source that defines it, falling back to the static config.
*/
private resolveOwnFlag(key: FeatureKey): boolean {
return this.resolveOwnFlagWithSource(key).value;
}
/**
* The own flag plus the layer that decided it. Two phases, and the order
* between them is the security-relevant part:
*
* 1. A kill from an authoritative layer wins outright, whatever sits above it
* in the priority list. A device-local toggle must not be able to re-enable
* a feature the bank has shut down.
* 2. Otherwise the highest-priority layer that defines the key wins, falling
* back to the static config compiled into the bundle.
*/
private resolveOwnFlagWithSource(key: FeatureKey): {
value: boolean;
source: FeatureSource;
killedBy?: FeatureSource;
} {
for (const source of AUTHORITATIVE_FEATURE_SOURCES) {
if (this.layers.get(source)?.[key] === false) {
return { value: false, source, killedBy: source };
}
}
for (let i = FEATURE_SOURCE_PRIORITY.length - 1; i >= 0; i -= 1) {
const source = FEATURE_SOURCE_PRIORITY[i];
if (!source || source === FeatureSource.Default) {
continue;
}
const override = this.layers.get(source)?.[key];
if (typeof override === 'boolean') {
return { value: override, source };
}
}
return {
value: this.registry.get(key)?.enabled ?? false,
source: FeatureSource.Default,
};
}
/**
* Whether a feature is usable: it must exist, its own flag must be on, and
* every ancestor must also resolve to enabled.
*/
isEnabled(key: FeatureKey): boolean {
const cached = this.cache.get(key);
if (typeof cached === 'boolean') {
return cached;
}
let enabled: boolean;
if (!this.registry.has(key)) {
if (__DEV__) {
console.warn(
`[features] Unknown feature key "${key}". Resolving to disabled. ` +
'Add it to FeatureKey and DEFAULT_FEATURE_CONFIG.',
);
}
enabled = false;
} else if (!this.resolveOwnFlag(key)) {
enabled = false;
} else {
// Walking ancestors nearest-first lets the recursive call hit the cache.
enabled = getFeatureAncestors(key).every((ancestor) => this.isEnabled(ancestor));
}
this.cache.set(key, enabled);
return enabled;
}
/** True only when every supplied key is enabled. */
areAllEnabled(keys: readonly FeatureKey[]): boolean {
return keys.every((key) => this.isEnabled(key));
}
/** True when at least one supplied key is enabled. */
isAnyEnabled(keys: readonly FeatureKey[]): boolean {
return keys.some((key) => this.isEnabled(key));
}
/**
* Resolves an untrusted string (JSON UI, deep link, remote payload) and
* reports whether it maps to an enabled feature. Unknown strings → false.
*/
isEnabledKey(value: string | null | undefined): boolean {
const key = toFeatureKey(value);
return key ? this.isEnabled(key) : false;
}
// -------------------------------------------------------------- sources ---
/**
* Replaces one layer's overrides wholesale and notifies subscribers.
* This is the seam for Firebase Remote Config, an API-driven config service,
* or per-tenant/role rules — no UI component changes when a layer is added.
*/
setSource(source: FeatureSource, overrides: FeatureOverrides) {
if (source === FeatureSource.Default) {
throw new Error('[features] The default layer is static and cannot be replaced.');
}
this.layers.set(source, { ...overrides });
this.invalidate();
}
/**
* Sets or clears a *single* key within one layer, leaving the rest of that
* layer alone. This is what the toggle screens use: `setSource` would discard
* every other override in the layer on each tap.
*
* Passing `undefined` removes the key, so the feature falls back to whatever
* the layers below it say rather than being pinned to a value.
*/
patchSource(source: FeatureSource, key: FeatureKey, value: boolean | undefined) {
if (source === FeatureSource.Default) {
throw new Error('[features] The default layer is static and cannot be patched.');
}
const next: FeatureOverrides = { ...this.layers.get(source) };
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
this.layers.set(source, next);
this.invalidate();
}
/** One layer's overrides, for the toggle screens and for persisting them. */
getSource(source: FeatureSource): FeatureOverrides {
return { ...this.layers.get(source) };
}
/** Drops a layer entirely (e.g. on logout, clear the user/tenant layers). */
clearSource(source: FeatureSource) {
if (this.layers.delete(source)) {
this.invalidate();
}
}
/** Removes every non-default layer. Used on logout and in tests. */
resetSources() {
this.layers.clear();
this.layers.set(FeatureSource.Environment, { ...ENVIRONMENT_FEATURE_OVERRIDES });
this.invalidate();
}
private invalidate() {
this.cache.clear();
this.version += 1;
this.listeners.forEach((listener) => listener());
}
// ------------------------------------------------------------ reactivity ---
/** Subscribes to config changes. Returns an unsubscribe function. */
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
/** Monotonic version, stable between changes — safe for useSyncExternalStore. */
getVersion = (): number => this.version;
// ----------------------------------------------------------- diagnostics ---
/** Full resolved state. Development/debugging aid only. */
getSnapshot(): Record<FeatureKey, boolean> {
const snapshot = {} as Record<FeatureKey, boolean>;
for (const key of ALL_FEATURE_KEYS) {
snapshot[key] = this.isEnabled(key);
}
return snapshot;
}
/** Resolved config for one feature, including where its parent sits. */
getConfig(key: FeatureKey): FeatureConfig | undefined {
return this.registry.get(key);
}
/**
* Why one feature resolved the way it did.
*
* The toggle screens need this rather than a bare boolean: a row that is off
* because the bank killed it, and a row that is off because its parent is off,
* both look identical as a `false` — and neither can be fixed by flipping that
* row. Showing the reason is what stops someone tapping a locked toggle and
* concluding the app is broken.
*/
explain(key: FeatureKey): FeatureResolution {
const { value: ownFlag, source, killedBy } = this.resolveOwnFlagWithSource(key);
const blockedBy = ownFlag
? getFeatureAncestors(key).find((ancestor) => !this.isEnabled(ancestor))
: undefined;
return {
key,
enabled: this.isEnabled(key),
ownFlag,
decidedBy: source,
...(killedBy ? { killedBy } : {}),
...(blockedBy ? { blockedBy } : {}),
// A killed feature cannot be lifted locally, and neither can one whose
// parent is off — the ancestor has to be fixed first.
locallyToggleable: !killedBy && !blockedBy,
};
}
}
export const featureService = new FeatureService();
/**
* Canonical feature check used across the app.
*
* Automatically accounts for the feature's own flag, every ancestor, whether the
* key exists at all, and the active source layers.
*
* if (isFeatureEnabled(FeatureKey.PAYMENTS_BENEFICIARIES_ADD)) { … }
*/
export const isFeatureEnabled = (key: FeatureKey): boolean => featureService.isEnabled(key);
/** Every key enabled? */
export const areAllFeaturesEnabled = (keys: readonly FeatureKey[]): boolean =>
featureService.areAllEnabled(keys);
/** Any key enabled? */
export const isAnyFeatureEnabled = (keys: readonly FeatureKey[]): boolean =>
featureService.isAnyEnabled(keys);
/**
* Check for a feature reference that arrived as a plain string — JSON-driven UI,
* deep links, remote payloads. Unknown strings resolve to `false`.
*/
export const isFeatureKeyEnabled = (value: string | null | undefined): boolean =>
featureService.isEnabledKey(value);
/**
* Wraps an action so it cannot run while its feature is disabled. Use for any
* handler that mutates state or calls an API, so a disabled feature can never
* fire a request even if some UI path reaches it.
*
* const deleteUser = guardFeature(FeatureKey.USERS_DELETE, async (id) => { … });
*/
export function guardFeature<TArgs extends unknown[], TResult>(
key: FeatureKey,
action: (...args: TArgs) => TResult,
): (...args: TArgs) => TResult | undefined {
return (...args: TArgs) => {
if (!isFeatureEnabled(key)) {
if (__DEV__) {
console.warn(`[features] Blocked action for disabled feature "${key}".`);
}
return undefined;
}
return action(...args);
};
}
/**
* Throwing variant, for service/thunk layers where silently doing nothing would
* hide a bug. Prefer `guardFeature` in UI handlers.
*/
export function assertFeatureEnabled(key: FeatureKey): void {
if (!isFeatureEnabled(key)) {
throw new Error(`[features] Feature "${key}" is disabled.`);
}
}
/**
* Filters any list of feature-tagged items down to the enabled ones. This is the
* single helper behind gated service tiles, menu rows, search entries and
* JSON-driven component arrays.
*
* Items without a `feature` field are always kept — tagging is opt-in.
*/
export function filterEnabled<T extends { feature?: FeatureKey | string }>(
items: readonly T[],
): T[] {
return items.filter((item) => item.feature === undefined || isFeatureKeyEnabled(item.feature));
}
+29
View File
@@ -0,0 +1,29 @@
import { env } from '../config/env';
import { featureOverrides } from './featureOverrides';
import { featureRemoteConfig } from './featureRemoteConfig';
/**
* Brings every feature layer up before the app renders. Passed to
* `FeatureProvider`'s `hydrate` slot, which holds `ready` until it resolves.
*
* The order matters:
*
* 1. **Local overrides** — read from storage first. Cheap, and it means a
* tester's switches are in force even if the network never answers.
* 2. **Cached server config** — the bank's last known instruction. Applied
* before the fetch so a cold start offline still honours a kill switch
* rather than reverting to whatever the bundle was built with.
* 3. **Live fetch** — replaces the cache when it succeeds, and is a no-op when
* it does not, leaving step 2 in force.
*
* The awaits are sequential on purpose. Running them together would let the
* fetch land before the cache and then be overwritten by it.
*
* This resolves the org-wide scope only; role-scoped switches need a session and
* are refreshed at sign-in (see `useAppBootstrap`).
*/
export const hydrateFeatures = async (): Promise<void> => {
await featureOverrides.restore();
await featureRemoteConfig.applyCached();
await featureRemoteConfig.refresh({ useMock: env.useMockServer });
};
+45
View File
@@ -0,0 +1,45 @@
/**
* Centralised feature management.
*
* Import everything feature-related from here:
*
* import { FeatureGate, useFeature, isFeatureEnabled } from '../../features';
* import { FeatureKey } from '../../constants/featureKeys';
*/
export { FeatureGate, DynamicFeatureGate } from './FeatureGate';
export { FeatureProvider, useFeatureContext } from './FeatureProvider';
export {
areAllFeaturesEnabled,
assertFeatureEnabled,
featureService,
filterEnabled,
guardFeature,
isAnyFeatureEnabled,
isFeatureEnabled,
isFeatureKeyEnabled,
} from './featureService';
export {
useAllFeatures,
useAnyFeature,
useEnabledItems,
useFeature,
useFeatureAction,
useFeatures,
useFeatureVersion,
} from './useFeature';
export { DEFAULT_FEATURE_CONFIG, ENVIRONMENT_FEATURE_OVERRIDES } from './featureConfig';
export { featureOverrides } from './featureOverrides';
export { featureRemoteConfig } from './featureRemoteConfig';
export { hydrateFeatures } from './hydrateFeatures';
export { ADMIN_TOGGLEABLE_FEATURES } from './adminFeatures';
export {
AUTHORITATIVE_FEATURE_SOURCES,
FEATURE_SOURCE_PRIORITY,
FeatureSource,
type FeatureConfig,
type FeatureConfigIssue,
type FeatureDefinition,
type FeatureOverrides,
type FeatureResolution,
type FeatureTagged,
} from './types';
+117
View File
@@ -0,0 +1,117 @@
import type { FeatureKey } from '../constants/featureKeys';
/**
* How a single feature is declared in the static configuration.
*/
export type FeatureDefinition = {
/** When false the feature and everything beneath it is hidden. */
enabled: boolean;
/** Why this feature exists / what it gates. Documentation only. */
description?: string;
/**
* Why this feature ships off. Documentation only, and only meaningful
* alongside `enabled: false` — it is the note a reviewer needs to tell a
* deliberate scope decision from an accidental one.
*/
reason?: string;
};
/**
* A fully resolved feature entry as held by the registry. `parent` is derived
* from the key's dot path rather than hand-written, so the hierarchy can never
* drift from the key naming.
*/
export type FeatureConfig = {
key: FeatureKey;
enabled: boolean;
parent?: FeatureKey;
};
/**
* Configuration layers, lowest priority first. A value supplied by a later
* source overrides the same key from an earlier one:
*
* default → environment → remote → tenant → user
*
* `default` is the static config compiled into the bundle. The remaining layers
* are empty until something registers them, which is what lets us add Firebase
* Remote Config, an API-driven config or per-tenant/role rules later without
* touching a single UI component.
*/
export enum FeatureSource {
Default = 'default',
Environment = 'environment',
Remote = 'remote',
Tenant = 'tenant',
User = 'user',
}
/** Source precedence, lowest → highest. */
export const FEATURE_SOURCE_PRIORITY: readonly FeatureSource[] = [
FeatureSource.Default,
FeatureSource.Environment,
FeatureSource.Remote,
FeatureSource.Tenant,
FeatureSource.User,
];
/**
* Layers whose `false` is a **kill switch**: once one of these turns a feature
* off, no higher-priority layer may turn it back on.
*
* Priority alone is the wrong tool here. `user` sits above `remote` in the list
* above — deliberately, so a local override can enable something for testing —
* but that same ordering would let a device-local toggle re-enable a feature the
* bank had shut down for a live incident. So a kill is resolved *before*
* precedence, and precedence then decides only among features nobody has killed.
*
* Note the asymmetry: an authoritative `true` is only a preference and a local
* layer may still switch it off. Turning a feature *off* locally is always safe;
* turning one *on* against the bank's instruction is not.
*/
export const AUTHORITATIVE_FEATURE_SOURCES: readonly FeatureSource[] = [
FeatureSource.Remote,
FeatureSource.Tenant,
];
/**
* Why one feature resolved the way it did. Drives the toggle screens, which
* must show *why* a row is off — a key held down by a remote kill or by a
* disabled parent cannot be fixed by flipping the row itself.
*/
export type FeatureResolution = {
key: FeatureKey;
/** Final answer, ancestors included. This is what `isEnabled` returns. */
enabled: boolean;
/** The feature's own flag, before ancestors are considered. */
ownFlag: boolean;
/** Layer that decided the own flag. */
decidedBy: FeatureSource;
/** Set when an authoritative layer forced it off. No local override lifts this. */
killedBy?: FeatureSource;
/** Nearest ancestor that is off, when the own flag is on but the feature is not. */
blockedBy?: FeatureKey;
/** False when a local toggle cannot change the outcome (killed, or parent off). */
locallyToggleable: boolean;
};
/** A sparse set of overrides contributed by one source. */
export type FeatureOverrides = Partial<Record<FeatureKey, boolean>>;
/**
* Anything carrying an optional feature reference — used by JSON-driven UI and
* by static option arrays (service tiles, menu rows, search entries).
*
* The field is a plain string so it can come straight from JSON; it is narrowed
* through `toFeatureKey` before use, and an unknown value hides the item.
*/
export type FeatureTagged = {
feature?: FeatureKey | string;
};
/** Result of validating the configuration against the key enum. */
export type FeatureConfigIssue = {
key: string;
problem: 'missing-config' | 'unknown-key' | 'missing-parent';
detail: string;
};
+130
View File
@@ -0,0 +1,130 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import type { FeatureKey } from '../constants/featureKeys';
import { featureService, isFeatureEnabled } from './featureService';
/**
* Subscribes the calling component to feature-config changes.
*
* The store snapshot is the service's version counter, which only changes when a
* source layer is replaced. That keeps `useSyncExternalStore` stable (no render
* loops) while still re-rendering everything the moment remote config lands.
*/
export const useFeatureVersion = (): number =>
useSyncExternalStore(
featureService.subscribe,
featureService.getVersion,
featureService.getVersion,
);
/**
* Whether a single feature is enabled, accounting for its ancestors.
*
* const canAddBeneficiary = useFeature(FeatureKey.PAYMENTS_BENEFICIARIES_ADD);
* return <>{canAddBeneficiary && <AddBeneficiaryButton />}</>;
*/
export const useFeature = (key: FeatureKey): boolean => {
const version = useFeatureVersion();
// `version` looks unused to the linter, but `isFeatureEnabled` reads mutable
// service state — the version is exactly what tells us that state changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => isFeatureEnabled(key), [key, version]);
};
/**
* Resolves several features at once, keyed by feature key. Cheaper and tidier
* than calling `useFeature` repeatedly in a screen with many gated controls.
*
* const flags = useFeatures([FeatureKey.CARDS_CONTROLS_FREEZE, FeatureKey.CARDS_CONTROLS_LIMITS]);
* flags[FeatureKey.CARDS_CONTROLS_FREEZE] // boolean
*/
export const useFeatures = <T extends readonly FeatureKey[]>(
keys: T,
): Record<T[number], boolean> => {
const version = useFeatureVersion();
// Key the memo on the contents, so an inline array literal does not thrash it.
const signature = keys.join('|');
return useMemo(() => {
const result = {} as Record<T[number], boolean>;
for (const key of keys) {
result[key as T[number]] = isFeatureEnabled(key);
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature, version]);
};
/** True only when every listed feature is enabled. */
export const useAllFeatures = (keys: readonly FeatureKey[]): boolean => {
const version = useFeatureVersion();
const signature = keys.join('|');
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => keys.every((key) => isFeatureEnabled(key)), [signature, version]);
};
/** True when at least one listed feature is enabled. */
export const useAnyFeature = (keys: readonly FeatureKey[]): boolean => {
const version = useFeatureVersion();
const signature = keys.join('|');
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => keys.some((key) => isFeatureEnabled(key)), [signature, version]);
};
/**
* Wraps a handler so it is inert while its feature is disabled — the callback
* still exists (keeping call sites simple) but does nothing and fires no API
* call. Pair with `FeatureGate` so the control is not rendered either.
*
* const onDelete = useFeatureAction(FeatureKey.PAYMENTS_BENEFICIARIES_DELETE, () =>
* dispatch(deleteBeneficiary(id)),
* );
*/
export const useFeatureAction = <TArgs extends unknown[]>(
key: FeatureKey,
action: (...args: TArgs) => void,
): ((...args: TArgs) => void) => {
const enabled = useFeature(key);
return useCallback(
(...args: TArgs) => {
if (!enabled) {
if (__DEV__) {
console.warn(`[features] Blocked action for disabled feature "${key}".`);
}
return;
}
action(...args);
},
[enabled, key, action],
);
};
/**
* Filters a list of feature-tagged items down to the enabled ones, recomputing
* when config changes. Use for service grids, menus and JSON-driven UI arrays.
*/
export const useEnabledItems = <T extends { feature?: FeatureKey | string }>(
items: readonly T[],
): T[] => {
const version = useFeatureVersion();
return useMemo(
() =>
items.filter(
(item) =>
item.feature === undefined ||
(typeof item.feature === 'string' && featureService.isEnabledKey(item.feature)),
),
// `version` is the signal that the service's mutable state changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
[items, version],
);
};