From 9809b3f540e13a51afce4a9b456d16c7f046c7a9 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Wed, 17 Sep 2025 15:04:29 -0600 Subject: [PATCH 01/10] WIP: Feature flags - @todo: Figure out mock implementation for tests & mock API - @todo: Update README for new env vars - @todo: Set statsig user (email) in authenticated context - @todo: Unset statsig user during logout - @todo: Update CSP Lambda tests - @todo: Work with backend to set new env vars in CDK build - @todo: Work with backend to make sure CSP Lambda hash builds as expected --- .../lambdas/nodejs/cloudfront-csp/index.js | 12 ++ webroot/.env.example | 2 + webroot/package.json | 3 + webroot/src/main.ts | 171 ++++++++++-------- .../pages/PublicDashboard/PublicDashboard.ts | 6 + webroot/src/plugins/API/api.d.ts | 2 +- .../src/plugins/EnvConfig/envConfig.plugin.ts | 27 +++ webroot/src/plugins/Statsig/statsig.d.ts | 15 ++ webroot/src/plugins/Statsig/statsig.plugin.ts | 66 +++++++ webroot/yarn.lock | 139 ++++++++++++++ 10 files changed, 364 insertions(+), 79 deletions(-) create mode 100644 webroot/src/plugins/Statsig/statsig.d.ts create mode 100644 webroot/src/plugins/Statsig/statsig.plugin.ts diff --git a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js index 26c896b3f..41e4845cf 100644 --- a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js +++ b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js @@ -242,7 +242,19 @@ const setCspHeader = (headers = {}) => { domains.cognitoStaff, domains.cognitoProvider, cognitoIdpUrl, + // Begin Statsig domains 'https://www.google.com/recaptcha/', + 'http://api.statsig.com/', + 'http://featuregates.org/', + 'http://statsigapi.net/', + 'http://events.statsigapi.net/', + 'http://api.statsigcdn.com/', + 'http://featureassets.org/', + 'http://assetsconfigcdn.org/', + 'http://prodregistryv2.org/', + 'http://cloudflare-dns.com/', + 'http://beyondwickedmapping.org/', + // End Statsig domains ]), ].join(' ')}`, }]; diff --git a/webroot/.env.example b/webroot/.env.example index 347760704..6b2137cae 100644 --- a/webroot/.env.example +++ b/webroot/.env.example @@ -1,5 +1,6 @@ NODE_ENV=development BASE_URL=/ +VUE_APP_ENV=local VUE_APP_DOMAIN=http://localhost:3018 VUE_APP_ROBOTS_META=noindex,nofollow VUE_APP_API_STATE_ROOT=https://api.test.jcc.iaapi.io @@ -11,6 +12,7 @@ VUE_APP_COGNITO_CLIENT_ID_STAFF=4s5iil9aut9lo0du76p37o8m7h VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE=https://ia-cc-provider-test.auth.us-east-1.amazoncognito.com VUE_APP_COGNITO_CLIENT_ID_LICENSEE=topd4vhftng5cfm3ccgkb6ejd VUE_APP_RECAPTCHA_KEY=6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih +VUE_APP_STATSIG_KEY=TODO VUE_APP_MOCK_API=false VUE_APP_MOCK_API_PAYMENT_LOGIN_ID=TODO VUE_APP_MOCK_API_PAYMENT_CLIENT_KEY=TODO diff --git a/webroot/package.json b/webroot/package.json index 0e93af78d..1b523f543 100644 --- a/webroot/package.json +++ b/webroot/package.json @@ -13,6 +13,9 @@ "audit:dependencies": "/bin/bash -c 'yarn audit --groups dependencies --level moderate; [[ $? -ge 4 ]] && exit 1 || exit 0'" }, "dependencies": { + "@statsig/js-client": "^3.25.3", + "@statsig/session-replay": "^3.25.3", + "@statsig/web-analytics": "^3.25.3", "@vue/compat": "3.4.21", "@vuepic/vue-datepicker": "^8.7.0", "axios": "^1.12.2", diff --git a/webroot/src/main.ts b/webroot/src/main.ts index f031a5e9c..5e7e27e6e 100644 --- a/webroot/src/main.ts +++ b/webroot/src/main.ts @@ -7,6 +7,7 @@ import { createApp } from 'vue'; import envConfig from '@plugins/EnvConfig/envConfig.plugin'; +import statsig, { initStatsig } from '@plugins/Statsig/statsig.plugin'; import router from '@router/index'; import store from '@store/index'; import api from '@plugins/API/api.plugin'; @@ -18,91 +19,105 @@ import i18n from './i18n'; import App from './components/App/App.vue'; import './registerServiceWorker'; -// -// INITIALIZE APP -// -const app = createApp(App); +(async () => { + // + // INITIALIZE APP + // + const app = createApp(App); -// Enable vue-devtools. Can make environment-specific if needed. -app.config.performance = true; + // Initialize the Statsig library (feature flags & analytics) + const statsigClient = await initStatsig(); -// Inject router into API interceptors (avoids circular dependency) -network.dataApi.initInterceptors(router); + // Enable vue-devtools. Can make environment-specific if needed. + app.config.performance = true; -// -// INJECT PLUGINS -// -app.use(envConfig); -app.use(router); -app.use(store); -app.use(i18n); -app.use(api); -app.use(vClickOutside); -app.use(VueResponsiveness, { - phone: 0, - tablet: 770, - desktop: 1024, - largeDesktop: 1600, - xLargeDesktop: 2400, -}); - -app.use(VueLazyload, { - // https://github.com/murongg/vue3-lazyload - observerOptions: { - rootMargin: '0px', - threshold: 0.1, - }, - error: '/img/static/img-load-error.svg', - // loading: 'dist/loading.gif', - lifecycle: { - loaded: (el) => { - // Add ratio (wide / tall) class to image element - const imgEl = el || document.createElement('img'); - const img = new Image(); - - img.onload = () => { - const { width, height } = img; - - if (width / height > 1) { - imgEl.classList.remove('tall'); - imgEl.classList.add('wide'); - } else { - imgEl.classList.remove('wide'); - imgEl.classList.add('tall'); - } - }; - - // img.src = src; + // Inject router into API interceptors (avoids circular dependency) + network.dataApi.initInterceptors(router); + + // + // INJECT PLUGINS + // + app.use(envConfig); + app.use(router); + app.use(store); + app.use(i18n); + app.use(api); + app.use(statsig, { statsigClient }); + app.use(vClickOutside); + app.use(VueResponsiveness, { + phone: 0, + tablet: 770, + desktop: 1024, + largeDesktop: 1600, + xLargeDesktop: 2400, + }); + + app.use(VueLazyload, { + // https://github.com/murongg/vue3-lazyload + observerOptions: { + rootMargin: '0px', + threshold: 0.1, }, - }, -}); + error: '/img/static/img-load-error.svg', + // loading: 'dist/loading.gif', + lifecycle: { + loaded: (el) => { + // Add ratio (wide / tall) class to image element + const imgEl = el || document.createElement('img'); + const img = new Image(); -// -// ALLOW ACCESS TO VUE INSTANCE SERVICES -// -// Attach any services that aren't automatically attached to the Vue instance -const { globalProperties } = app.config; -const { t: $t, tm: $tm } = i18n.global; + img.onload = () => { + const { width, height } = img; + + if (width / height > 1) { + imgEl.classList.remove('tall'); + imgEl.classList.add('wide'); + } else { + imgEl.classList.remove('wide'); + imgEl.classList.add('tall'); + } + }; -if (!globalProperties.$t) { - (globalProperties as any).$t = $t; -} + // img.src = src; + }, + }, + }); -if (!globalProperties.$tm) { - (globalProperties as any).$tm = $tm; -} + // + // ALLOW ACCESS TO VUE INSTANCE SERVICES + // + // Attach any services that aren't automatically attached to the Vue instance + const { globalProperties } = app.config; + const { t: $t, tm: $tm } = i18n.global; -// Make Vue available globally -(window as any).Vue = app || {}; + if (!globalProperties.$t) { + (globalProperties as any).$t = $t; + } -// -// MOUNT -// -app.mount('#jcc-app'); + if (!globalProperties.$tm) { + (globalProperties as any).$tm = $tm; + } -// -// E2E TESTS INJECTION -// -if ((window as any).Cypress) { - (window as any).app = app; -} + if (!globalProperties.$features) { + (globalProperties as any).$features = statsigClient; + } + + if (!globalProperties.$analytics) { + (globalProperties as any).$analytics = statsigClient; + } + + // Make Vue available globally + (window as any).Vue = app || {}; + + // + // MOUNT + // + app.mount('#jcc-app'); + + // + // E2E TESTS INJECTION + // + if ((window as any).Cypress) { + (window as any).app = app; + } +})(); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.ts index 9c123d2cc..90d260fe5 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -37,6 +37,12 @@ export default class DashboardPublic extends Vue { if (this.bypassQuery) { this.bypassRedirect(); } + + if (this.$features.checkGate('test-feature-1')) { + console.log('ON all the way'); + } else { + console.log('OFF bro'); + } } // diff --git a/webroot/src/plugins/API/api.d.ts b/webroot/src/plugins/API/api.d.ts index eefacb6be..9eeacb082 100644 --- a/webroot/src/plugins/API/api.d.ts +++ b/webroot/src/plugins/API/api.d.ts @@ -1,5 +1,5 @@ // -// envConfig.d.ts +// api.d.ts // InspiringApps modules // // Created by InspiringApps on 4/12/20. diff --git a/webroot/src/plugins/EnvConfig/envConfig.plugin.ts b/webroot/src/plugins/EnvConfig/envConfig.plugin.ts index 5a1e0461e..858013b7b 100644 --- a/webroot/src/plugins/EnvConfig/envConfig.plugin.ts +++ b/webroot/src/plugins/EnvConfig/envConfig.plugin.ts @@ -11,9 +11,20 @@ // @NOTE: Any custom keys in .env have to start with VUE_APP.... to be recognized at runtime // +// Build environments (Node) const ENV_PRODUCTION = 'production'; const ENV_TEST = 'test'; const ENV_DEVELOPMENT = 'development'; + +// App environments +export const appEnvironments = { + APP_PRODUCTION: 'production', + APP_BETA: 'beta', + APP_TEST_IA: 'ia-test', + APP_TEST_CSG: 'csg-test', + APP_LOCAL: 'local', +}; + const context = process.env; export interface EnvConfig { @@ -21,6 +32,13 @@ export interface EnvConfig { isProduction?: boolean; isTest?: boolean; isDevelopment?: boolean; + appEnv?: string; + isAppProduction?: boolean; + isAppBeta?: boolean; + isAppTest?: boolean; + isAppTestIa?: boolean; + isAppTestCsg?: boolean; + isAppLocal?: boolean; baseUrl?: string; domain?: string; apiUrlState?: string; @@ -34,6 +52,7 @@ export interface EnvConfig { cognitoAuthDomainLicensee?: string; cognitoClientIdLicensee?: string; recaptchaKey?: string; + statsigKey?: string; isUsingMockApi?: boolean; } @@ -43,6 +62,13 @@ export const config: EnvConfig = { isProduction: (context.NODE_ENV === ENV_PRODUCTION), isTest: (context.NODE_ENV === ENV_TEST), isDevelopment: (context.NODE_ENV === ENV_DEVELOPMENT), + appEnv: context.VUE_APP_ENV, + isAppProduction: (context.APP_ENV === appEnvironments.APP_PRODUCTION), + isAppBeta: (context.APP_ENV === appEnvironments.APP_BETA), + isAppTest: (context.APP_ENV === appEnvironments.APP_TEST_IA || context.APP_ENV === appEnvironments.APP_TEST_CSG), + isAppTestIa: (context.APP_ENV === appEnvironments.APP_TEST_IA), + isAppTestCsg: (context.APP_ENV === appEnvironments.APP_TEST_CSG), + isAppLocal: (context.APP_ENV === appEnvironments.APP_LOCAL), baseUrl: context.BASE_URL, domain: context.VUE_APP_DOMAIN, apiUrlState: context.VUE_APP_API_STATE_ROOT, @@ -56,6 +82,7 @@ export const config: EnvConfig = { cognitoAuthDomainLicensee: context.VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE, cognitoClientIdLicensee: context.VUE_APP_COGNITO_CLIENT_ID_LICENSEE, recaptchaKey: context.VUE_APP_RECAPTCHA_KEY, + statsigKey: context.VUE_APP_STATSIG_KEY, isUsingMockApi: (context.VUE_APP_MOCK_API === 'true'), }; diff --git a/webroot/src/plugins/Statsig/statsig.d.ts b/webroot/src/plugins/Statsig/statsig.d.ts new file mode 100644 index 000000000..00e2ffeb5 --- /dev/null +++ b/webroot/src/plugins/Statsig/statsig.d.ts @@ -0,0 +1,15 @@ +// +// statsigapi.d.ts +// CompactConnect +// +// Created by InspiringApps on 9/17/2025. +// + +import { StatsigClient } from '@statsig/js-client'; + +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $features: StatsigClient, + $analytics: StatsigClient, + } +} diff --git a/webroot/src/plugins/Statsig/statsig.plugin.ts b/webroot/src/plugins/Statsig/statsig.plugin.ts new file mode 100644 index 000000000..49e39a158 --- /dev/null +++ b/webroot/src/plugins/Statsig/statsig.plugin.ts @@ -0,0 +1,66 @@ +// +// statsig.plugin.ts +// CompactConnect +// +// Created by InspiringApps on 9/17/2025. +// + +/* eslint-disable import/no-extraneous-dependencies */ + +import { config as envConfig, appEnvironments } from '@plugins/EnvConfig/envConfig.plugin'; +import { StatsigClient } from '@statsig/js-client'; +import { StatsigSessionReplayPlugin } from '@statsig/session-replay'; +import { StatsigAutoCapturePlugin } from '@statsig/web-analytics'; + +const STATSIG_PRODUCTION = 'production'; +const STATSIG_STAGING = 'staging'; +const STATSIG_DEVELOPMENT = 'development'; + +export const getStatsigEnvironment = () => { + let statsigEnvironment = ''; + + switch (envConfig.appEnv) { + case appEnvironments.APP_PRODUCTION: + statsigEnvironment = STATSIG_PRODUCTION; + break; + case appEnvironments.APP_BETA: + statsigEnvironment = STATSIG_STAGING; + break; + case appEnvironments.APP_TEST_IA: + case appEnvironments.APP_TEST_CSG: + case appEnvironments.APP_LOCAL: + statsigEnvironment = STATSIG_DEVELOPMENT; + break; + default: + statsigEnvironment = STATSIG_PRODUCTION; + break; + } + + return statsigEnvironment; +}; + +export const initStatsig = async () => { + const statsigEnvironment = getStatsigEnvironment(); + const statsigClient = new StatsigClient( + envConfig.statsigKey || '', + {}, + { + environment: { tier: statsigEnvironment }, + plugins: [ + new StatsigSessionReplayPlugin(), + new StatsigAutoCapturePlugin(), + ], + } + ); + + await statsigClient.initializeAsync(); + + return statsigClient; +}; + +export default { + install: (app, { statsigClient }) => { + app.config.globalProperties.$features = statsigClient; + app.config.globalProperties.$analytics = statsigClient; + }, +}; diff --git a/webroot/yarn.lock b/webroot/yarn.lock index 6c2674121..33422e13f 100644 --- a/webroot/yarn.lock +++ b/webroot/yarn.lock @@ -2535,6 +2535,24 @@ estree-walker "^2.0.1" picomatch "^2.2.2" +"@rrweb/record@2.0.0-alpha.17": + version "2.0.0-alpha.17" + resolved "https://registry.yarnpkg.com/@rrweb/record/-/record-2.0.0-alpha.17.tgz#4f306a8dfcdbb56419b59bb1cb0f5cbea073cafc" + integrity sha512-Je+lzjeWMF8/I0IDoXFzkGPKT8j7AkaBup5YcwUHlkp18VhLVze416MvI6915teE27uUA2ScXMXzG0Yiu5VTIw== + dependencies: + "@rrweb/types" "^2.0.0-alpha.17" + rrweb "^2.0.0-alpha.17" + +"@rrweb/types@^2.0.0-alpha.17", "@rrweb/types@^2.0.0-alpha.18": + version "2.0.0-alpha.18" + resolved "https://registry.yarnpkg.com/@rrweb/types/-/types-2.0.0-alpha.18.tgz#e1d9af844cebbf30a2be8808f6cf64f5df3e7f50" + integrity sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q== + +"@rrweb/utils@^2.0.0-alpha.17", "@rrweb/utils@^2.0.0-alpha.18": + version "2.0.0-alpha.18" + resolved "https://registry.yarnpkg.com/@rrweb/utils/-/utils-2.0.0-alpha.18.tgz#7440b425461cf92b8ad9a229db40fa58d456159a" + integrity sha512-qV8azQYo9RuwW4NGRtOiQfTBdHNL1B0Q//uRLMbCSjbaKqJYd88Js17Bdskj65a0Vgp2dwTLPIZ0gK47dfjfaA== + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" @@ -2595,6 +2613,36 @@ resolved "https://registry.yarnpkg.com/@soda/get-current-script/-/get-current-script-1.0.2.tgz#a53515db25d8038374381b73af20bb4f2e508d87" integrity sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w== +"@statsig/client-core@3.25.3": + version "3.25.3" + resolved "https://registry.yarnpkg.com/@statsig/client-core/-/client-core-3.25.3.tgz#1e901cfea218502ee727463fd1ce4212cdccd379" + integrity sha512-QmLigHO4aN9NHQLVvoP8bYFbuFFWaNJVm6RM59yQDAaW8AynMHjrmWtValpSD/sRjwEUUZBpQty0ciJCg56e9Q== + +"@statsig/js-client@3.25.3", "@statsig/js-client@^3.25.3": + version "3.25.3" + resolved "https://registry.yarnpkg.com/@statsig/js-client/-/js-client-3.25.3.tgz#2342cf2fab63ad437093646dfe7bd061b276e398" + integrity sha512-yySqWNONM/fHIUgTtF5QEYJqrnLaoBstPLCfJlCAbrTAzX+QnPYBzGcHBPlIFdRI4txP76RWCTnb9hRxyLZbsg== + dependencies: + "@statsig/client-core" "3.25.3" + +"@statsig/session-replay@^3.25.3": + version "3.25.3" + resolved "https://registry.yarnpkg.com/@statsig/session-replay/-/session-replay-3.25.3.tgz#63d630c444ad7032a6deb88b625ad5be29323a45" + integrity sha512-j0aDVb5GtbyaGkLOgyUaW6vfdXlZ3DBbbRI9zhtc9D8GTLQ3xPw0y2JD9+m1AHDsSb92l4Fsd5qhRACNEXxwPA== + dependencies: + "@rrweb/record" "2.0.0-alpha.17" + "@statsig/client-core" "3.25.3" + rrweb "2.0.0-alpha.17" + +"@statsig/web-analytics@^3.25.3": + version "3.25.3" + resolved "https://registry.yarnpkg.com/@statsig/web-analytics/-/web-analytics-3.25.3.tgz#a410519e74a2e0965f08f5435a49ba5f6909303f" + integrity sha512-M2W/8Ea7HGxAEX6ujUDOMfkyaBJ7jKab67LIoc/GG+7zKLSDxGI5iTB++V51MeMWsB0XUCYa3iGV4tu0T3AAmw== + dependencies: + "@statsig/client-core" "3.25.3" + "@statsig/js-client" "3.25.3" + web-vitals "5.0.3" + "@stylelint/postcss-css-in-js@^0.37.2": version "0.37.3" resolved "https://registry.yarnpkg.com/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.37.3.tgz#d149a385e07ae365b0107314c084cb6c11adbf49" @@ -2665,6 +2713,11 @@ dependencies: "@types/node" "*" +"@types/css-font-loading-module@0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz#2f98ede46acc0975de85c0b7b0ebe06041d24601" + integrity sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q== + "@types/eslint@^7.29.0 || ^8.4.1": version "8.56.7" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.7.tgz#c33b5b5a9cfb66881beb7b5be6c34aa3e81d3366" @@ -3611,6 +3664,11 @@ "@webassemblyjs/ast" "1.12.1" "@xtuc/long" "4.2.2" +"@xstate/fsm@^1.4.0": + version "1.6.5" + resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.5.tgz#f599e301997ad7e3c572a0b1ff0696898081bea5" + integrity sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -4224,6 +4282,11 @@ balanced-match@^2.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== +base64-arraybuffer@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" + integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -9060,6 +9123,11 @@ minipass@^3.1.1: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== +mitt@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + mkdirp@^0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" @@ -9185,6 +9253,11 @@ nanoid@3.1.20, nanoid@^3.1.31, nanoid@^3.3.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + native-request@^1.0.5: version "1.0.8" resolved "https://registry.yarnpkg.com/native-request/-/native-request-1.0.8.tgz#8f66bf606e0f7ea27c0e5995eb2f5d03e33ae6fb" @@ -9842,6 +9915,11 @@ picocolors@^1.0.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1: version "2.2.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.3.tgz#465547f359ccc206d3c48e46a1bcb89bf7ee619d" @@ -10229,6 +10307,15 @@ postcss@^8.2.6, postcss@^8.3.5, postcss@^8.4.33, postcss@^8.4.35: picocolors "^1.0.0" source-map-js "^1.2.0" +postcss@^8.4.38: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postcss@^8.4.39: version "8.4.40" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.40.tgz#eb81f2a4dd7668ed869a6db25999e02e9ad909d8" @@ -10772,6 +10859,48 @@ rollup@^2.43.1: optionalDependencies: fsevents "~2.3.2" +rrdom@^2.0.0-alpha.17, rrdom@^2.0.0-alpha.18: + version "2.0.0-alpha.18" + resolved "https://registry.yarnpkg.com/rrdom/-/rrdom-2.0.0-alpha.18.tgz#54726a87053c420ef67b7597a31fef515e372e85" + integrity sha512-fSFzFFxbqAViITyYVA4Z0o5G6p1nEqEr/N8vdgSKie9Rn0FJxDSNJgjV0yiCIzcDs0QR+hpvgFhpbdZ6JIr5Nw== + dependencies: + rrweb-snapshot "^2.0.0-alpha.18" + +rrweb-snapshot@^2.0.0-alpha.17, rrweb-snapshot@^2.0.0-alpha.18: + version "2.0.0-alpha.18" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz#b242d079cb07acadd389a56674465a466b111e20" + integrity sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA== + dependencies: + postcss "^8.4.38" + +rrweb@2.0.0-alpha.17: + version "2.0.0-alpha.17" + resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-2.0.0-alpha.17.tgz#de4d8bce18c0f184708b79edaa3c974a9f5b843f" + integrity sha512-GQxBkCC4r9XL2bwSdv7iIS49M3cEA8OtObVq0rrQ4GUT4+h7omucGQ4x7m5YN5Vq1oalStBaBlYqF7yRnfG3JA== + dependencies: + "@rrweb/types" "^2.0.0-alpha.17" + "@rrweb/utils" "^2.0.0-alpha.17" + "@types/css-font-loading-module" "0.0.7" + "@xstate/fsm" "^1.4.0" + base64-arraybuffer "^1.0.1" + mitt "^3.0.0" + rrdom "^2.0.0-alpha.17" + rrweb-snapshot "^2.0.0-alpha.17" + +rrweb@^2.0.0-alpha.17: + version "2.0.0-alpha.18" + resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-2.0.0-alpha.18.tgz#19d96bccba44dc1ee37d0b77b9ca1952682e62b5" + integrity sha512-1mjZcB+LVoGSx1+i9E2ZdAP90fS3MghYVix2wvGlZvrgRuLCbTCCOZMztFCkKpgp7/EeCdYM4nIHJkKX5J1Nmg== + dependencies: + "@rrweb/types" "^2.0.0-alpha.18" + "@rrweb/utils" "^2.0.0-alpha.18" + "@types/css-font-loading-module" "0.0.7" + "@xstate/fsm" "^1.4.0" + base64-arraybuffer "^1.0.1" + mitt "^3.0.0" + rrdom "^2.0.0-alpha.18" + rrweb-snapshot "^2.0.0-alpha.18" + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -11239,6 +11368,11 @@ source-map-js@^1.0.2, source-map-js@^1.2.0: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-support@^0.5.13: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" @@ -12627,6 +12761,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-vitals@5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-5.0.3.tgz#29708ca3ab7ff96f676c6ff68fa8e8ed09a93d9b" + integrity sha512-4KmOFYxj7qT6RAdCH0SWwq8eKeXNhAFXR4PmgF6nrWFmrJ41n7lq3UCA6UK0GebQ4uu+XP8e8zGjaDO3wZlqTg== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From c2fdf03ace4777f53b3e29d080488fe511566ad4 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 22 Sep 2025 13:39:34 -0600 Subject: [PATCH 02/10] WIP: Feature flags - @todo: Set statsig user (email) in authenticated context - @todo: Unset statsig user during logout - @todo: Work with backend to set new env vars in CDK build - @todo: Work with backend to make sure CSP Lambda hash builds as expected --- .github/workflows/check-webroot.yml | 3 + .../lambdas/nodejs/cloudfront-csp/index.js | 2 +- .../nodejs/cloudfront-csp/test/index.test.js | 12 ++++ webroot/README.md | 66 +++++++++++++------ webroot/nyc.config.js | 1 + .../LicenseeList/LicenseeList.spec.ts | 2 +- .../src/components/Lists/Sorting/Sorting.ts | 8 ++- webroot/src/plugins/Statsig/statsig.plugin.ts | 36 ++++++++-- webroot/tests/helpers/setup.ts | 7 ++ 9 files changed, 108 insertions(+), 29 deletions(-) diff --git a/.github/workflows/check-webroot.yml b/.github/workflows/check-webroot.yml index 836fb733a..c84b1ff74 100644 --- a/.github/workflows/check-webroot.yml +++ b/.github/workflows/check-webroot.yml @@ -11,6 +11,7 @@ on: - development - ia-development - ia-web-development + - frontend/feature-flags # @TODO: Remove after feature branch paths: - webroot/** @@ -78,6 +79,7 @@ jobs: env: NODE_ENV: production BASE_URL: ${{ env.BASE_URL }} + VUE_APP_ENV: production VUE_APP_DOMAIN: ${{ env.VUE_APP_DOMAIN }} VUE_APP_ROBOTS_META: ${{ env.VUE_APP_ROBOTS_META }} VUE_APP_API_STATE_ROOT: ${{ env.VUE_APP_API_STATE_ROOT }} @@ -88,5 +90,6 @@ jobs: VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE: ${{ env.VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE }} VUE_APP_COGNITO_CLIENT_ID_LICENSEE: ${{ env.VUE_APP_COGNITO_CLIENT_ID_LICENSEE }} VUE_APP_RECAPTCHA_KEY: ${{ env.VUE_APP_RECAPTCHA_KEY }} + VUE_APP_MOCK_API: true run: yarn build working-directory: ./webroot diff --git a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js index 41e4845cf..b75474f60 100644 --- a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js +++ b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js @@ -242,8 +242,8 @@ const setCspHeader = (headers = {}) => { domains.cognitoStaff, domains.cognitoProvider, cognitoIdpUrl, - // Begin Statsig domains 'https://www.google.com/recaptcha/', + // Begin Statsig domains 'http://api.statsig.com/', 'http://featuregates.org/', 'http://statsigapi.net/', diff --git a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js index 4ba75f99d..fa93042ae 100644 --- a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js +++ b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js @@ -156,6 +156,18 @@ const buildCspHeaders = (environment) => { cognitoProviderUrl, cognitoIdpUrl, 'https://www.google.com/recaptcha/', + // Begin Statsig domains + 'http://api.statsig.com/', + 'http://featuregates.org/', + 'http://statsigapi.net/', + 'http://events.statsigapi.net/', + 'http://api.statsigcdn.com/', + 'http://featureassets.org/', + 'http://assetsconfigcdn.org/', + 'http://prodregistryv2.org/', + 'http://cloudflare-dns.com/', + 'http://beyondwickedmapping.org/', + // End Statsig domains ].join(' '); return `${[ diff --git a/webroot/README.md b/webroot/README.md index 5730b8c9d..035eab3a6 100644 --- a/webroot/README.md +++ b/webroot/README.md @@ -36,76 +36,102 @@ - **`BASE_URL`** - `/` to serve under domain root - Otherwise, a relative path under the domain root; don't include trailing slash + - **`VUE_APP_ENV`** + - _Server_ :arrow_heading_up: + - IA Test: `ia-test` + - CSG Test: `csg-test` + - Beta: `beta` + - Prod: `production` + - _Local_ :arrow_heading_down: + - `local` - **`VUE_APP_ROBOTS_META`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `noindex,nofollow` - - Test(CSG-Test): `noindex,nofollow` + - IA Test: `noindex,nofollow` + - CSG Test: `noindex,nofollow` + - Beta: `noindex,nofollow` - Prod: `nofollow` - _Local_ :arrow_heading_down: - `noindex,nofollow` - **`VUE_APP_DOMAIN`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `https://app.test.jcc.iaapi.io` - - Test(CSG-Test): `https://app.test.compactconnect.org` + - IA Test: `https://app.test.jcc.iaapi.io` + - CSG Test: `https://app.test.compactconnect.org` + - Beta: `https://app.beta.compactconnect.org` - Prod: `https://app.compactconnect.org` - _Local_ :arrow_heading_down: - `http://localhost:3018` - **`VUE_APP_API_STATE_ROOT`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `https://api.test.jcc.iaapi.io` - - Test(CSG-Test): `https://api.test.compactconnect.org` + - IA Test: `https://api.test.jcc.iaapi.io` + - CSG Test: `https://api.test.compactconnect.org` + - Beta: `https://api.beta.compactconnect.org` - Prod: `https://api.compactconnect.org` - _Local_ :arrow_heading_down: - `https://api.test.jcc.iaapi.io` - **`VUE_APP_API_LICENSE_ROOT`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `https://api.test.jcc.iaapi.io` - - Test(CSG-Test): `https://api.test.compactconnect.org` + - IA Test: `https://api.test.jcc.iaapi.io` + - CSG Test: `https://api.test.compactconnect.org` + - Beta: `https://api.beta.compactconnect.org` - Prod: `https://api.compactconnect.org` - _Local_ :arrow_heading_down: - `https://api.test.jcc.iaapi.io` - **`VUE_APP_COGNITO_REGION`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `us-east-1` - - Test(CSG-Test): `us-east-1` + - IA Test: `us-east-1` + - CSG Test: `us-east-1` + - Beta: `us-east-1` - Prod: `us-east-1` - _Local_ :arrow_heading_down: - `us-east-1` - **`VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `https://ia-cc-provider-test.auth.us-east-1.amazoncognito.com` - - Test(CSG-Test): `https://compact-connect-provider-test.auth.us-east-1.amazoncognito.com` + - IA Test: `https://ia-cc-provider-test.auth.us-east-1.amazoncognito.com` + - CSG Test: `https://compact-connect-provider-test.auth.us-east-1.amazoncognito.com` + - Beta: `https://compact-connect-provider-beta.auth.us-east-1.amazoncognito.com` - Prod: `https://compact-connect-provider.auth.us-east-1.amazoncognito.com` - _Local_ :arrow_heading_down: - `https://ia-cc-provider-test.auth.us-east-1.amazoncognito.com` - **`VUE_APP_COGNITO_CLIENT_ID_LICENSEE`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `topd4vhftng5cfm3ccgkb6ejd` - - Test(CSG-Test): `6erj63mpa5tjqdtdi6vfi9q9hi` + - IA Test: `topd4vhftng5cfm3ccgkb6ejd` + - CSG Test: `6erj63mpa5tjqdtdi6vfi9q9hi` + - Beta: TODO - Prod: `3dp0nf7acvtavqlbec6p4t20to` - _Local_ :arrow_heading_down: - `topd4vhftng5cfm3ccgkb6ejd` - **`VUE_APP_COGNITO_AUTH_DOMAIN_STAFF`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `https://ia-cc-staff-test.auth.us-east-1.amazoncognito.com` - - Test(CSG-Test): `https://compact-connect-staff-test.auth.us-east-1.amazoncognito.com` + - IA Test: `https://ia-cc-staff-test.auth.us-east-1.amazoncognito.com` + - CSG Test: `https://compact-connect-staff-test.auth.us-east-1.amazoncognito.com` + - Beta: `https://compact-connect-staff-beta.auth.us-east-1.amazoncognito.com` - Prod: `https://compact-connect-staff.auth.us-east-1.amazoncognito.com` - _Local_ :arrow_heading_down: - `https://ia-cc-staff-test.auth.us-east-1.amazoncognito.com` - **`VUE_APP_COGNITO_CLIENT_ID_STAFF`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `15mh24ea4af3of8jcnv8h2ic10` - - Test(CSG-Test): `75uq274pv8ufhc1g1h4n86gp1l` + - IA Test: `15mh24ea4af3of8jcnv8h2ic10` + - CSG Test: `75uq274pv8ufhc1g1h4n86gp1l` + - Beta: TODO - Prod: `4mnd3u2rp30ssgnm7dk81jcqsc` - _Local_ :arrow_heading_down: - `15mh24ea4af3of8jcnv8h2ic10` - **`VUE_APP_RECAPTCHA_KEY`** - _Server_ :arrow_heading_up: - - Dev(JCC-Test): `6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih` - - Test(CSG-Test): `6LcWQMkqAAAAAL_Wkh6Ik_HSqSqNqROzOyPCrvNC` + - IA Test: `6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih` + - CSG Test: `6LcWQMkqAAAAAL_Wkh6Ik_HSqSqNqROzOyPCrvNC` + - Beta: `6LcWQMkqAAAAAL_Wkh6Ik_HSqSqNqROzOyPCrvNC` - Prod: `6LcEQckqAAAAAJUQDEO1KsoeH17-EH5h2UfrwdyK` - _Local_ :arrow_heading_down: - `6Le-3bgqAAAAAILDVUKkRnAF9SSzb8o9uv5lY7Ih` + - **`VUE_APP_STATSIG_KEY`** + - _Server_ :arrow_heading_up: + - IA Test: TODO + - CSG Test: TODO + - Beta: TODO + - Prod: TODO + - _Local_ :arrow_heading_down: + - TODO - **`VUE_APP_MOCK_API`** :arrow_heading_down: - Only used for local development - `true` if mock API should be used diff --git a/webroot/nyc.config.js b/webroot/nyc.config.js index 6bcae19e4..f7456a73f 100644 --- a/webroot/nyc.config.js +++ b/webroot/nyc.config.js @@ -17,6 +17,7 @@ module.exports = { '**/*.d.ts', '**/mock*.ts', '**/exampleApi/**/*.*', + '**/statsig.plugin.ts', ], extension: [ '.ts', diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts index e9243fecc..c3a468ff2 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts @@ -24,7 +24,7 @@ const populateComponentStorePagingKeys = (component) => { describe('LicenseeList component', async () => { it('should mount the component', async () => { - const wrapper = await mountFull(LicenseeList); // mounting full here to get ahead of some vue-test-utils oddities in fast local environments + const wrapper = await mountShallow(LicenseeList); expect(wrapper.exists()).to.equal(true); expect(wrapper.findComponent(LicenseeList).exists()).to.equal(true); diff --git a/webroot/src/components/Lists/Sorting/Sorting.ts b/webroot/src/components/Lists/Sorting/Sorting.ts index f0d6bfa9d..104fc3268 100644 --- a/webroot/src/components/Lists/Sorting/Sorting.ts +++ b/webroot/src/components/Lists/Sorting/Sorting.ts @@ -103,7 +103,9 @@ class Sorting extends mixins(MixinForm) { await this.$store.dispatch('sorting/updateSortOption', { sortingId, newOption }); } - this.sortChange(sortOptions.value, Boolean(sortDirection.value === SortDirection.asc)); + if (typeof this.sortChange === 'function') { + this.sortChange(sortOptions.value, Boolean(sortDirection.value === SortDirection.asc)); + } } } @@ -120,7 +122,9 @@ class Sorting extends mixins(MixinForm) { await this.$store.dispatch('sorting/updateSortDirection', { sortingId, newDirection }); } - this.sortChange(sortOptions.value, Boolean(sortDirection.value === SortDirection.asc)); // ...but we'll also fire the change method property too for now + if (typeof this.sortChange === 'function') { + this.sortChange(sortOptions.value, Boolean(sortDirection.value === SortDirection.asc)); + } } } diff --git a/webroot/src/plugins/Statsig/statsig.plugin.ts b/webroot/src/plugins/Statsig/statsig.plugin.ts index 49e39a158..e787b3769 100644 --- a/webroot/src/plugins/Statsig/statsig.plugin.ts +++ b/webroot/src/plugins/Statsig/statsig.plugin.ts @@ -39,17 +39,28 @@ export const getStatsigEnvironment = () => { return statsigEnvironment; }; -export const initStatsig = async () => { +export const getStatsigClient = async () => { + const { isAppProduction, isAppBeta, isAppTest } = envConfig; const statsigEnvironment = getStatsigEnvironment(); + const plugins: any = []; + + // Setup Statsig analytics + if (isAppProduction || isAppBeta || isAppTest) { + plugins.push(new StatsigAutoCapturePlugin()); + } + + // Setup Statsig session replay + if (isAppProduction || isAppBeta || isAppTest) { + plugins.push(new StatsigSessionReplayPlugin()); + } + + // Create and initialize the Statsig client const statsigClient = new StatsigClient( envConfig.statsigKey || '', {}, { environment: { tier: statsigEnvironment }, - plugins: [ - new StatsigSessionReplayPlugin(), - new StatsigAutoCapturePlugin(), - ], + plugins, } ); @@ -58,6 +69,21 @@ export const initStatsig = async () => { return statsigClient; }; +export const getStatsigClientMock = async () => ({ + checkGate: (gateId = '') => { + const disabledGates = ['disabled-gate-1']; + + return !disabledGates.includes(gateId); + }, +}); + +export const initStatsig = async () => { + const { isTest, isUsingMockApi } = envConfig; + const statsigClient = (isTest || isUsingMockApi) ? getStatsigClientMock() : await getStatsigClient(); + + return statsigClient; +}; + export default { install: (app, { statsigClient }) => { app.config.globalProperties.$features = statsigClient; diff --git a/webroot/tests/helpers/setup.ts b/webroot/tests/helpers/setup.ts index 82add17b1..73886bf50 100644 --- a/webroot/tests/helpers/setup.ts +++ b/webroot/tests/helpers/setup.ts @@ -12,6 +12,7 @@ import routes from '@router/routes'; import { DataApi } from '@network/mocks/mock.data.api'; import mockStore from '@tests/mocks/mockStore'; import mockEnvConfig from '@tests/mocks/mockEnvConfig'; +import { getStatsigClientMock } from '@plugins/Statsig/statsig.plugin'; import { relativeTimeFormats } from '@/app.config'; import { VueResponsiveness } from 'vue-responsiveness'; import i18n from '@/i18n'; @@ -162,6 +163,7 @@ const mockApi = sinon.createStubInstance(DataApi); const mountShallow = async (component, mountConfig: any = {}) => { const router = createRouter({ routes, history: createWebHistory() }); const store = mockStore; + const statsigClientMock = await getStatsigClientMock(); const config: any = { global: { plugins: [ @@ -182,6 +184,8 @@ const mountShallow = async (component, mountConfig: any = {}) => { $api: mockApi, $t: sinon.spy(() => ''), $i18n: { locale: 'en' }, + $features: statsigClientMock, + $analytics: statsigClientMock, }, stubs: { transition: true, @@ -212,6 +216,7 @@ const mountShallow = async (component, mountConfig: any = {}) => { const mountFull = async (component, mountConfig: any = {}) => { const router = createRouter({ routes, history: createWebHistory() }); const store = mockStore; + const statsigClientMock = await getStatsigClientMock(); const config: any = { global: { plugins: [ @@ -232,6 +237,8 @@ const mountFull = async (component, mountConfig: any = {}) => { $api: mockApi, $t: sinon.spy(() => ''), $i18n: { locale: 'en' }, + $features: statsigClientMock, + $analytics: statsigClientMock, }, stubs: { transition: true, From 345b155a6ac5c2619631b586d7928623a63a1846 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Tue, 23 Sep 2025 13:46:46 -0600 Subject: [PATCH 03/10] WIP: Feature flags - @todo: Remove sample gate check & display in PublicDashboard - @todo: Work with backend to set new env vars in CDK build - @todo: Work with backend to make sure CSP Lambda hash builds as expected --- .github/workflows/check-webroot.yml | 1 - webroot/src/components/App/App.ts | 39 +++++++++++++++++++ webroot/src/pages/Logout/Logout.ts | 9 +++++ .../pages/PublicDashboard/PublicDashboard.ts | 10 ++--- .../pages/PublicDashboard/PublicDashboard.vue | 3 ++ webroot/src/plugins/Statsig/statsig.plugin.ts | 5 ++- 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check-webroot.yml b/.github/workflows/check-webroot.yml index c84b1ff74..5f89d9720 100644 --- a/.github/workflows/check-webroot.yml +++ b/.github/workflows/check-webroot.yml @@ -11,7 +11,6 @@ on: - development - ia-development - ia-web-development - - frontend/feature-flags # @TODO: Remove after feature branch paths: - webroot/** diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index 47718cfda..85b4c7f78 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -21,6 +21,7 @@ import { import { CompactType } from '@models/Compact/Compact.model'; import PageContainer from '@components/Page/PageContainer/PageContainer.vue'; import Modal from '@components/Modal/Modal.vue'; +import { StatsigUser } from '@statsig/js-client'; import moment from 'moment'; @Component({ @@ -38,6 +39,7 @@ class App extends Vue { // Data // body = document.body; + featureGateFetchIntervalId: number | undefined = undefined; // // Lifecycle @@ -48,6 +50,11 @@ class App extends Vue { } this.setRelativeTimeFormats(); + this.setFeatureGateRefetchInterval(); + } + + async beforeUnmount() { + this.clearFeatureGateRefetchInterval(); } // @@ -118,6 +125,38 @@ class App extends Vue { } else if (authType === AuthTypes.LICENSEE) { await this.$store.dispatch('user/getLicenseeAccountRequest'); } + + this.updateAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps + } + + async updateAnalyticsUser(): Promise { + const { model: appUser } = this.userStore; + const analyticsUser: StatsigUser = {}; + + if (appUser?.id) { + analyticsUser.userID = appUser.id; + } + if (appUser?.compactConnectEmail) { + analyticsUser.email = appUser.compactConnectEmail; + } + + try { + await this.$analytics.updateUserAsync(analyticsUser); + } catch (err) { + // Continue + } + } + + setFeatureGateRefetchInterval(): void { + const refetchIntervalMs = moment.duration(1, 'minute').asMilliseconds(); + + this.featureGateFetchIntervalId = (window as Window).setInterval(() => { + this.updateAnalyticsUser(); + }, refetchIntervalMs); + } + + clearFeatureGateRefetchInterval(): void { + (window as Window).clearInterval(this.featureGateFetchIntervalId); } async setCurrentCompact(): Promise { diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index d33ea7543..43b4af619 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -91,11 +91,20 @@ export default class Logout extends Vue { async logoutChecklist(isRemoteLoggedInAsLicenseeOnly): Promise { const authType = (isRemoteLoggedInAsLicenseeOnly) ? AuthTypes.LICENSEE : AuthTypes.STAFF; + this.unsetAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps this.stashWorkingUri(); this.$store.dispatch('user/clearRefreshTokenTimeout'); await this.$store.dispatch('user/logoutRequest', authType); } + async unsetAnalyticsUser(): Promise { + try { + await this.$analytics.updateUserAsync({}); + } catch (err) { + // Continue + } + } + stashWorkingUri(): void { const { workingUri } = this; const authType = authStorage.getItem(AUTH_TYPE); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.ts index 90d260fe5..a7a5d04dd 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -37,12 +37,6 @@ export default class DashboardPublic extends Vue { if (this.bypassQuery) { this.bypassRedirect(); } - - if (this.$features.checkGate('test-feature-1')) { - console.log('ON all the way'); - } else { - console.log('OFF bro'); - } } // @@ -76,6 +70,10 @@ export default class DashboardPublic extends Vue { return this.$envConfig.isUsingMockApi || false; } + get shouldShowSampleGateText(): boolean { + return this.$features.checkGate('test-feature-1'); + } + // // Methods // diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.vue b/webroot/src/pages/PublicDashboard/PublicDashboard.vue index e9727385f..7b9905f4f 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.vue +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.vue @@ -15,6 +15,9 @@ :alt="$t('common.appName')" /> +
+

Sample gate text

+
{ export const getStatsigClient = async () => { const { isAppProduction, isAppBeta, isAppTest } = envConfig; const statsigEnvironment = getStatsigEnvironment(); - const plugins: any = []; + const plugins: Array> = []; // Setup Statsig analytics if (isAppProduction || isAppBeta || isAppTest) { @@ -70,6 +70,7 @@ export const getStatsigClient = async () => { }; export const getStatsigClientMock = async () => ({ + updateUserAsync: async (user) => user, checkGate: (gateId = '') => { const disabledGates = ['disabled-gate-1']; From 0b2184df7f2429d5dc44ecfdd6f34c8a90265777 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Thu, 25 Sep 2025 12:49:59 -0600 Subject: [PATCH 04/10] WIP: Feature flags - @todo: Look at test coverage improvements - @todo: Work with backend to set new env vars in CDK build - @todo: Work with backend to make sure CSP Lambda hash builds as expected --- webroot/src/app.config.ts | 7 ++ .../ExampleFeatureGates.less | 20 +++++ .../ExampleFeatureGates.spec.ts | 19 +++++ .../ExampleFeatureGates.ts | 68 ++++++++++++++++ .../ExampleFeatureGates.vue | 79 +++++++++++++++++++ webroot/src/locales/en.json | 9 ++- webroot/src/locales/es.json | 9 ++- webroot/src/main.ts | 54 ++++++------- .../CompactFeeConfig.model.ts | 8 ++ webroot/src/models/License/License.model.ts | 8 +- webroot/src/models/Licensee/Licensee.model.ts | 8 +- .../models/LicenseeUser/LicenseeUser.model.ts | 6 +- .../src/models/StaffUser/StaffUser.model.ts | 6 +- webroot/src/models/User/User.model.ts | 8 +- webroot/src/network/data.api.ts | 14 +++- webroot/src/network/exampleApi/data.api.ts | 17 +++- webroot/src/network/mocks/mock.data.api.ts | 10 +++ .../pages/PublicDashboard/PublicDashboard.ts | 4 - .../pages/PublicDashboard/PublicDashboard.vue | 3 - webroot/src/pages/StyleGuide/StyleGuide.less | 2 +- webroot/src/pages/StyleGuide/StyleGuide.ts | 2 + webroot/src/pages/StyleGuide/StyleGuide.vue | 1 + .../store/styleguide/styleguide.actions.ts | 9 +++ 23 files changed, 324 insertions(+), 47 deletions(-) create mode 100644 webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.less create mode 100644 webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.spec.ts create mode 100644 webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.ts create mode 100644 webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.vue diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 8082080fd..df803d58e 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -285,6 +285,13 @@ export const compacts = { coun: {}, }; +// ============================= +// = Feature gate IDs = +// ============================= +export enum FeatureGates { + EXAMPLE_FEATURE_1 = 'test-feature-1', // Keep this ID in place for examples & tests +} + export default { authStorage, tokens, diff --git a/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.less b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.less new file mode 100644 index 000000000..6db5b3f61 --- /dev/null +++ b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.less @@ -0,0 +1,20 @@ +// +// ExampleFeatureGates.less +// CompactConnect +// +// Created by InspiringApps on 9/25/2025. +// + +.example-feature-gates-container { + .example-feature-gate { + margin-bottom: 2.4rem; + + .example-feature-gate-layer { + font-weight: @fontWeightBold; + } + + .example-feature-gate-name { + margin-right: 1.2rem; + } + } +} diff --git a/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.spec.ts b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.spec.ts new file mode 100644 index 000000000..b26cd9a4f --- /dev/null +++ b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.spec.ts @@ -0,0 +1,19 @@ +// +// ExampleFeatureGates.spec.ts +// CompactConnect +// +// Created by InspiringApps on 9/25/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import ExampleFeatureGates from '@components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.vue'; + +describe('ExampleFeatureGates component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(ExampleFeatureGates); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(ExampleFeatureGates).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.ts b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.ts new file mode 100644 index 000000000..73f48eea0 --- /dev/null +++ b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.ts @@ -0,0 +1,68 @@ +// +// ExampleFeatureGates.ts +// CompactConnect +// +// Created by InspiringApps on 9/25/2025. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; +import { FeatureGates } from '@/app.config'; +import Section from '@components/Section/Section.vue'; +import { User } from '@models/User/User.model'; +import { dataApi } from '@network/data.api'; + +@Component({ + name: 'ExampleFeatureGates', + components: { + Section, + }, +}) +class ExampleFeatureGates extends Vue { + // + // Data + // + isFeatureGateEnabledInStoreLayer = false; + isFeatureGateEnabledInModelLayer = false; + isFeatureGateEnabledInNetworkLayer = false; + + // + // Lifecycle + // + async created(): Promise { + await this.setFeatureGateEnabledInStoreLayer(); + await this.setFeatureGateEnabledInModelLayer(); + await this.setFeatureGateEnabledInNetworkLayer(); + } + + // + // Computed + // + get featureGates(): typeof FeatureGates { + return FeatureGates; + } + + // + // Methods + // + async setFeatureGateEnabledInStoreLayer(): Promise { + const isEnabled = await this.$store.dispatch('styleguide/getFeatureGateExample'); + + this.isFeatureGateEnabledInStoreLayer = isEnabled; + } + + async setFeatureGateEnabledInModelLayer(): Promise { + const userModel = new User(); + + this.isFeatureGateEnabledInModelLayer = userModel.$features?.checkGate(FeatureGates.EXAMPLE_FEATURE_1) || false; + } + + async setFeatureGateEnabledInNetworkLayer(): Promise { + const isEnabled = await dataApi.getExampleFeatureGate(); + + this.isFeatureGateEnabledInNetworkLayer = isEnabled; + } +} + +export default toNative(ExampleFeatureGates); + +// export default ExampleFeatureGates; diff --git a/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.vue b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.vue new file mode 100644 index 000000000..6ba5cfb28 --- /dev/null +++ b/webroot/src/components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.vue @@ -0,0 +1,79 @@ + + + + + + diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index cc980d74c..523e749a8 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -1035,7 +1035,14 @@ "openModalCustomActions": "Open Modal with Custom Actions", "loadingSpinner": "Loading Spinner", "showSpinner": "Show Spinner", - "mobileStoreLinks": "Mobile Store Links" + "mobileStoreLinks": "Mobile Store Links", + "exampleFeatureGates": "Example Feature Gates", + "exampleFeatureGateLayerComponent": "Evaluated in component", + "exampleFeatureGateLayerStore": "Evaluated in store", + "exampleFeatureGateLayerModel": "Evaluated in model", + "exampleFeatureGateLayerNetwork": "Evaluated in network layer", + "exampleFeatureGateOn": "Enabled", + "exampleFeatureGateOff": "Disabled" }, "homeJurisdictionChange": { "formTitle": "Change home state", diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index 82fefe357..df085b175 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -1034,7 +1034,14 @@ "openModalCustomActions": "Abre el modal con acciones personalizadas.", "loadingSpinner": "Indicador de funcionamiento", "showSpinner": "Mostrar el indicador", - "mobileStoreLinks": "Enlaces de tiendas móviles" + "mobileStoreLinks": "Enlaces de tiendas móviles", + "exampleFeatureGates": "Puertas de características de ejemplo", + "exampleFeatureGateLayerComponent": "Evaluado en componente", + "exampleFeatureGateLayerStore": "Evaluado en tienda", + "exampleFeatureGateLayerModel": "Evaluado en el modelo", + "exampleFeatureGateLayerNetwork": "Evaluado en la capa de red", + "exampleFeatureGateOn": "Activado", + "exampleFeatureGateOff": "Desactivado" }, "homeJurisdictionChange": { "formTitle": "Cambiar estado de residencia", diff --git a/webroot/src/main.ts b/webroot/src/main.ts index 5e7e27e6e..27d707608 100644 --- a/webroot/src/main.ts +++ b/webroot/src/main.ts @@ -34,15 +34,41 @@ import './registerServiceWorker'; // Inject router into API interceptors (avoids circular dependency) network.dataApi.initInterceptors(router); + // + // ALLOW ACCESS TO VUE INSTANCE SERVICES + // + // Attach any services that aren't automatically attached to the Vue instance + const { globalProperties } = app.config; + const { t: $t, tm: $tm } = i18n.global; + + if (!globalProperties.$t) { + (globalProperties as any).$t = $t; + } + + if (!globalProperties.$tm) { + (globalProperties as any).$tm = $tm; + } + + if (!globalProperties.$features) { + (globalProperties as any).$features = statsigClient; + } + + if (!globalProperties.$analytics) { + (globalProperties as any).$analytics = statsigClient; + } + + // Make Vue available globally + (window as any).Vue = app || {}; + // // INJECT PLUGINS // app.use(envConfig); + app.use(statsig, { statsigClient }); app.use(router); app.use(store); app.use(i18n); app.use(api); - app.use(statsig, { statsigClient }); app.use(vClickOutside); app.use(VueResponsiveness, { phone: 0, @@ -83,32 +109,6 @@ import './registerServiceWorker'; }, }); - // - // ALLOW ACCESS TO VUE INSTANCE SERVICES - // - // Attach any services that aren't automatically attached to the Vue instance - const { globalProperties } = app.config; - const { t: $t, tm: $tm } = i18n.global; - - if (!globalProperties.$t) { - (globalProperties as any).$t = $t; - } - - if (!globalProperties.$tm) { - (globalProperties as any).$tm = $tm; - } - - if (!globalProperties.$features) { - (globalProperties as any).$features = statsigClient; - } - - if (!globalProperties.$analytics) { - (globalProperties as any).$analytics = statsigClient; - } - - // Make Vue available globally - (window as any).Vue = app || {}; - // // MOUNT // diff --git a/webroot/src/models/CompactFeeConfig/CompactFeeConfig.model.ts b/webroot/src/models/CompactFeeConfig/CompactFeeConfig.model.ts index ab67be459..12ca9e8e2 100644 --- a/webroot/src/models/CompactFeeConfig/CompactFeeConfig.model.ts +++ b/webroot/src/models/CompactFeeConfig/CompactFeeConfig.model.ts @@ -7,6 +7,7 @@ import { FeeTypes } from '@/app.config'; import { deleteUndefinedProperties } from '@models/_helpers'; +import { StatsigClient } from '@statsig/js-client'; // ======================================================== // = Interface = @@ -30,6 +31,7 @@ export interface InterfaceCompactFeeConfigCreate { // = Model = // ======================================================== export class CompactFeeConfig implements InterfaceCompactFeeConfigCreate { + public $features?: StatsigClient | null = null; public compactType? = ''; public compactCommissionFee? = 0; public compactCommissionFeeType? = null; @@ -39,6 +41,12 @@ export class CompactFeeConfig implements InterfaceCompactFeeConfigCreate { constructor(data?: InterfaceCompactFeeConfigCreate) { const cleanDataObject = deleteUndefinedProperties(data); + const global = window as any; + const { $features } = global.Vue?.config?.globalProperties || {}; + + if ($features) { + this.$features = $features; + } Object.assign(this, cleanDataObject); } diff --git a/webroot/src/models/License/License.model.ts b/webroot/src/models/License/License.model.ts index 87fb6f27c..1c27b17af 100644 --- a/webroot/src/models/License/License.model.ts +++ b/webroot/src/models/License/License.model.ts @@ -14,6 +14,7 @@ import { LicenseHistoryItem } from '@models/LicenseHistoryItem/LicenseHistoryIte import { Address, AddressSerializer } from '@models/Address/Address.model'; import { AdverseAction, AdverseActionSerializer } from '@models/AdverseAction/AdverseAction.model'; import moment from 'moment'; +import { StatsigClient } from '@statsig/js-client'; // ======================================================== // = Interface = @@ -70,6 +71,7 @@ export interface InterfaceLicense { export class License implements InterfaceLicense { // This model is used to represent both privileges and licenses as their shape almost entirely overlaps public $tm?: any = () => []; + public $features?: StatsigClient | null = null; public id? = null; public compact? = null; public isPrivilege? = false; @@ -93,12 +95,16 @@ export class License implements InterfaceLicense { constructor(data?: InterfaceLicense) { const cleanDataObject = deleteUndefinedProperties(data); const global = window as any; - const $tm = global.Vue?.config?.globalProperties?.$tm; + const { $tm, $features } = global.Vue?.config?.globalProperties || {}; if ($tm) { this.$tm = $tm; } + if ($features) { + this.$features = $features; + } + Object.assign(this, cleanDataObject); } diff --git a/webroot/src/models/Licensee/Licensee.model.ts b/webroot/src/models/Licensee/Licensee.model.ts index 5e1887df5..127824ea0 100644 --- a/webroot/src/models/Licensee/Licensee.model.ts +++ b/webroot/src/models/Licensee/Licensee.model.ts @@ -19,6 +19,7 @@ import { import { MilitaryAffiliation, MilitaryAffiliationSerializer } from '@models/MilitaryAffiliation/MilitaryAffiliation.model'; import { State } from '@models/State/State.model'; import moment from 'moment'; +import { StatsigClient } from '@statsig/js-client'; /** * This model is used to represent both get one and get all server responses @@ -62,6 +63,7 @@ export interface InterfaceLicensee { export class Licensee implements InterfaceLicensee { public $tm?: any = () => []; public $t?: any = () => ''; + public $features?: StatsigClient | null = null; public id? = null; public npi? = null; public licenseNumber?= null; @@ -85,13 +87,17 @@ export class Licensee implements InterfaceLicensee { constructor(data?: InterfaceLicensee) { const cleanDataObject = deleteUndefinedProperties(data); const global = window as any; - const { $tm, $t } = global.Vue?.config?.globalProperties || {}; + const { $tm, $t, $features } = global.Vue?.config?.globalProperties || {}; if ($tm) { this.$tm = $tm; this.$t = $t; } + if ($features) { + this.$features = $features; + } + Object.assign(this, cleanDataObject); } diff --git a/webroot/src/models/LicenseeUser/LicenseeUser.model.ts b/webroot/src/models/LicenseeUser/LicenseeUser.model.ts index e9eaaa3b6..b3a26d013 100644 --- a/webroot/src/models/LicenseeUser/LicenseeUser.model.ts +++ b/webroot/src/models/LicenseeUser/LicenseeUser.model.ts @@ -30,13 +30,17 @@ export class LicenseeUser extends User implements InterfaceLicenseeUserCreate { super(data); const cleanDataObject = deleteUndefinedProperties(data); const global = window as any; - const { $tm, $t } = global.Vue?.config?.globalProperties || {}; + const { $tm, $t, $features } = global.Vue?.config?.globalProperties || {}; if ($tm) { this.$tm = $tm; this.$t = $t; } + if ($features) { + this.$features = $features; + } + Object.assign(this, cleanDataObject); } } diff --git a/webroot/src/models/StaffUser/StaffUser.model.ts b/webroot/src/models/StaffUser/StaffUser.model.ts index 41f143782..c42f9b59e 100644 --- a/webroot/src/models/StaffUser/StaffUser.model.ts +++ b/webroot/src/models/StaffUser/StaffUser.model.ts @@ -46,13 +46,17 @@ export class StaffUser extends User implements InterfaceStaffUserCreate { super(data); const cleanDataObject = deleteUndefinedProperties(data); const global = window as any; - const { $tm, $t } = global.Vue?.config?.globalProperties || {}; + const { $tm, $t, $features } = global.Vue?.config?.globalProperties || {}; if ($tm) { this.$tm = $tm; this.$t = $t; } + if ($features) { + this.$features = $features; + } + Object.assign(this, cleanDataObject); } diff --git a/webroot/src/models/User/User.model.ts b/webroot/src/models/User/User.model.ts index f5f17f95f..da90e8257 100644 --- a/webroot/src/models/User/User.model.ts +++ b/webroot/src/models/User/User.model.ts @@ -8,6 +8,7 @@ /* eslint-disable max-classes-per-file */ import { AuthTypes } from '@/app.config'; import { deleteUndefinedProperties } from '@models/_helpers'; +import { StatsigClient } from '@statsig/js-client'; // ======================================================== // = Interface = @@ -28,6 +29,7 @@ export interface InterfaceUserCreate { export class User implements InterfaceUserCreate { public $tm?: any = () => []; public $t?: any = () => ''; + public $features?: StatsigClient | null = null; public id? = null; public firstName? = null; public lastName? = null; @@ -39,13 +41,17 @@ export class User implements InterfaceUserCreate { constructor(data?: InterfaceUserCreate) { const cleanDataObject = deleteUndefinedProperties(data); const global = window as any; - const { $tm, $t } = global.Vue?.config?.globalProperties || {}; + const { $tm, $t, $features } = global.Vue?.config?.globalProperties || {}; if ($tm) { this.$tm = $tm; this.$t = $t; } + if ($features) { + this.$features = $features; + } + Object.assign(this, cleanDataObject); } diff --git a/webroot/src/network/data.api.ts b/webroot/src/network/data.api.ts index 3c2b9d9d2..7fc5332ed 100644 --- a/webroot/src/network/data.api.ts +++ b/webroot/src/network/data.api.ts @@ -538,7 +538,7 @@ export class DataApi { /** * Example of app-specific API call which is defined in a subfolder * in the /network directory and which has their own data.api.ts & interceptors.ts. - * @return {Promise.} + * @return {Promise} */ public getStyleguidePetsCount(config?: any) { return exampleDataApi.getStyleguidePetsCount(config); @@ -547,7 +547,7 @@ export class DataApi { /** * Example of app-specific API call which is defined in a subfolder * in the /network directory and which has their own data.api.ts & interceptors.ts. - * @return {Promise.} + * @return {Promise} */ public getStyleguidePets(config?: any) { return exampleDataApi.getStyleguidePets(config); @@ -556,11 +556,19 @@ export class DataApi { /** * Example of app-specific API call which is defined in a subfolder * in the /network directory and which has their own data.api.ts & interceptors.ts. - * @return {Promise.} + * @return {Promise} */ public getAccount() { return exampleDataApi.getAccount(); } + + /** + * Example of a network call that can evaluate feature gates if needed. + * @return {Promise} + */ + public getExampleFeatureGate() { + return exampleDataApi.getExampleFeatureGate(); + } } export const dataApi = new DataApi(); diff --git a/webroot/src/network/exampleApi/data.api.ts b/webroot/src/network/exampleApi/data.api.ts index 9e82ff4b3..44d50a975 100644 --- a/webroot/src/network/exampleApi/data.api.ts +++ b/webroot/src/network/exampleApi/data.api.ts @@ -5,7 +5,8 @@ // Created by InspiringApps on 5/6/20. // -import axios, { AxiosInstance } from 'axios'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { FeatureGates } from '@/app.config'; import { requestError, requestSuccess, @@ -13,7 +14,7 @@ import { responseError } from '@network/exampleApi/interceptors'; import { userData, pets } from '@network/mocks/mock.data'; -import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import axios, { AxiosInstance } from 'axios'; export interface DataApiInterface { api: AxiosInstance; @@ -178,6 +179,18 @@ export class ExampleDataApi implements DataApiInterface { return this.wait(1000).then(() => response); } + + /** + * Example of a network layer function that can evaluate feature gates. + * @return {Promise} The feature gate evaluation. + */ + public getExampleFeatureGate() { + const { $features } = (window as any).Vue?.config?.globalProperties || {}; + + // Obviously network call functions aren't needed to *just* check a feature gate; + // This is just an example of how a feature gate can be evaluated in a network call if needed. + return this.wait(0).then(() => $features?.checkGate(FeatureGates.EXAMPLE_FEATURE_1) || false); + } } export const exampleDataApi = new ExampleDataApi(); diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index 31355c2a2..26c6dfe95 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -6,6 +6,7 @@ // import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; +import { FeatureGates } from '@/app.config'; import { LicenseeSerializer } from '@models/Licensee/Licensee.model'; import { LicenseHistoryItem, LicenseHistoryItemSerializer } from '@/models/LicenseHistoryItem/LicenseHistoryItem.model'; import { LicenseeUserSerializer } from '@models/LicenseeUser/LicenseeUser.model'; @@ -626,6 +627,15 @@ export class DataApi { return serializedUser; }); } + + // Perform an example feature gate check withn a network call + public getExampleFeatureGate() { + const { $features } = (window as any).Vue?.config?.globalProperties || {}; + + // Obviously network call functions aren't needed to *just* check a feature gate; + // This is just an example of how a feature gate can be evaluated in a network call if needed. + return wait(0).then(() => $features?.checkGate(FeatureGates.EXAMPLE_FEATURE_1) || false); + } } export const dataApi = new DataApi(); diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.ts b/webroot/src/pages/PublicDashboard/PublicDashboard.ts index a7a5d04dd..9c123d2cc 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.ts +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.ts @@ -70,10 +70,6 @@ export default class DashboardPublic extends Vue { return this.$envConfig.isUsingMockApi || false; } - get shouldShowSampleGateText(): boolean { - return this.$features.checkGate('test-feature-1'); - } - // // Methods // diff --git a/webroot/src/pages/PublicDashboard/PublicDashboard.vue b/webroot/src/pages/PublicDashboard/PublicDashboard.vue index 7b9905f4f..e9727385f 100644 --- a/webroot/src/pages/PublicDashboard/PublicDashboard.vue +++ b/webroot/src/pages/PublicDashboard/PublicDashboard.vue @@ -15,9 +15,6 @@ :alt="$t('common.appName')" /> -
-

Sample gate text

-
* { width: 100%; diff --git a/webroot/src/pages/StyleGuide/StyleGuide.ts b/webroot/src/pages/StyleGuide/StyleGuide.ts index 730430897..3a425ec3f 100644 --- a/webroot/src/pages/StyleGuide/StyleGuide.ts +++ b/webroot/src/pages/StyleGuide/StyleGuide.ts @@ -11,6 +11,7 @@ import ExampleLanguageSelector from '@components/StyleGuide/ExampleLanguageSelec import ExampleForm from '@components/StyleGuide/ExampleForm/ExampleForm.vue'; import ExampleModal from '@components/StyleGuide/ExampleModal/ExampleModal.vue'; import ExampleLoadingSpinner from '@components/StyleGuide/ExampleLoadingSpinner/ExampleLoadingSpinner.vue'; +import ExampleFeatureGates from '@components/StyleGuide/ExampleFeatureGates/ExampleFeatureGates.vue'; @Component({ name: 'StyleguidePage', @@ -20,6 +21,7 @@ import ExampleLoadingSpinner from '@components/StyleGuide/ExampleLoadingSpinner/ ExampleForm, ExampleModal, ExampleLoadingSpinner, + ExampleFeatureGates, } }) export default class StyleGuide extends Vue { diff --git a/webroot/src/pages/StyleGuide/StyleGuide.vue b/webroot/src/pages/StyleGuide/StyleGuide.vue index e9037a7fa..7ef2503c6 100644 --- a/webroot/src/pages/StyleGuide/StyleGuide.vue +++ b/webroot/src/pages/StyleGuide/StyleGuide.vue @@ -13,6 +13,7 @@ +
diff --git a/webroot/src/store/styleguide/styleguide.actions.ts b/webroot/src/store/styleguide/styleguide.actions.ts index 18723df9b..0a678cbe5 100644 --- a/webroot/src/store/styleguide/styleguide.actions.ts +++ b/webroot/src/store/styleguide/styleguide.actions.ts @@ -5,6 +5,7 @@ // Created by InspiringApps on 4/12/20. // +import { FeatureGates } from '@/app.config'; import { dataApi } from '@network/data.api'; import { MutationTypes } from './styleguide.mutations'; @@ -43,6 +44,14 @@ export default { getPetsFailure: ({ commit }, error: Error) => { commit(MutationTypes.GET_PETS_FAILURE, error); }, + // GET FEATURE GATE IN STORE ACTION EXAMPLE + getFeatureGateExample: () => { + const { $features } = (window as any).Vue?.config?.globalProperties || {}; + + // Obviously store actions aren't needed to *just* check a feature gate; + // This is just an example of how a feature gate can be evaluated in a store action if needed. + return $features?.checkGate(FeatureGates.EXAMPLE_FEATURE_1) || false; + }, // SET THE STORE STATE setStorePetCount: ({ commit }, count) => { commit(MutationTypes.STORE_UPDATE_COUNT, count); From 104e795af22aa68e9931c9fb9b9e51ee4347f6bb Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Fri, 26 Sep 2025 11:12:32 -0600 Subject: [PATCH 05/10] WIP: Feature flags - @todo: Look at test coverage improvements - @todo: Work with backend to set new env vars in CDK build - @todo: Work with backend to make sure CSP Lambda hash builds as expected --- .../lambdas/nodejs/cloudfront-csp/index.js | 20 ++++----- .../nodejs/cloudfront-csp/test/index.test.js | 20 ++++----- .../components/LicenseCard/LicenseCard.vue | 4 +- .../MilitaryAffiliationInfoBlock.vue | 2 +- .../PaymentProcessorConfig.vue | 2 +- .../PrivilegeCard/PrivilegeCard.vue | 2 +- .../PrivilegePurchaseAcceptUI.ts | 3 ++ ...ivilegePurchaseInformationConfirmation.vue | 18 ++++---- .../components/UserAccount/UserAccount.vue | 6 +-- webroot/src/network/data.api.ts | 2 +- webroot/src/network/mocks/mock.data.api.ts | 2 +- .../pages/LicensingDetail/LicensingDetail.vue | 2 +- .../src/plugins/EnvConfig/envConfig.plugin.ts | 13 +++--- webroot/src/plugins/Statsig/statsig.plugin.ts | 44 ++++++++++++++----- 14 files changed, 82 insertions(+), 58 deletions(-) diff --git a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js index b75474f60..023e59629 100644 --- a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js +++ b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/index.js @@ -244,16 +244,16 @@ const setCspHeader = (headers = {}) => { cognitoIdpUrl, 'https://www.google.com/recaptcha/', // Begin Statsig domains - 'http://api.statsig.com/', - 'http://featuregates.org/', - 'http://statsigapi.net/', - 'http://events.statsigapi.net/', - 'http://api.statsigcdn.com/', - 'http://featureassets.org/', - 'http://assetsconfigcdn.org/', - 'http://prodregistryv2.org/', - 'http://cloudflare-dns.com/', - 'http://beyondwickedmapping.org/', + 'https://api.statsig.com/', + 'https://featuregates.org/', + 'https://statsigapi.net/', + 'https://events.statsigapi.net/', + 'https://api.statsigcdn.com/', + 'https://featureassets.org/', + 'https://assetsconfigcdn.org/', + 'https://prodregistryv2.org/', + 'https://cloudflare-dns.com/', + 'https://beyondwickedmapping.org/', // End Statsig domains ]), ].join(' ')}`, diff --git a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js index fa93042ae..157766d5b 100644 --- a/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js +++ b/backend/compact-connect/lambdas/nodejs/cloudfront-csp/test/index.test.js @@ -157,16 +157,16 @@ const buildCspHeaders = (environment) => { cognitoIdpUrl, 'https://www.google.com/recaptcha/', // Begin Statsig domains - 'http://api.statsig.com/', - 'http://featuregates.org/', - 'http://statsigapi.net/', - 'http://events.statsigapi.net/', - 'http://api.statsigcdn.com/', - 'http://featureassets.org/', - 'http://assetsconfigcdn.org/', - 'http://prodregistryv2.org/', - 'http://cloudflare-dns.com/', - 'http://beyondwickedmapping.org/', + 'https://api.statsig.com/', + 'https://featuregates.org/', + 'https://statsigapi.net/', + 'https://events.statsigapi.net/', + 'https://api.statsigcdn.com/', + 'https://featureassets.org/', + 'https://assetsconfigcdn.org/', + 'https://prodregistryv2.org/', + 'https://cloudflare-dns.com/', + 'https://beyondwickedmapping.org/', // End Statsig domains ].join(' '); diff --git a/webroot/src/components/LicenseCard/LicenseCard.vue b/webroot/src/components/LicenseCard/LicenseCard.vue index 17f71070f..241924bb5 100644 --- a/webroot/src/components/LicenseCard/LicenseCard.vue +++ b/webroot/src/components/LicenseCard/LicenseCard.vue @@ -69,7 +69,7 @@
{{$t('licensing.licenseNumSymbol')}}
-
{{licenseNumber}}
+
{{licenseNumber}}
{{ $t('licensing.disciplineStatus') }}
@@ -121,7 +121,7 @@
{{ $t('licensing.licenseNumber') }}
-
{{ licenseNumber }}
+
{{ licenseNumber }}
{{ $t('licensing.licenseType') }}
diff --git a/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.vue b/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.vue index ca1d6efe2..2a693df39 100644 --- a/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.vue +++ b/webroot/src/components/MilitaryAffiliationInfoBlock/MilitaryAffiliationInfoBlock.vue @@ -6,7 +6,7 @@ -->