diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html
index 530d4caca03..37c4ba67e0a 100644
--- a/apps/web/src/app/layouts/user-layout.component.html
+++ b/apps/web/src/app/layouts/user-layout.component.html
@@ -25,6 +25,10 @@
route="settings/emergency-access"
>
+
diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts
index d3e7fc495ca..83f2f7d188f 100644
--- a/apps/web/src/app/oss-routing.module.ts
+++ b/apps/web/src/app/oss-routing.module.ts
@@ -176,6 +176,22 @@ const routes: Routes = [
path: "",
component: AnonLayoutWrapperComponent,
children: [
+ // TODO: consider adding guard to prevent access to this route if env is not dev or qa.
+ // TODO: figure out why this doesn't work when other one does.
+ // this is for anon-web scenario
+ {
+ path: "feature-flags",
+ data: {
+ pageTitle: {
+ key: "featureFlags",
+ },
+ maxWidth: "3xl",
+ hideIcon: true, // TODO: log bug with UIF or offer a PR to fix where this isn't reset to false upon navigation
+ } satisfies RouteDataProperties & AnonLayoutWrapperData,
+ loadComponent: () =>
+ import("@bitwarden/angular/platform/feature-flags").then((m) => m.FeatureFlagsComponent),
+ },
+
{
path: "signup",
canActivate: [unauthGuardFn()],
@@ -700,6 +716,27 @@ const routes: Routes = [
component: SponsoredFamiliesComponent,
data: { titleId: "sponsoredFamilies" } satisfies RouteDataProperties,
},
+ // TODO: consider adding guard to prevent access to this route if env is not dev or qa.
+ {
+ path: "developer-tools",
+ data: { titleId: "developerTools" } satisfies RouteDataProperties,
+ loadComponent: () =>
+ import("./platform/settings/developer-tools").then((m) => m.DeveloperToolsComponent),
+ children: [
+ {
+ path: "",
+ redirectTo: "feature-flags",
+ pathMatch: "full",
+ },
+ {
+ path: "feature-flags",
+ loadComponent: () =>
+ import("@bitwarden/angular/platform/feature-flags").then(
+ (m) => m.FeatureFlagsComponent,
+ ),
+ },
+ ],
+ },
],
},
{
diff --git a/apps/web/src/app/platform/settings/developer-tools/developer-tools.component.html b/apps/web/src/app/platform/settings/developer-tools/developer-tools.component.html
new file mode 100644
index 00000000000..3861aae3028
--- /dev/null
+++ b/apps/web/src/app/platform/settings/developer-tools/developer-tools.component.html
@@ -0,0 +1,9 @@
+
+
+ {{ "featureFlags" | i18n }}
+
+
+
+
+
+
diff --git a/apps/web/src/app/platform/settings/developer-tools/developer-tools.component.ts b/apps/web/src/app/platform/settings/developer-tools/developer-tools.component.ts
new file mode 100644
index 00000000000..3b3fd15188c
--- /dev/null
+++ b/apps/web/src/app/platform/settings/developer-tools/developer-tools.component.ts
@@ -0,0 +1,11 @@
+import { Component } from "@angular/core";
+
+import { HeaderModule } from "../../../layouts/header/header.module";
+import { SharedModule } from "../../../shared";
+
+@Component({
+ selector: "app-developer-tools",
+ templateUrl: "./developer-tools.component.html",
+ imports: [SharedModule, HeaderModule],
+})
+export class DeveloperToolsComponent {}
diff --git a/apps/web/src/app/platform/settings/developer-tools/index.ts b/apps/web/src/app/platform/settings/developer-tools/index.ts
new file mode 100644
index 00000000000..3fea3c4e3b0
--- /dev/null
+++ b/apps/web/src/app/platform/settings/developer-tools/index.ts
@@ -0,0 +1 @@
+export * from "./developer-tools.component";
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 9150028f4d6..00fb0e9806b 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -2093,6 +2093,12 @@
"default": {
"message": "Default"
},
+ "developerTools": {
+ "message": "Developer Tools"
+ },
+ "featureFlags": {
+ "message": "Feature flags"
+ },
"domainRules": {
"message": "Domain rules"
},
diff --git a/libs/angular/src/platform/feature-flags/feature-flags.component.html b/libs/angular/src/platform/feature-flags/feature-flags.component.html
new file mode 100644
index 00000000000..d900490a4cb
--- /dev/null
+++ b/libs/angular/src/platform/feature-flags/feature-flags.component.html
@@ -0,0 +1,17 @@
+@if (loading) {
+
+
+
+} @else {
+
+
+ key |
+ value |
+
+
+
+ {{ row.key }} |
+ {{ row.value }} |
+
+
+}
diff --git a/libs/angular/src/platform/feature-flags/feature-flags.component.ts b/libs/angular/src/platform/feature-flags/feature-flags.component.ts
new file mode 100644
index 00000000000..e4d4d8eb5c9
--- /dev/null
+++ b/libs/angular/src/platform/feature-flags/feature-flags.component.ts
@@ -0,0 +1,45 @@
+import { CommonModule } from "@angular/common";
+import { Component, DestroyRef, OnInit } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
+import { map } from "rxjs";
+
+import { AllowedFeatureFlagTypes } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { TableDataSource, TableModule } from "@bitwarden/components";
+
+@Component({
+ selector: "app-feature-flags",
+ templateUrl: "./feature-flags.component.html",
+ imports: [CommonModule, TableModule],
+})
+export class FeatureFlagsComponent implements OnInit {
+ loading = true;
+ tableDataSource = new TableDataSource<{ key: string; value: AllowedFeatureFlagTypes }>();
+
+ constructor(
+ private destroyRef: DestroyRef,
+ private configService: ConfigService,
+ ) {}
+
+ ngOnInit() {
+ this.configService.featureStates$
+ .pipe(
+ takeUntilDestroyed(this.destroyRef),
+ map((states) => {
+ if (!states) {
+ return [];
+ }
+
+ // Convert the feature states object into an array of key-value pairs
+ return Object.entries(states).map(([key, value]) => ({
+ key,
+ value,
+ }));
+ }),
+ )
+ .subscribe((featureStates) => {
+ this.tableDataSource.data = featureStates;
+ this.loading = false;
+ });
+ }
+}
diff --git a/libs/angular/src/platform/feature-flags/index.ts b/libs/angular/src/platform/feature-flags/index.ts
new file mode 100644
index 00000000000..7d4448120d2
--- /dev/null
+++ b/libs/angular/src/platform/feature-flags/index.ts
@@ -0,0 +1 @@
+export * from "./feature-flags.component";
diff --git a/libs/common/src/platform/abstractions/config/config.service.ts b/libs/common/src/platform/abstractions/config/config.service.ts
index 04f150838e4..b24cb6f7282 100644
--- a/libs/common/src/platform/abstractions/config/config.service.ts
+++ b/libs/common/src/platform/abstractions/config/config.service.ts
@@ -3,7 +3,11 @@
import { Observable } from "rxjs";
import { SemVer } from "semver";
-import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum";
+import {
+ AllowedFeatureFlagTypes,
+ FeatureFlag,
+ FeatureFlagValueType,
+} from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ServerSettings } from "../../models/domain/server-settings";
import { Region } from "../environment.service";
@@ -11,11 +15,13 @@ import { Region } from "../environment.service";
import { ServerConfig } from "./server-config";
export abstract class ConfigService {
- /** The server config of the currently active user */
+ /** The server config of the environment or the currently active user */
serverConfig$: Observable;
- /** The server settings of the currently active user */
+ /** The server settings of the environment or the currently active user */
serverSettings$: Observable;
- /** The cloud region of the currently active user */
+ /** The feature states of the environment or the currently active user */
+ featureStates$: Observable<{ [key: string]: AllowedFeatureFlagTypes } | undefined>;
+ /** The cloud region of the environment or the currently active user */
cloudRegion$: Observable;
/**
* Retrieves the value of a feature flag for the currently active user
diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts
index 33f86d30885..8e36341aa8f 100644
--- a/libs/common/src/platform/services/config/default-config.service.ts
+++ b/libs/common/src/platform/services/config/default-config.service.ts
@@ -17,7 +17,11 @@ import { SemVer } from "semver";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
-import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
+import {
+ AllowedFeatureFlagTypes,
+ FeatureFlag,
+ getFeatureFlagValue,
+} from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ConfigService } from "../../abstractions/config/config.service";
@@ -56,6 +60,8 @@ export class DefaultConfigService implements ConfigService {
serverConfig$: Observable;
+ featureStates$: Observable<{ [key: string]: AllowedFeatureFlagTypes } | undefined>;
+
serverSettings$: Observable;
cloudRegion$: Observable;
@@ -116,6 +122,10 @@ export class DefaultConfigService implements ConfigService {
this.serverSettings$ = this.serverConfig$.pipe(
map((config) => config?.settings ?? new ServerSettings()),
);
+
+ this.featureStates$ = this.serverConfig$.pipe(
+ map((config) => config?.featureStates ?? undefined),
+ );
}
getFeatureFlag$(key: Flag) {