mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-22 06:40:21 +08:00
fix(settings): inject channel_monitor & available_channels into SSR payload
Root cause: GetPublicSettingsForInjection used an inline struct that silently drifted from dto.PublicSettings and omitted channel_monitor_enabled / available_channels_enabled. On refresh window.__APP_CONFIG__ lacked these keys, so cachedPublicSettings.available_channels_enabled resolved to undefined and the opt-in sidebar entry (=== true) disappeared. Backend: extract PublicSettingsInjectionPayload as a named type with all feature-flag fields wired, and add a reflect-based drift test in the dto package so forgetting a future flag fails CI instead of the browser. Frontend: introduce utils/featureFlags.ts as the single registry for public-settings-driven toggles, with explicit opt-in / opt-out modes that encode the pre-load fallback. AppSidebar switches to makeSidebarFlag() so adding a new switch only touches the registry.
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPublicSettingsInjectionPayload_SchemaDoesNotDrift guarantees the SSR
|
||||||
|
// injection struct exposes every JSON field consumed by the frontend.
|
||||||
|
//
|
||||||
|
// Why this test exists: before we extracted a named PublicSettingsInjectionPayload
|
||||||
|
// type, the inline struct was manually kept in sync with dto.PublicSettings and
|
||||||
|
// drifted — ChannelMonitorEnabled / AvailableChannelsEnabled were missing, which
|
||||||
|
// made the frontend read `undefined` on refresh and hide the "可用渠道" menu
|
||||||
|
// until the async /api/v1/settings/public round-trip finished.
|
||||||
|
//
|
||||||
|
// This test compares the two JSON-tag sets and fails if injection is missing
|
||||||
|
// any field that dto.PublicSettings exposes. Adding a new feature flag with
|
||||||
|
// only a DTO entry will fail this test until the injection struct is updated.
|
||||||
|
//
|
||||||
|
// Intentional exclusions (fields present on dto.PublicSettings that SSR does
|
||||||
|
// not need to inject) are listed in `dtoOnlyFields` below with a reason.
|
||||||
|
func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) {
|
||||||
|
injection := jsonTags(reflect.TypeOf(service.PublicSettingsInjectionPayload{}))
|
||||||
|
dtoKeys := jsonTags(reflect.TypeOf(PublicSettings{}))
|
||||||
|
|
||||||
|
// Fields that legitimately live only on the DTO. Keep tiny; document each.
|
||||||
|
dtoOnlyFields := map[string]string{
|
||||||
|
// sora_client_enabled is an upstream-only field the fork does not surface.
|
||||||
|
"sora_client_enabled": "upstream-only field, not used on this fork",
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for key := range dtoKeys {
|
||||||
|
if _, ok := injection[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, allowed := dtoOnlyFields[key]; allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
missing = append(missing, key)
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
t.Fatalf("service.PublicSettingsInjectionPayload is missing JSON fields present on dto.PublicSettings: %s\n"+
|
||||||
|
"add the field to PublicSettingsInjectionPayload (and GetPublicSettingsForInjection), or "+
|
||||||
|
"document the exclusion in dtoOnlyFields with a reason.", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonTags(t reflect.Type) map[string]struct{} {
|
||||||
|
out := make(map[string]struct{})
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
f := t.Field(i)
|
||||||
|
tag := f.Tag.Get("json")
|
||||||
|
if tag == "" || tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.SplitN(tag, ",", 2)[0]
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[name] = struct{}{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -358,50 +358,71 @@ func (s *SettingService) SetVersion(version string) {
|
|||||||
s.version = version
|
s.version = version
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection
|
// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as
|
||||||
// This implements the web.PublicSettingsProvider interface
|
// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site
|
||||||
|
// config before the first XHR finishes.
|
||||||
|
//
|
||||||
|
// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings.
|
||||||
|
// If you forget a feature-flag field here, the frontend's
|
||||||
|
// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the
|
||||||
|
// async `/api/v1/settings/public` call returns — which causes opt-in menus
|
||||||
|
// (strict `=== true`) to flicker off/on. See
|
||||||
|
// frontend/src/utils/featureFlags.ts for the matching registry.
|
||||||
|
//
|
||||||
|
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
|
||||||
|
// drift automatically (see setting_service_injection_test.go).
|
||||||
|
type PublicSettingsInjectionPayload struct {
|
||||||
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
|
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||||
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||||
|
TotpEnabled bool `json:"totp_enabled"`
|
||||||
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
SiteLogo string `json:"site_logo"`
|
||||||
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
|
APIBaseURL string `json:"api_base_url"`
|
||||||
|
ContactInfo string `json:"contact_info"`
|
||||||
|
DocURL string `json:"doc_url"`
|
||||||
|
HomeContent string `json:"home_content"`
|
||||||
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
|
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||||
|
TableDefaultPageSize int `json:"table_default_page_size"`
|
||||||
|
TablePageSizeOptions []int `json:"table_page_size_options"`
|
||||||
|
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||||
|
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||||
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
|
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||||
|
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||||
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
|
PaymentEnabled bool `json:"payment_enabled"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
|
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||||
|
|
||||||
|
// Feature flags — MUST match the opt-in/opt-out registry in
|
||||||
|
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
|
||||||
|
// that hid the "可用渠道" menu on page refresh.
|
||||||
|
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||||
|
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||||
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
||||||
|
// This implements the web.PublicSettingsProvider interface.
|
||||||
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
||||||
settings, err := s.GetPublicSettings(ctx)
|
settings, err := s.GetPublicSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a struct that matches the frontend's expected format
|
return &PublicSettingsInjectionPayload{
|
||||||
return &struct {
|
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
|
||||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
|
||||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
|
||||||
TotpEnabled bool `json:"totp_enabled"`
|
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
|
||||||
SiteName string `json:"site_name"`
|
|
||||||
SiteLogo string `json:"site_logo,omitempty"`
|
|
||||||
SiteSubtitle string `json:"site_subtitle,omitempty"`
|
|
||||||
APIBaseURL string `json:"api_base_url,omitempty"`
|
|
||||||
ContactInfo string `json:"contact_info,omitempty"`
|
|
||||||
DocURL string `json:"doc_url,omitempty"`
|
|
||||||
HomeContent string `json:"home_content,omitempty"`
|
|
||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
|
||||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
|
||||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
|
|
||||||
TableDefaultPageSize int `json:"table_default_page_size"`
|
|
||||||
TablePageSizeOptions []int `json:"table_page_size_options"`
|
|
||||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
|
||||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
|
||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
|
||||||
PaymentEnabled bool `json:"payment_enabled"`
|
|
||||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
|
||||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
|
||||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
|
||||||
}{
|
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||||
@@ -426,15 +447,19 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
BackendModeEnabled: settings.BackendModeEnabled,
|
|
||||||
PaymentEnabled: settings.PaymentEnabled,
|
|
||||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||||
|
BackendModeEnabled: settings.BackendModeEnabled,
|
||||||
|
PaymentEnabled: settings.PaymentEnabled,
|
||||||
Version: s.version,
|
Version: s.version,
|
||||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||||
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||||||
|
|
||||||
|
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
||||||
|
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||||
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||||
import { sanitizeSvg } from '@/utils/sanitize'
|
import { sanitizeSvg } from '@/utils/sanitize'
|
||||||
|
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -627,13 +628,12 @@ const ChevronDownIcon = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 各个开关集中声明:所有菜单项引用这里的 getter,未来加新开关只需在此加一个常量。
|
// Public-settings flags go through the registry in utils/featureFlags.ts,
|
||||||
// getter 返回 false = 隐藏;undefined/true = 显示(宽容策略,避免 public settings 未加载闪烁)。
|
// which handles the opt-in vs opt-out fallback when settings haven't loaded
|
||||||
const flagChannelMonitor = () => appStore.cachedPublicSettings?.channel_monitor_enabled
|
// yet. Admin-only flags (not in public settings) stay inline below.
|
||||||
const flagPayment = () => appStore.cachedPublicSettings?.payment_enabled
|
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
|
||||||
// Opt-in feature:默认关闭,未加载 settings 时也视为关闭(严格 === true 判定)
|
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||||
const flagAvailableChannels = () =>
|
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||||
appStore.cachedPublicSettings?.available_channels_enabled === true
|
|
||||||
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
||||||
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => {
|
|||||||
|
|
||||||
describe('AppSidebar header styles', () => {
|
describe('AppSidebar header styles', () => {
|
||||||
it('does not clip the version badge dropdown', () => {
|
it('does not clip the version badge dropdown', () => {
|
||||||
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
|
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n {2}\}/)
|
||||||
const sidebarBrandBlockMatch = componentSource.match(/\.sidebar-brand\s*\{[\s\S]*?\n\}/)
|
const sidebarBrandBlockMatch = componentSource.match(/\.sidebar-brand\s*\{[\s\S]*?\n\}/)
|
||||||
|
|
||||||
expect(sidebarHeaderBlockMatch).not.toBeNull()
|
expect(sidebarHeaderBlockMatch).not.toBeNull()
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Feature flag registry — single source of truth for public-settings-driven
|
||||||
|
* feature switches used by the sidebar, routes, and views.
|
||||||
|
*
|
||||||
|
* ## Why this module exists
|
||||||
|
*
|
||||||
|
* `public settings` reach the frontend through two channels:
|
||||||
|
*
|
||||||
|
* 1. **SSR injection** — the backend embeds `window.__APP_CONFIG__` into the
|
||||||
|
* HTML. `main.ts` calls `appStore.initFromInjectedConfig()` synchronously
|
||||||
|
* before Vue mounts, so `cachedPublicSettings` is populated on first
|
||||||
|
* render.
|
||||||
|
* 2. **Async API** — `App.vue` awaits `appStore.fetchPublicSettings()` on
|
||||||
|
* mount as a fallback (used when injection is missing or stale).
|
||||||
|
*
|
||||||
|
* If the SSR injection struct forgets to include a feature flag field — the
|
||||||
|
* exact bug that hid the "可用渠道" menu after every refresh — the frontend
|
||||||
|
* reads `undefined` until the async call resolves. An opt-in flag written as
|
||||||
|
* `settings?.xxx_enabled === true` then evaluates to `false` and the menu
|
||||||
|
* disappears. An opt-out flag written as `settings?.xxx_enabled !== false`
|
||||||
|
* evaluates to `true` (menu stays) but will flicker off if the backend sends
|
||||||
|
* `false`.
|
||||||
|
*
|
||||||
|
* This module hides that `undefined` handling behind two explicit modes.
|
||||||
|
*
|
||||||
|
* ## Modes
|
||||||
|
*
|
||||||
|
* - **`opt-out`** (default enabled) — menu visible when settings unloaded,
|
||||||
|
* hidden only when the backend explicitly sends `false`. Use for features
|
||||||
|
* that ship enabled by default (Channel Monitor, Payment).
|
||||||
|
* - **`opt-in`** (default disabled) — menu hidden when settings unloaded,
|
||||||
|
* visible only when the backend explicitly sends `true`. Use for features
|
||||||
|
* that ship disabled (Available Channels).
|
||||||
|
*
|
||||||
|
* For `opt-in` flags to render immediately on refresh, the backend **must**
|
||||||
|
* inject the field through `PublicSettingsInjectionPayload`. A drift test in
|
||||||
|
* `backend/internal/handler/dto/public_settings_injection_schema_test.go`
|
||||||
|
* catches omissions.
|
||||||
|
*
|
||||||
|
* ## Adding a new flag
|
||||||
|
*
|
||||||
|
* 1. Backend `service/domain_constants.go` → `SettingKey<Name>Enabled`
|
||||||
|
* 2. Backend `service/settings_view.go` → `PublicSettings` + `SystemSettings`
|
||||||
|
* 3. Backend `service/setting_service.go` → `GetPublicSettings` / `UpdateSettings` /
|
||||||
|
* `GetAllSettings` / `InitDefaultSettings` /
|
||||||
|
* **`PublicSettingsInjectionPayload`**
|
||||||
|
* (the drift test enforces this)
|
||||||
|
* 4. Backend `handler/dto/settings.go` → `PublicSettings` + `SystemSettings`
|
||||||
|
* 5. Backend `handler/setting_handler.go` → handler response
|
||||||
|
* 6. Backend `handler/admin/setting_handler.go` → update request + audit diff
|
||||||
|
* 7. Frontend `types/index.ts` → `PublicSettings` typings
|
||||||
|
* 8. Frontend `api/admin/settings.ts` → admin DTO typings
|
||||||
|
* 9. **Frontend `utils/featureFlags.ts` (this file)** → register via `defineFlag`
|
||||||
|
* 10. Frontend `views/admin/SettingsView.vue` → Toggle UI + form defaults + save payload
|
||||||
|
* 11. Frontend `components/layout/AppSidebar.vue` → attach via `makeSidebarFlag`
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||||
|
*
|
||||||
|
* const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||||
|
* // ...
|
||||||
|
* { path: '/available-channels', label: ..., featureFlag: flagAvailableChannels }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `isFeatureFlagEnabled(flag)` returns the resolved boolean (`true` = show).
|
||||||
|
* `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with
|
||||||
|
* `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import type { PublicSettings } from '@/types'
|
||||||
|
|
||||||
|
export type FeatureFlagMode = 'opt-in' | 'opt-out'
|
||||||
|
|
||||||
|
export interface FeatureFlagDefinition {
|
||||||
|
/** Public-settings key used for lookup. */
|
||||||
|
readonly key: keyof PublicSettings
|
||||||
|
/** Resolution mode when the key is missing/undefined. */
|
||||||
|
readonly mode: FeatureFlagMode
|
||||||
|
/** Short human label for logs and debug tooling. */
|
||||||
|
readonly label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineFlag<K extends keyof PublicSettings>(
|
||||||
|
def: { key: K; mode: FeatureFlagMode; label: string },
|
||||||
|
): FeatureFlagDefinition {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered feature flags. Add a new entry here when introducing a new
|
||||||
|
* public-settings-driven switch; see the "Adding a new flag" checklist above.
|
||||||
|
*/
|
||||||
|
export const FeatureFlags = {
|
||||||
|
channelMonitor: defineFlag({
|
||||||
|
key: 'channel_monitor_enabled',
|
||||||
|
mode: 'opt-out',
|
||||||
|
label: 'Channel Monitor',
|
||||||
|
}),
|
||||||
|
availableChannels: defineFlag({
|
||||||
|
key: 'available_channels_enabled',
|
||||||
|
mode: 'opt-in',
|
||||||
|
label: 'Available Channels',
|
||||||
|
}),
|
||||||
|
payment: defineFlag({
|
||||||
|
key: 'payment_enabled',
|
||||||
|
mode: 'opt-out',
|
||||||
|
label: 'Payment',
|
||||||
|
}),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current value of a flag, honoring the mode's fallback.
|
||||||
|
* `true` → the feature is enabled (menu/route should render).
|
||||||
|
* `false` → the feature is disabled (menu/route should hide).
|
||||||
|
*/
|
||||||
|
export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const raw = appStore.cachedPublicSettings?.[flag.key] as
|
||||||
|
| boolean
|
||||||
|
| undefined
|
||||||
|
if (typeof raw === 'boolean') return raw
|
||||||
|
// Settings not yet loaded → fall back to the flag's declared mode:
|
||||||
|
// opt-out → visible by default, opt-in → hidden by default.
|
||||||
|
return flag.mode === 'opt-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar NavItem.featureFlag accepts a getter that returns
|
||||||
|
* `false` to hide. Keeping the same contract lets callers swap in
|
||||||
|
* registry-backed flags without changing AppSidebar's filter logic.
|
||||||
|
*/
|
||||||
|
export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean {
|
||||||
|
return () => isFeatureFlagEnabled(flag)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user