From d7f552717c693f43ee7f090a568ee6e579657ece Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:00 -0500 Subject: [PATCH 1/9] feat(core): add typed build-time feature flags and featureGuard Introduce a single source of truth for hiding non-MVP areas: a `features` map in both environment files (all off), typed by the `FeatureKey` union and checked with `satisfies FeatureFlagMap`, read through the `FeatureFlags.isEnabled()` service. Tests override flags with `provideFeatureFlags()`. `featureGuard(key, next?)` is a functional canMatch guard: while the flag is off the route does not match, so the URL falls through to the wildcard fallback like an unknown path (no blank page, no lazy chunk). An existing canMatch guard is chained through `next` because Angular runs every guard of a canMatch array eagerly. --- src/app/core/features/feature-flags.ts | 42 ++++++++++++ src/app/core/guards/feature.guard.spec.ts | 82 +++++++++++++++++++++++ src/app/core/guards/feature.guard.ts | 24 +++++++ src/environments/environment.prod.ts | 13 ++++ src/environments/environment.ts | 13 ++++ 5 files changed, 174 insertions(+) create mode 100644 src/app/core/features/feature-flags.ts create mode 100644 src/app/core/guards/feature.guard.spec.ts create mode 100644 src/app/core/guards/feature.guard.ts diff --git a/src/app/core/features/feature-flags.ts b/src/app/core/features/feature-flags.ts new file mode 100644 index 0000000..93567cf --- /dev/null +++ b/src/app/core/features/feature-flags.ts @@ -0,0 +1,42 @@ +import { Injectable, InjectionToken, Provider, inject } from '@angular/core'; +import { environment } from '../../../environments/environment'; + +/** + * The product areas that can be switched off per build. A disabled feature disappears from the + * UI — nav entries, command-palette actions, buttons and CTAs — and its routes stop matching, so + * its URLs behave like unknown paths. The code and the backend stay in place: turning a flag back + * on in the environment files needs no other change. See docs/FEATURE-FLAGS.md. + */ +export type FeatureKey = + 'billing' | 'usage' | 'integrations' | 'members' | 'customRoles' | 'notifications' | 'tokens'; + +/** One on/off switch per {@link FeatureKey}: the shape every environment file must provide. */ +export type FeatureFlagMap = Readonly>; + +/** The active flag map: the build's environment by default (see {@link provideFeatureFlags}). */ +const FEATURE_FLAGS = new InjectionToken('FEATURE_FLAGS', { + providedIn: 'root', + factory: () => environment.features, +}); + +/** + * Overrides individual flags on top of the environment's map, e.g. + * `provideFeatureFlags({ members: true })` in a test's providers. + */ +export function provideFeatureFlags(overrides: Partial): Provider { + return { provide: FEATURE_FLAGS, useValue: { ...environment.features, ...overrides } }; +} + +/** + * Read-only access to the build's feature flags. The map is static for the lifetime of the + * app (it comes from the environment file), so callers can read a flag once into a field. + */ +@Injectable({ providedIn: 'root' }) +export class FeatureFlags { + private readonly flags = inject(FEATURE_FLAGS); + + /** True when `feature` is switched on for this build. */ + isEnabled(feature: FeatureKey): boolean { + return this.flags[feature]; + } +} diff --git a/src/app/core/guards/feature.guard.spec.ts b/src/app/core/guards/feature.guard.spec.ts new file mode 100644 index 0000000..a1af175 --- /dev/null +++ b/src/app/core/guards/feature.guard.spec.ts @@ -0,0 +1,82 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CanMatchFn, Route, Router, UrlSegment, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { provideFeatureFlags } from '../features/feature-flags'; +import { featureGuard } from './feature.guard'; + +@Component({ template: '' }) +class Page {} + +const route = {} as Route; +const segments = [] as UrlSegment[]; + +/** Runs a `CanMatch` guard the way the router does: inside the injection context. */ +function run(guard: CanMatchFn): unknown { + return TestBed.runInInjectionContext(() => guard(route, segments, {} as never)); +} + +describe('featureGuard', () => { + describe('as a guard function', () => { + it('does not match while the feature is off', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ billing: false })] }); + expect(run(featureGuard('billing'))).toBe(false); + }); + + it('matches while the feature is on', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ billing: true })] }); + expect(run(featureGuard('billing'))).toBe(true); + }); + + it('never runs the chained guard while the feature is off', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ members: false })] }); + const next = vi.fn(() => true); + + expect(run(featureGuard('members', next))).toBe(false); + expect(next).not.toHaveBeenCalled(); + }); + + it('defers to the chained guard while the feature is on', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ members: true })] }); + const next = vi.fn(() => false); + + expect(run(featureGuard('members', next))).toBe(false); + expect(next).toHaveBeenCalledOnce(); + }); + }); + + describe('in a route table', () => { + function navigate(flags: { billing: boolean }, url: string): Promise { + TestBed.configureTestingModule({ + providers: [ + provideFeatureFlags(flags), + provideRouter([ + { path: 'home', component: Page }, + { + path: 'settings', + children: [ + { path: 'general', component: Page }, + { path: 'billing', canMatch: [featureGuard('billing')], component: Page }, + ], + }, + { path: '**', redirectTo: 'home' }, + ]), + ], + }); + const router = TestBed.inject(Router); + return router.navigateByUrl(url).then(() => router.url); + } + + it('makes a disabled route behave like an unknown URL (wildcard fallback)', async () => { + expect(await navigate({ billing: false }, '/settings/billing')).toBe('/home'); + }); + + it('leaves sibling routes of a disabled one untouched', async () => { + expect(await navigate({ billing: false }, '/settings/general')).toBe('/settings/general'); + }); + + it('resolves the route normally once the feature is on', async () => { + expect(await navigate({ billing: true }, '/settings/billing')).toBe('/settings/billing'); + }); + }); +}); diff --git a/src/app/core/guards/feature.guard.ts b/src/app/core/guards/feature.guard.ts new file mode 100644 index 0000000..192985d --- /dev/null +++ b/src/app/core/guards/feature.guard.ts @@ -0,0 +1,24 @@ +import { inject } from '@angular/core'; +import { CanMatchFn } from '@angular/router'; +import { FeatureFlags, FeatureKey } from '../features/feature-flags'; + +/** + * `CanMatch` guard factory for a feature-flagged route. While `feature` is off the route + * simply does not match: the router keeps looking, so the URL falls through to the wildcard + * fallback exactly like an unknown path — no blank page, no "no access" toast, and the lazy + * chunk is never fetched. While it is on, the route matches as usual, or `next` decides. + * + * Pass the route's own `CanMatch` guard (e.g. `requirePermissionMatch`) as `next` instead of + * listing it beside this one: Angular runs every guard of a `canMatch` array eagerly, so a + * sibling permission guard would still fetch permissions — and toast a denial — for a route + * that is switched off. + * + * Usage: `canMatch: [featureGuard('billing')]`, or + * `canMatch: [featureGuard('members', requirePermissionMatch('MEMBER_READ'))]`. + */ +export function featureGuard(feature: FeatureKey, next?: CanMatchFn): CanMatchFn { + return (route, segments, currentSnapshot) => { + if (!inject(FeatureFlags).isEnabled(feature)) return false; + return next ? next(route, segments, currentSnapshot) : true; + }; +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d517e08..24364b2 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,3 +1,5 @@ +import type { FeatureFlagMap } from '../app/core/features/feature-flags'; + export const environment = { production: true, // Empty on purpose: HTTP calls use relative /api paths and the WebSocket @@ -6,4 +8,15 @@ export const environment = { // ALB under the same origin as the frontend, so no absolute URL is needed. apiUrl: '', wsUrl: '', + // MVP scope: every non-MVP area is hidden. Set a flag to true to bring that area back + // (see docs/FEATURE-FLAGS.md); keep environment.ts in sync. + features: { + billing: false, + usage: false, + integrations: false, + members: false, + customRoles: false, + notifications: false, + tokens: false, + } satisfies FeatureFlagMap, } as const; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index d5b19ab..bcf49a5 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,18 @@ +import type { FeatureFlagMap } from '../app/core/features/feature-flags'; + export const environment = { production: false, apiUrl: '', // proxy.conf.json forwards /api → localhost:8080 wsUrl: '', // proxy.conf.json forwards /ws → ws://localhost:8080 + // MVP scope: every non-MVP area is hidden. Set a flag to true to bring that area back + // (see docs/FEATURE-FLAGS.md); keep environment.prod.ts in sync. + features: { + billing: false, + usage: false, + integrations: false, + members: false, + customRoles: false, + notifications: false, + tokens: false, + } satisfies FeatureFlagMap, } as const; From 6b43eaaf3b35e55781fa6541586bb5c486076949 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:09 -0500 Subject: [PATCH 2/9] feat(routing): hide non-MVP routes behind feature flags Gate every non-MVP route with featureGuard so a disabled feature's URLs fall through to the `**` -> /projects fallback like unknown paths: billing (settings page + checkout success/cancel), usage, integrations (org + project pages and the Jira OAuth callback), members (org + project pages and the invitation landing), customRoles (roles list/new/edit) and the notifications/tokens account placeholders. Routes that already had a permission canMatch chain it through featureGuard so a hidden page never fetches permissions or toasts a denial. The invitation landing follows `members`: with it off nobody can invite from the UI and an owner has no screen to see or remove whoever joins, so a stale link must not add people to an org. The project settings landing skips members/roles while their feature is off. A spec drives the real route table to pin both directions. --- src/app/app.routes.spec.ts | 145 ++++++++++++++++++++++++ src/app/app.routes.ts | 24 +++- src/app/core/guards/permission.guard.ts | 12 +- 3 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/app/app.routes.spec.ts diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts new file mode 100644 index 0000000..2dfaf1e --- /dev/null +++ b/src/app/app.routes.spec.ts @@ -0,0 +1,145 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router, provideRouter, withRouterConfig } from '@angular/router'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { of } from 'rxjs'; +import { routes } from './app.routes'; +import { AuthStore } from './core/auth/auth.store'; +import { CURRENT_TERMS_VERSION } from './core/auth/terms'; +import { PermissionsStore } from './core/authz/permissions.store'; +import { FeatureFlagMap, provideFeatureFlags } from './core/features/feature-flags'; +import { TenantContextService } from './core/tenant/tenant-context.service'; + +/** Every non-MVP URL, each gated by one feature flag. */ +const HIDDEN_URLS = [ + '/settings/billing', + '/billing/success', + '/billing/cancel', + '/settings/usage', + '/settings/integrations', + '/settings/integrations/jira/callback', + '/projects/p1/settings/integrations', + '/settings/members', + '/projects/p1/settings/members', + '/invitations/accept', + '/projects/p1/settings/roles', + '/projects/p1/settings/roles/new', + '/projects/p1/settings/roles/r1/edit', + '/account/notifications', + '/account/tokens', +]; + +/** MVP URLs that must keep resolving with every flag off. */ +const MVP_URLS = [ + '/projects', + '/projects/p1/overview', + '/projects/p1/sessions/history', + '/projects/p1/stories', + '/projects/p1/stories/new', + '/projects/p1/stories/s1', + '/projects/p1/glossary', + '/projects/p1/constraints', + '/projects/p1/settings/general', + '/projects/p1/settings/danger', + '/settings/general', + '/account/profile', + '/account/security', + '/account/appearance', +]; + +/** The MVP release: every non-MVP feature off (explicit, so the test doesn't follow the env). */ +const ALL_OFF: FeatureFlagMap = { + billing: false, + usage: false, + integrations: false, + members: false, + customRoles: false, + notifications: false, + tokens: false, +}; + +const ALL_ON: FeatureFlagMap = { + billing: true, + usage: true, + integrations: true, + members: true, + customRoles: true, + notifications: true, + tokens: true, +}; + +/** + * Drives the REAL route table as a signed-in organization owner (stubbed auth, terms and + * permissions, so every role/permission guard passes) and reports where each URL ends up. + * No outlet is mounted, so the lazy pages are loaded but never rendered. + */ +describe('app routes with feature flags', () => { + function setup(flags: FeatureFlagMap): Router { + TestBed.configureTestingModule({ + imports: [TranslocoTestingModule.forRoot({ langs: { en: {} } })], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'always' })), + provideFeatureFlags(flags), + { + provide: AuthStore, + useValue: { isAuthenticated: () => true, organizationId: () => 'org-1' }, + }, + { + provide: TenantContextService, + useValue: { termsVersion: () => CURRENT_TERMS_VERSION }, + }, + { + provide: PermissionsStore, + useValue: { + loadOrgAuthorization: () => of(undefined), + loadProjectPermissions: () => of(undefined), + isOrgOwner: () => true, + isOrgOwnerOrAdmin: () => true, + has: () => true, + }, + }, + ], + }); + return TestBed.inject(Router); + } + + async function landing(router: Router, url: string): Promise { + await router.navigateByUrl(url); + return router.url; + } + + describe('with the MVP flags (all off)', () => { + it.each(HIDDEN_URLS)('treats %s as an unknown URL (falls back to /projects)', async (url) => { + const router = setup(ALL_OFF); + expect(await landing(router, url)).toBe('/projects'); + }); + + it.each(['/members', '/projects/p1/members'])( + 'sends the legacy %s redirect to the fallback too', + async (url) => { + const router = setup(ALL_OFF); + expect(await landing(router, url)).toBe('/projects'); + }, + ); + + it.each(MVP_URLS)('still resolves %s', async (url) => { + const router = setup(ALL_OFF); + expect(await landing(router, url)).toBe(url); + }); + + it('lands the project settings index on General', async () => { + const router = setup(ALL_OFF); + expect(await landing(router, '/projects/p1/settings')).toBe('/projects/p1/settings/general'); + }); + }); + + describe('with every flag on', () => { + it.each(HIDDEN_URLS)('resolves %s again', async (url) => { + const router = setup(ALL_ON); + expect(await landing(router, url)).toBe(url); + }); + }); +}); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 8cc2eb0..c691742 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { authGuard } from './core/guards/auth.guard'; +import { featureGuard } from './core/guards/feature.guard'; import { launchGuard } from './core/guards/launch.guard'; import { onboardingGuard, orgGuard } from './core/guards/org.guard'; import { @@ -21,9 +22,12 @@ export const routes: Routes = [ }, // Invitation landing: chrome-less, no auth guard so logged-out invitees can view it. + // Part of the `members` feature: while it is off nobody can invite or manage members, + // so a stale invite link falls through to the fallback like any unknown URL. { path: 'invitations/accept', title: 'titles.acceptInvitation', + canMatch: [featureGuard('members')], loadComponent: () => import('./features/workspace/pages/accept-invitation/accept-invitation').then( (m) => m.AcceptInvitation, @@ -43,6 +47,7 @@ export const routes: Routes = [ { path: 'settings/integrations/jira/callback', title: 'titles.integrations', + canMatch: [featureGuard('integrations')], canActivate: [authGuard, termsGuard], loadComponent: () => import('./features/workspace/pages/jira-oauth-callback/jira-oauth-callback').then( @@ -133,6 +138,7 @@ export const routes: Routes = [ { path: 'members', title: 'titles.members', + canMatch: [featureGuard('members')], canActivate: [requireOrgRole('ADMIN')], data: { orgRole: 'ADMIN' }, loadComponent: () => @@ -141,6 +147,7 @@ export const routes: Routes = [ { path: 'billing', title: 'titles.billing', + canMatch: [featureGuard('billing')], canActivate: [requireOrgRole('OWNER')], data: { orgRole: 'OWNER' }, loadComponent: () => @@ -149,6 +156,7 @@ export const routes: Routes = [ { path: 'integrations', title: 'titles.integrations', + canMatch: [featureGuard('integrations')], canActivate: [requireOrgRole('ADMIN')], data: { orgRole: 'ADMIN' }, loadComponent: () => @@ -159,6 +167,7 @@ export const routes: Routes = [ { path: 'usage', title: 'titles.usage', + canMatch: [featureGuard('usage')], canActivate: [requireOrgRole('OWNER')], data: { orgRole: 'OWNER' }, loadComponent: () => @@ -170,6 +179,7 @@ export const routes: Routes = [ { path: 'billing/success', title: 'titles.billing', + canMatch: [featureGuard('billing')], loadComponent: () => import('./features/billing/pages/checkout-result/checkout-result').then( (m) => m.CheckoutResult, @@ -179,6 +189,7 @@ export const routes: Routes = [ { path: 'billing/cancel', title: 'titles.billing', + canMatch: [featureGuard('billing')], loadComponent: () => import('./features/billing/pages/checkout-result/checkout-result').then( (m) => m.CheckoutResult, @@ -214,6 +225,7 @@ export const routes: Routes = [ { path: 'notifications', title: 'titles.notifications', + canMatch: [featureGuard('notifications')], loadComponent: () => import('./shared/components/coming-soon/coming-soon').then((m) => m.ComingSoon), data: { titleKey: 'titles.notifications', icon: 'lucideBell' }, @@ -221,6 +233,7 @@ export const routes: Routes = [ { path: 'tokens', title: 'titles.tokens', + canMatch: [featureGuard('tokens')], loadComponent: () => import('./shared/components/coming-soon/coming-soon').then((m) => m.ComingSoon), data: { titleKey: 'titles.tokens', icon: 'lucideKey' }, @@ -322,7 +335,7 @@ export const routes: Routes = [ { path: 'roles', title: 'titles.projectRoles', - canMatch: [requirePermissionMatch('ROLE_READ')], + canMatch: [featureGuard('customRoles', requirePermissionMatch('ROLE_READ'))], canActivate: [requirePermission('ROLE_READ')], data: { permission: 'ROLE_READ' }, loadComponent: () => @@ -333,6 +346,7 @@ export const routes: Routes = [ { path: 'roles/new', title: 'titles.newRole', + canMatch: [featureGuard('customRoles')], canActivate: [requirePermission('ROLE_CREATE')], data: { permission: 'ROLE_CREATE' }, loadComponent: () => @@ -343,6 +357,7 @@ export const routes: Routes = [ { path: 'roles/:roleId/edit', title: 'titles.editRole', + canMatch: [featureGuard('customRoles')], canActivate: [requirePermission('ROLE_UPDATE')], data: { permission: 'ROLE_UPDATE' }, loadComponent: () => @@ -353,7 +368,7 @@ export const routes: Routes = [ { path: 'members', title: 'titles.projectMembers', - canMatch: [requirePermissionMatch('MEMBER_READ')], + canMatch: [featureGuard('members', requirePermissionMatch('MEMBER_READ'))], canActivate: [requirePermission('MEMBER_READ')], data: { permission: 'MEMBER_READ' }, loadComponent: () => @@ -364,7 +379,9 @@ export const routes: Routes = [ { path: 'integrations', title: 'titles.integrations', - canMatch: [requirePermissionMatch('INTEGRATION_READ')], + canMatch: [ + featureGuard('integrations', requirePermissionMatch('INTEGRATION_READ')), + ], canActivate: [requirePermission('INTEGRATION_READ')], data: { permission: 'INTEGRATION_READ' }, loadComponent: () => @@ -390,5 +407,6 @@ export const routes: Routes = [ ], }, + // Unknown URLs — and the routes of a disabled feature (see featureGuard) — land here. { path: '**', redirectTo: 'projects' }, ]; diff --git a/src/app/core/guards/permission.guard.ts b/src/app/core/guards/permission.guard.ts index 2b62dfe..23fb155 100644 --- a/src/app/core/guards/permission.guard.ts +++ b/src/app/core/guards/permission.guard.ts @@ -12,6 +12,7 @@ import { TranslocoService } from '@jsverse/transloco'; import { firstValueFrom } from 'rxjs'; import { AuthStore } from '../auth/auth.store'; import { PermissionsStore } from '../authz/permissions.store'; +import { FeatureFlags } from '../features/feature-flags'; import { OrgRole } from '../authz/permissions.models'; import { ToastService } from '../../shared/toast/toast.service'; @@ -22,6 +23,7 @@ import { ToastService } from '../../shared/toast/toast.service'; */ interface GuardDeps { auth: AuthStore; + flags: FeatureFlags; permissions: PermissionsStore; router: Router; toast: ToastService; @@ -31,6 +33,7 @@ interface GuardDeps { function resolveDeps(): GuardDeps { return { auth: inject(AuthStore), + flags: inject(FeatureFlags), permissions: inject(PermissionsStore), router: inject(Router), toast: inject(ToastService), @@ -163,8 +166,9 @@ export function requireOrgRole(role: OrgRole): CanActivateFn { * Landing redirect for the project Settings index. The fixed `general` default is * gated by `PROJECT_UPDATE`, so a member who can only reach (say) Members would be * bounced with a "no access" toast just for entering Settings. Instead, redirect to the - * FIRST settings sub-page the caller can actually access; fall back to the project - * overview when none apply. No toast — this is a silent landing choice, not a denial. + * FIRST settings sub-page the caller can actually access — skipping the pages of a + * disabled feature — and fall back to the project overview when none apply. No toast: + * this is a silent landing choice, not a denial. */ export const projectSettingsLanding: CanActivateFn = async (route: ActivatedRouteSnapshot) => { const deps = resolveDeps(); @@ -174,8 +178,8 @@ export const projectSettingsLanding: CanActivateFn = async (route: ActivatedRout const can = (p: string) => deps.permissions.isOrgOwnerOrAdmin() || deps.permissions.has(p); const target = (can('PROJECT_UPDATE') && 'general') || - (can('MEMBER_READ') && 'members') || - (can('ROLE_READ') && 'roles') || + (deps.flags.isEnabled('members') && can('MEMBER_READ') && 'members') || + (deps.flags.isEnabled('customRoles') && can('ROLE_READ') && 'roles') || (can('PROJECT_DELETE') && 'danger') || null; return projectId && target From 64c6017fa8e7af6d8d89a66a3bca5490466a461c Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:14 -0500 Subject: [PATCH 3/9] feat(shell): filter the sidebar nav by feature flag Move the nav segment lists and their visibility rule into shell-nav.ts so they can be unit-tested, and add an optional `feature` to NavSeg that visibleNavSegs checks alongside role/permission. Flagged entries: org members/billing/integrations/usage, project roles/members/integrations and the account notifications/tokens placeholders. A "Settings" root entry with no reachable sub-page is now dropped, and the org one opens the first reachable sub-page instead of the owner-only General, so an admin never lands on a "no access" toast. The Jira job banner only mounts while integrations are on. --- src/app/layout/shell/shell-nav.spec.ts | 122 +++++++++++++++++++++++++ src/app/layout/shell/shell-nav.ts | 77 ++++++++++++++++ src/app/layout/shell/shell.ts | 116 +++++++++-------------- 3 files changed, 243 insertions(+), 72 deletions(-) create mode 100644 src/app/layout/shell/shell-nav.spec.ts create mode 100644 src/app/layout/shell/shell-nav.ts diff --git a/src/app/layout/shell/shell-nav.spec.ts b/src/app/layout/shell/shell-nav.spec.ts new file mode 100644 index 0000000..421a0c9 --- /dev/null +++ b/src/app/layout/shell/shell-nav.spec.ts @@ -0,0 +1,122 @@ +import { OrgRole } from '../../core/authz/permissions.models'; +import { FeatureKey } from '../../core/features/feature-flags'; +import { + ACCOUNT_NAV, + NavAccess, + NavSeg, + ORG_SETTINGS_NAV, + PROJECT_SETTINGS_NAV, + visibleNavSegs, +} from './shell-nav'; + +/** A caller with `role`, the given project permissions and the given features switched on. */ +function access( + role: OrgRole, + features: readonly FeatureKey[] = [], + permissions: readonly string[] = [], +): NavAccess { + const ownerOrAdmin = role === 'OWNER' || role === 'ADMIN'; + return { + isOrgOwner: () => role === 'OWNER', + isOrgOwnerOrAdmin: () => ownerOrAdmin, + has: (p) => ownerOrAdmin || permissions.includes(p), + isEnabled: (f) => features.includes(f), + }; +} + +const ALL_FEATURES: readonly FeatureKey[] = [ + 'billing', + 'usage', + 'integrations', + 'members', + 'customRoles', + 'notifications', + 'tokens', +]; + +const segs = (list: NavSeg[]) => list.map((s) => s.seg); + +describe('visibleNavSegs', () => { + it('hides an item whose feature is off, even for the owner', () => { + const items: NavSeg[] = [{ seg: 'general' }, { seg: 'billing', feature: 'billing' }]; + expect(segs(visibleNavSegs(items, access('OWNER')))).toEqual(['general']); + }); + + it('shows an item whose feature is on', () => { + const items: NavSeg[] = [{ seg: 'general' }, { seg: 'billing', feature: 'billing' }]; + expect(segs(visibleNavSegs(items, access('OWNER', ['billing'])))).toEqual([ + 'general', + 'billing', + ]); + }); + + it('still applies the role gate when the feature is on', () => { + const items: NavSeg[] = [{ seg: 'billing', role: 'OWNER', feature: 'billing' }]; + expect(visibleNavSegs(items, access('ADMIN', ['billing']))).toEqual([]); + }); + + it('still applies the permission gate when the feature is on', () => { + const items: NavSeg[] = [{ seg: 'members', permission: 'MEMBER_READ', feature: 'members' }]; + expect(visibleNavSegs(items, access('MEMBER', ['members']))).toEqual([]); + expect(segs(visibleNavSegs(items, access('MEMBER', ['members'], ['MEMBER_READ'])))).toEqual([ + 'members', + ]); + }); + + describe('with the MVP flags (everything off)', () => { + it('leaves only General in the org settings nav', () => { + expect(segs(visibleNavSegs(ORG_SETTINGS_NAV, access('OWNER')))).toEqual(['general']); + }); + + it('leaves an admin no org settings page at all', () => { + expect(visibleNavSegs(ORG_SETTINGS_NAV, access('ADMIN'))).toEqual([]); + }); + + it('leaves General and Danger in the project settings nav', () => { + expect(segs(visibleNavSegs(PROJECT_SETTINGS_NAV, access('OWNER')))).toEqual([ + 'general', + 'danger', + ]); + }); + + it('drops the "soon" notifications and tokens entries from the account nav', () => { + expect(segs(visibleNavSegs(ACCOUNT_NAV, access('MEMBER')))).toEqual([ + 'profile', + 'security', + 'appearance', + ]); + }); + }); + + describe('with every flag on', () => { + it('restores the full org settings nav', () => { + expect(segs(visibleNavSegs(ORG_SETTINGS_NAV, access('OWNER', ALL_FEATURES)))).toEqual([ + 'general', + 'members', + 'billing', + 'integrations', + 'usage', + ]); + }); + + it('restores the full project settings nav', () => { + expect(segs(visibleNavSegs(PROJECT_SETTINGS_NAV, access('OWNER', ALL_FEATURES)))).toEqual([ + 'general', + 'roles', + 'members', + 'integrations', + 'danger', + ]); + }); + + it('restores the account placeholders', () => { + expect(segs(visibleNavSegs(ACCOUNT_NAV, access('MEMBER', ALL_FEATURES)))).toEqual([ + 'profile', + 'security', + 'appearance', + 'notifications', + 'tokens', + ]); + }); + }); +}); diff --git a/src/app/layout/shell/shell-nav.ts b/src/app/layout/shell/shell-nav.ts new file mode 100644 index 0000000..f6fa89c --- /dev/null +++ b/src/app/layout/shell/shell-nav.ts @@ -0,0 +1,77 @@ +import { OrgRole } from '../../core/authz/permissions.models'; +import { FeatureKey } from '../../core/features/feature-flags'; + +/** + * A sidebar nav item before its link is resolved. `seg` is the route segment, the i18n key + * (`nav.`) and the nav-icon name. `soon` marks a disabled placeholder. `permission` / + * `role` (when set) gate the item to callers who hold that project permission / org role, and + * `feature` hides it while that feature flag is off — so the aside never offers a dead end + * (the guards + backend still enforce). + */ +export interface NavSeg { + seg: string; + soon?: boolean; + permission?: string; + role?: OrgRole; + feature?: FeatureKey; +} + +/** What {@link visibleNavSegs} needs to decide: the caller's grants and the build's flags. */ +export interface NavAccess { + isOrgOwner(): boolean; + isOrgOwnerOrAdmin(): boolean; + has(permission: string): boolean; + isEnabled(feature: FeatureKey): boolean; +} + +// Nav item segments per sidebar context; the shell resolves their links once the active +// project id is known. +export const ORG_ROOT_NAV: readonly NavSeg[] = [{ seg: 'projects' }, { seg: 'settings' }]; +export const ORG_SETTINGS_NAV: readonly NavSeg[] = [ + { seg: 'general', role: 'OWNER' }, + { seg: 'members', role: 'ADMIN', feature: 'members' }, + { seg: 'billing', role: 'OWNER', feature: 'billing' }, + { seg: 'integrations', role: 'ADMIN', feature: 'integrations' }, + { seg: 'usage', role: 'OWNER', feature: 'usage' }, +]; +export const PROJECT_ROOT_NAV: readonly NavSeg[] = [ + { seg: 'overview' }, + { seg: 'sessions', permission: 'SESSION_READ' }, + { seg: 'stories', permission: 'STORY_READ' }, + { seg: 'glossary', permission: 'GLOSSARY_READ' }, + { seg: 'constraints', permission: 'CONSTRAINT_READ' }, + { seg: 'settings' }, +]; +export const PROJECT_SETTINGS_NAV: readonly NavSeg[] = [ + { seg: 'general', permission: 'PROJECT_UPDATE' }, + { seg: 'roles', permission: 'ROLE_READ', feature: 'customRoles' }, + { seg: 'members', permission: 'MEMBER_READ', feature: 'members' }, + { seg: 'integrations', permission: 'INTEGRATION_READ', feature: 'integrations' }, + { seg: 'danger', permission: 'PROJECT_DELETE' }, +]; +export const ACCOUNT_NAV: readonly NavSeg[] = [ + { seg: 'profile' }, + { seg: 'security' }, + { seg: 'appearance' }, + { seg: 'notifications', soon: true, feature: 'notifications' }, + { seg: 'tokens', soon: true, feature: 'tokens' }, +]; + +/** + * Keeps only the nav segments the caller may reach: an item with a `feature` needs that flag + * on, an item with a `role` needs that org role (owner always passes; `'ADMIN'` admits owner + + * admin), and an item with a `permission` needs that project permission (owner/admin bypass). + * Ungated items always show. Pure, so the shell can call it inside a `computed` and re-render + * when authorization arrives. + */ +export function visibleNavSegs(segs: readonly NavSeg[], access: NavAccess): NavSeg[] { + return segs.filter((s) => { + if (s.feature && !access.isEnabled(s.feature)) return false; + if (s.role) { + const ok = s.role === 'OWNER' ? access.isOrgOwner() : access.isOrgOwnerOrAdmin(); + if (!ok) return false; + } + if (s.permission && !access.has(s.permission)) return false; + return true; + }); +} diff --git a/src/app/layout/shell/shell.ts b/src/app/layout/shell/shell.ts index 0d26d53..b332407 100644 --- a/src/app/layout/shell/shell.ts +++ b/src/app/layout/shell/shell.ts @@ -16,7 +16,7 @@ import { lucideChevronLeft, lucideMenu, lucideSearch, lucideX } from '@ng-icons/ import { TranslocoPipe } from '@jsverse/transloco'; import { AuthStore } from '../../core/auth/auth.store'; import { PermissionsStore } from '../../core/authz/permissions.store'; -import { OrgRole } from '../../core/authz/permissions.models'; +import { FeatureFlags } from '../../core/features/feature-flags'; import { PageTitleService } from '../../core/layout/page-title.service'; import { modLabel } from '../../core/platform/shortcut'; import { WorkspaceStore } from '../../features/workspace/data/workspace.store'; @@ -29,6 +29,16 @@ import { ToastHost } from '../../shared/toast/toast-host'; import { RecordingMinibar } from '../../features/discovery/components/recording-minibar/recording-minibar'; import { IntegrationJobsBanner } from '../../features/workspace/components/integration-jobs-banner/integration-jobs-banner'; import { HlmIcon } from '../../shared/ui'; +import { + ACCOUNT_NAV, + NavAccess, + NavSeg, + ORG_ROOT_NAV, + ORG_SETTINGS_NAV, + PROJECT_ROOT_NAV, + PROJECT_SETTINGS_NAV, + visibleNavSegs, +} from './shell-nav'; interface NavItem { /** Route segment + i18n key (`nav.`) + nav-icon name. */ @@ -39,18 +49,6 @@ interface NavItem { soon?: boolean; } -/** - * A nav item template before its link is resolved. `permission` / `role` (when set) - * gate the item to callers who hold that project permission / org role — used to hide - * settings entries a member can't reach. - */ -interface NavSeg { - seg: string; - soon?: boolean; - permission?: string; - role?: OrgRole; -} - /** The sidebar context derived from the URL: which nav list, back link and heading to show. */ interface NavContext { /** A back link shown above the list (e.g. "← All Projects"); null in the root contexts. */ @@ -243,7 +241,9 @@ interface Crumb { - + @if (integrationsEnabled) { + + }
@@ -291,6 +291,7 @@ export class Shell { protected readonly auth = inject(AuthStore); protected readonly workspace = inject(WorkspaceStore); private readonly permissions = inject(PermissionsStore); + private readonly flags = inject(FeatureFlags); private readonly router = inject(Router); private readonly pageTitle = inject(PageTitleService); @@ -298,6 +299,8 @@ export class Shell { protected readonly shortcut = modLabel('K'); protected readonly mobileOpen = signal(false); protected readonly paletteOpen = signal(false); + /** The Jira job banner only exists while the `integrations` feature is on. */ + protected readonly integrationsEnabled = this.flags.isEnabled('integrations'); /** Open the command palette on Cmd/Ctrl+K from anywhere in the shell. */ @HostListener('document:keydown', ['$event']) @@ -308,40 +311,13 @@ export class Shell { } } - // Nav item segments per context; links are resolved in `navContext()` where the - // active project id is known. `soon` marks disabled placeholder items. `permission` - // / `role` gate the item to what the caller may reach — items they can't use are - // filtered out so the aside never offers a dead end (the guards + backend still enforce). - private readonly orgRootSegs: NavSeg[] = [{ seg: 'projects' }, { seg: 'settings' }]; - private readonly orgSettingsSegs: NavSeg[] = [ - { seg: 'general', role: 'OWNER' }, - { seg: 'members', role: 'ADMIN' }, - { seg: 'billing', role: 'OWNER' }, - { seg: 'integrations', role: 'ADMIN' }, - { seg: 'usage', role: 'OWNER' }, - ]; - private readonly projectRootSegs: NavSeg[] = [ - { seg: 'overview' }, - { seg: 'sessions', permission: 'SESSION_READ' }, - { seg: 'stories', permission: 'STORY_READ' }, - { seg: 'glossary', permission: 'GLOSSARY_READ' }, - { seg: 'constraints', permission: 'CONSTRAINT_READ' }, - { seg: 'settings' }, - ]; - private readonly projectSettingsSegs: NavSeg[] = [ - { seg: 'general', permission: 'PROJECT_UPDATE' }, - { seg: 'roles', permission: 'ROLE_READ' }, - { seg: 'members', permission: 'MEMBER_READ' }, - { seg: 'integrations', permission: 'INTEGRATION_READ' }, - { seg: 'danger', permission: 'PROJECT_DELETE' }, - ]; - private readonly accountSegs: NavSeg[] = [ - { seg: 'profile' }, - { seg: 'security' }, - { seg: 'appearance' }, - { seg: 'notifications', soon: true }, - { seg: 'tokens', soon: true }, - ]; + /** The caller's grants and the build's feature flags, as the nav filter reads them. */ + private readonly navAccess: NavAccess = { + isOrgOwner: () => this.permissions.isOrgOwner(), + isOrgOwnerOrAdmin: () => this.permissions.isOrgOwnerOrAdmin(), + has: (permission) => this.permissions.has(permission), + isEnabled: (feature) => this.flags.isEnabled(feature), + }; private readonly url = toSignal( this.router.events.pipe( @@ -388,7 +364,7 @@ export class Shell { back: { link: ['/projects'], labelKey: 'nav.allProjects' }, headingKey: 'nav.account', ariaKey: 'nav.accountAria', - items: this.accountSegs.map((s) => ({ + items: this.visibleSegs(ACCOUNT_NAV).map((s) => ({ seg: s.seg, link: ['/account', s.seg], soon: s.soon, @@ -407,21 +383,21 @@ export class Shell { }, headingKey: 'nav.settings', ariaKey: 'nav.projectAria', - items: this.visibleSegs(this.projectSettingsSegs).map((s) => ({ + items: this.visibleSegs(PROJECT_SETTINGS_NAV).map((s) => ({ seg: s.seg, link: ['/projects', pid, 'settings', s.seg], soon: s.soon, })), }; } - // Project root. + // Project root. "Settings" is dropped when no settings sub-page is reachable. + const hasSettings = this.visibleSegs(PROJECT_SETTINGS_NAV).length > 0; return { back: { link: ['/projects'], labelKey: 'nav.allProjects' }, ariaKey: 'nav.projectAria', - items: this.visibleSegs(this.projectRootSegs).map((s) => ({ - seg: s.seg, - link: ['/projects', pid, s.seg], - })), + items: this.visibleSegs(PROJECT_ROOT_NAV) + .filter((s) => s.seg !== 'settings' || hasSettings) + .map((s) => ({ seg: s.seg, link: ['/projects', pid, s.seg] })), }; } @@ -431,7 +407,7 @@ export class Shell { back: { link: ['/projects'], labelKey: 'nav.allProjects' }, headingKey: 'nav.settings', ariaKey: 'nav.orgAria', - items: this.visibleSegs(this.orgSettingsSegs).map((s) => ({ + items: this.visibleSegs(ORG_SETTINGS_NAV).map((s) => ({ seg: s.seg, link: ['/settings', s.seg], soon: s.soon, @@ -439,30 +415,26 @@ export class Shell { }; } - // Org root. + // Org root. "Settings" opens the first org settings page the caller can reach (the + // fixed `general` default is owner-only) and is dropped when there is none. + const settingsLanding = this.visibleSegs(ORG_SETTINGS_NAV)[0]?.seg; return { back: null, ariaKey: 'nav.orgAria', - items: this.orgRootSegs.map((s) => ({ seg: s.seg, link: ['/' + s.seg] })), + items: ORG_ROOT_NAV.flatMap((s) => { + if (s.seg !== 'settings') return [{ seg: s.seg, link: ['/' + s.seg] }]; + return settingsLanding ? [{ seg: s.seg, link: ['/settings', settingsLanding] }] : []; + }), }; }); /** - * Keeps only the nav segments the caller may reach: an item with a `role` needs that - * org role (owner always passes; `'ADMIN'` admits owner + admin), an item with a - * `permission` needs that project permission (owner/admin bypass). Ungated items always - * show. Reads the permission signals so the list re-renders when authorization arrives. + * Keeps only the nav segments the caller may reach (feature flag, org role, project + * permission — see {@link visibleNavSegs}). Reads the permission signals so the list + * re-renders when authorization arrives. */ - private visibleSegs(segs: NavSeg[]): NavSeg[] { - return segs.filter((s) => { - if (s.role) { - const ok = - s.role === 'OWNER' ? this.permissions.isOrgOwner() : this.permissions.isOrgOwnerOrAdmin(); - if (!ok) return false; - } - if (s.permission && !this.permissions.has(s.permission)) return false; - return true; - }); + private visibleSegs(segs: readonly NavSeg[]): NavSeg[] { + return visibleNavSegs(segs, this.navAccess); } /** Breadcrumb trail for the top bar: the context ancestors (clickable) then the From fc0e476dcd0ff7c35705c12b9546a6d19145e089 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:22 -0500 Subject: [PATCH 4/9] feat(shared): drop flagged entries from the command palette and user menu The palette's "Members" quick action and backend MEMBER hits (both open /settings/members) only appear while `members` is on; the user menu's "Upgrade" CTA only while `billing` is on. --- .../command-palette/command-palette.spec.ts | 94 +++++++++++++++++++ .../command-palette/command-palette.ts | 45 +++++++-- .../shared/components/user-menu/user-menu.ts | 27 +++--- 3 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 src/app/shared/components/command-palette/command-palette.spec.ts diff --git a/src/app/shared/components/command-palette/command-palette.spec.ts b/src/app/shared/components/command-palette/command-palette.spec.ts new file mode 100644 index 0000000..60a2f79 --- /dev/null +++ b/src/app/shared/components/command-palette/command-palette.spec.ts @@ -0,0 +1,94 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { of } from 'rxjs'; +import { AuthService } from '../../../core/auth/auth.service'; +import { FeatureFlagMap, provideFeatureFlags } from '../../../core/features/feature-flags'; +import { ThemeService } from '../../../core/theme/theme.service'; +import { WorkspaceStore } from '../../../features/workspace/data/workspace.store'; +import { CommandRegistry } from '../../search/command-registry'; +import { SearchHitResponse } from '../../search/search-api.service'; +import { CommandPalette } from './command-palette'; + +const HITS: SearchHitResponse[] = [ + { type: 'PROJECT', id: 'p1', title: 'Alpha', subtitle: null, projectId: 'p1' }, + { type: 'MEMBER', id: 'm1', title: 'Alice', subtitle: null, projectId: null }, +]; + +describe('CommandPalette feature flags', () => { + let registry: CommandRegistry; + let palette: CommandPalette; + + function setup(flags: Partial): void { + TestBed.configureTestingModule({ + imports: [ + CommandPalette, + TranslocoTestingModule.forRoot({ + langs: { en: {} }, + translocoConfig: { availableLangs: ['en'], defaultLang: 'en' }, + preloadLangs: true, + }), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + provideFeatureFlags(flags), + // The palette only reads these; stubs keep the test free of their side effects. + { provide: AuthService, useValue: { switchOrganization: () => of(null) } }, + { provide: ThemeService, useValue: { toggle: () => undefined } }, + { + provide: WorkspaceStore, + useValue: { + organizations: signal([]), + projects: signal([]), + loadProjects: () => undefined, + }, + }, + ], + }); + palette = TestBed.createComponent(CommandPalette).componentInstance; + registry = TestBed.inject(CommandRegistry); + } + + const ids = () => registry.items().map((item) => item.id); + + /** Types `q`, lets the debounced backend search fire, and answers it with `hits`. */ + async function searchBackend(q: string, hits: SearchHitResponse[]): Promise { + palette['onQuery'](q); + TestBed.tick(); + await new Promise((resolve) => setTimeout(resolve, 250)); + TestBed.inject(HttpTestingController) + .expectOne((req) => req.url === '/api/search') + .flush(hits); + } + + it('omits the Members quick action while the members feature is off', () => { + setup({ members: false }); + expect(ids()).toContain('action:new-project'); + expect(ids()).toContain('action:settings'); + expect(ids()).not.toContain('action:members'); + }); + + it('offers the Members quick action while the members feature is on', () => { + setup({ members: true }); + expect(ids()).toContain('action:members'); + }); + + it('drops backend member hits while the members feature is off', async () => { + setup({ members: false }); + await searchBackend('al', HITS); + expect(ids()).toContain('project:p1'); + expect(ids()).not.toContain('member:m1'); + }); + + it('keeps backend member hits while the members feature is on', async () => { + setup({ members: true }); + await searchBackend('al', HITS); + expect(ids()).toContain('project:p1'); + expect(ids()).toContain('member:m1'); + }); +}); diff --git a/src/app/shared/components/command-palette/command-palette.ts b/src/app/shared/components/command-palette/command-palette.ts index 1ca5a25..17b91fe 100644 --- a/src/app/shared/components/command-palette/command-palette.ts +++ b/src/app/shared/components/command-palette/command-palette.ts @@ -31,12 +31,17 @@ import { catchError, debounceTime, distinctUntilChanged, map, of, switchMap } fr import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { AuthService } from '../../../core/auth/auth.service'; import { AuthStore } from '../../../core/auth/auth.store'; +import { FeatureFlags, FeatureKey } from '../../../core/features/feature-flags'; import { ThemeService } from '../../../core/theme/theme.service'; import { WorkspaceStore } from '../../../features/workspace/data/workspace.store'; import { Avatar } from '../avatar/avatar'; import { HlmIcon } from '../../ui'; import { CommandRegistry, SearchItem } from '../../search/command-registry'; -import { SearchApiService, SearchHitResponse } from '../../search/search-api.service'; +import { + SearchApiService, + SearchHitResponse, + SearchHitType, +} from '../../search/search-api.service'; import { translateFn } from '../../../core/i18n/translate-fn'; /** localStorage key for the small most-recently-used list of activated item ids. */ @@ -44,6 +49,12 @@ const RECENT_KEY = 'commandPalette.recent'; /** How many recent items to remember / surface. */ const RECENT_MAX = 5; +/** + * Backend search-hit types whose result opens a feature-flagged page; hits of a disabled + * feature are dropped so the palette never jumps to a route that no longer matches. + */ +const HIT_FEATURES: Partial> = { MEMBER: 'members' }; + /** A search item paired with its flat index across all visible groups (for keyboard nav). */ interface IndexedItem { item: SearchItem; @@ -179,6 +190,7 @@ export class CommandPalette { private readonly transloco = inject(TranslocoService); private readonly registry = inject(CommandRegistry); private readonly searchApi = inject(SearchApiService); + private readonly flags = inject(FeatureFlags); /** Two-way bound visibility — the shell flips this on ⌘K / search focus. */ readonly open = model(false); @@ -213,13 +225,18 @@ export class CommandPalette { icon: 'lucidePlus', run: () => this.go(['/projects/new']), }, - { - id: 'action:members', - label: t('commandPalette.actions.members'), - group: 'commandPalette.groups.actions', - icon: 'lucideUsers', - run: () => this.go(['/settings/members']), - }, + // Org members live behind the `members` feature flag. + ...(this.flags.isEnabled('members') + ? [ + { + id: 'action:members', + label: t('commandPalette.actions.members'), + group: 'commandPalette.groups.actions', + icon: 'lucideUsers', + run: () => this.go(['/settings/members']), + }, + ] + : []), { id: 'action:settings', label: t('commandPalette.actions.settings'), @@ -347,7 +364,11 @@ export class CommandPalette { ), takeUntilDestroyed(), ) - .subscribe(([q, hits]) => this.backendResults.set(hits.map((h) => this.toItem(h, q)))); + .subscribe(([q, hits]) => + this.backendResults.set( + hits.filter((h) => this.hitEnabled(h)).map((h) => this.toItem(h, q)), + ), + ); // On open: reset query/selection and focus the input. effect(() => { if (this.open()) { @@ -455,6 +476,12 @@ export class CommandPalette { }); } + /** False for a hit whose page belongs to a disabled feature (see {@link HIT_FEATURES}). */ + private hitEnabled(hit: SearchHitResponse): boolean { + const feature = HIT_FEATURES[hit.type]; + return !feature || this.flags.isEnabled(feature); + } + /** Map a backend search hit to a palette item. `keywords: q` guarantees it survives the client * fuzzy filter (a backend match can be typo-tolerant and need not be a subsequence of the label). * Project/organization ids mirror the local source ids so echoes de-dupe (the richer local wins). */ diff --git a/src/app/shared/components/user-menu/user-menu.ts b/src/app/shared/components/user-menu/user-menu.ts index ea8d8b1..5350e15 100644 --- a/src/app/shared/components/user-menu/user-menu.ts +++ b/src/app/shared/components/user-menu/user-menu.ts @@ -15,6 +15,7 @@ import { import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { AuthService } from '../../../core/auth/auth.service'; import { AuthStore } from '../../../core/auth/auth.store'; +import { FeatureFlags } from '../../../core/features/feature-flags'; import { ThemeMode, ThemeService } from '../../../core/theme/theme.service'; import { Lang, SUPPORTED_LANGS, saveLang } from '../../../core/i18n/language'; import { Avatar } from '../avatar/avatar'; @@ -157,17 +158,19 @@ import { HlmIcon } from '../../ui'; -
- -
+ @if (billingEnabled) { +
+ +
+ }
`, @@ -191,6 +194,8 @@ export class UserMenu { protected readonly positions = ABOVE_START; protected readonly open = signal(false); + /** The "Upgrade" CTA belongs to the `billing` feature. */ + protected readonly billingEnabled = inject(FeatureFlags).isEnabled('billing'); protected toggle(): void { this.open.update((v) => !v); From 1179f04a6165da86a64c7e06d90494f1c41d7ffb Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:23 -0500 Subject: [PATCH 5/9] feat(discovery): hide Jira actions on stories while integrations are off Wrap the backlog's Import from Jira / Push all / Push selected buttons and the story detail's Push to Jira in the `integrations` flag, skip the backlog's Jira-target lookup, and only let INTEGRATION_SYNC reveal the row-selection column while bulk push exists (bulk delete still needs STORY_DELETE). --- .../discovery/pages/stories/stories.ts | 176 ++++++++++-------- .../discovery/pages/stories/story-detail.ts | 38 ++-- 2 files changed, 117 insertions(+), 97 deletions(-) diff --git a/src/app/features/discovery/pages/stories/stories.ts b/src/app/features/discovery/pages/stories/stories.ts index a4656c2..423fd15 100644 --- a/src/app/features/discovery/pages/stories/stories.ts +++ b/src/app/features/discovery/pages/stories/stories.ts @@ -29,6 +29,7 @@ import { } from '../../../workspace/data/integrations.models'; import { ToastService } from '../../../../shared/toast/toast.service'; import { messageForError } from '../../../../core/errors/error-message'; +import { FeatureFlags } from '../../../../core/features/feature-flags'; import { HttpErrorResponse } from '@angular/common/http'; import { Modal } from '../../../../shared/components/modal/modal'; import { Indeterminate } from '../../../../shared/directives/indeterminate'; @@ -91,54 +92,56 @@ type SortValue = `${StorySort}:${StorySortDirection}`;

{{ 'stories.subtitle' | transloco }}

- - + @if (integrationsEnabled) { + + + }
- + @if (integrationsEnabled) { + + } + @if (integrationsEnabled) { + + } -
- + + @if (membersEnabled) { +
+
+

{{ 'orgSettings.transfer' | transloco }}

+

+ {{ 'orgSettings.transferDesc' | transloco }} +

+
+
+ +
+
+ }
@@ -683,6 +687,8 @@ export class OrgSettings { protected readonly languageOptions = LANGUAGE_OPTIONS; protected readonly skeletonCards = [0, 1, 2]; + /** Ownership transfer needs other members, so it follows the `members` feature flag. */ + protected readonly membersEnabled = inject(FeatureFlags).isEnabled('members'); protected readonly state = signal<'loading' | 'ready' | 'error'>('loading'); protected readonly saving = signal(null); @@ -815,9 +821,12 @@ export class OrgSettings { }, error: () => this.state.set('error'), }); - this.api.listMembers(orgId).subscribe({ - next: (members) => this.members.set(members), - }); + // The member list only feeds the ownership-transfer picker. + if (this.membersEnabled) { + this.api.listMembers(orgId).subscribe({ + next: (members) => this.members.set(members), + }); + } } protected saveField(field: OrgField): void { From c565b3fcb54a4b80c4e9d80e6299aefa3897ed92 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:33 -0500 Subject: [PATCH 7/9] feat(billing): show the usage page's Manage plan link only with billing Usage and billing are separate flags; keep the usage page free of a dead link to /settings/billing when only `usage` is turned on. --- src/app/features/billing/pages/usage/usage.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/features/billing/pages/usage/usage.ts b/src/app/features/billing/pages/usage/usage.ts index 7c22dfe..d8ed2d9 100644 --- a/src/app/features/billing/pages/usage/usage.ts +++ b/src/app/features/billing/pages/usage/usage.ts @@ -5,6 +5,7 @@ import { lucideCircleAlert } from '@ng-icons/lucide'; import { TranslocoPipe } from '@jsverse/transloco'; import { RouterLink } from '@angular/router'; import { AuthStore } from '../../../../core/auth/auth.store'; +import { FeatureFlags } from '../../../../core/features/feature-flags'; import { HlmButton, HlmIcon, HlmSkeleton } from '../../../../shared/ui'; import { BillingStore } from '../../data/billing.store'; @@ -98,9 +99,11 @@ import { BillingStore } from '../../data/billing.store'; {{ 'billing.planName.' + u.planType | transloco }}

- - {{ 'usage.managePlan' | transloco }} - + @if (billingEnabled) { + + {{ 'usage.managePlan' | transloco }} + + } } @@ -113,6 +116,8 @@ export class Usage { protected readonly usage = this.store.usage; protected readonly usageState = this.store.usageState; + /** "Manage plan" opens the billing page, so it needs the `billing` feature as well. */ + protected readonly billingEnabled = inject(FeatureFlags).isEnabled('billing'); protected readonly clampedPercentage = computed(() => { const pct = this.usage()?.usagePercentage ?? 0; From 1be8be99ae378e28a0024b39a12ad5c6e33dd5d4 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:34 -0500 Subject: [PATCH 8/9] docs: document the MVP feature flags Add docs/FEATURE-FLAGS.md (each flag, what it hides, how to turn it on or add one, the invitation/ownership decisions and the backend FREE-plan limits) and link it from the README environments section. --- README.md | 6 ++++ docs/FEATURE-FLAGS.md | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 docs/FEATURE-FLAGS.md diff --git a/README.md b/README.md index 1dc7e2f..131862d 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,14 @@ export const environment = { production: false, apiUrl: '', wsUrl: '', + features: { billing: false, usage: false, integrations: false, /* … */ } satisfies FeatureFlagMap, } as const; ``` +`features` son los feature flags de build: el MVP oculta billing, usage, integraciones (Jira), +miembros/invitaciones, roles personalizados y los placeholders de cuenta. Ponlos en `true` en +**ambos** archivos para reactivarlos — ver [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md). + --- ## Arquitectura @@ -130,6 +135,7 @@ Detalle en [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). |------------------------------------------------------|-----------------------------------------------------| | [`docs/adr/`](docs/adr/) | Architecture Decision Records (el *por qué*) | | [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Docker · AWS S3 + CloudFront · variables de entorno | +| [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md) | Feature flags del MVP y cómo reactivarlos | | [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md) | Flujo de trabajo, ramas, commits, PR | | [`CHANGELOG.md`](CHANGELOG.md) | Historial de cambios (Keep a Changelog) | | [`.github/SECURITY.md`](.github/SECURITY.md) | Política de seguridad y reporte de vulnerabilidades | diff --git a/docs/FEATURE-FLAGS.md b/docs/FEATURE-FLAGS.md new file mode 100644 index 0000000..7464267 --- /dev/null +++ b/docs/FEATURE-FLAGS.md @@ -0,0 +1,74 @@ +# Feature Flags — Reqs-AI Web (Frontend) + +The deployed product ships only the **MVP** ("Podar el árbol"): capture a discovery session, +turn it into Gherkin user stories, and ground the AI with the project glossary and constraints. +Everything else stays in the codebase — and in the backend — but is switched off in the UI by a +build-time feature flag. Turning a flag on brings the feature back with **no other code change**. + +## Flags + +All flags default to `false` in **both** `src/environments/environment.ts` and +`src/environments/environment.prod.ts`. + +| Flag | Hides (routes → fall back to `/projects`) | Hides (UI) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `billing` | `/settings/billing`, `/billing/success`, `/billing/cancel` | Org settings nav entry · user-menu **Upgrade** CTA · Usage page **Manage plan** link | +| `usage` | `/settings/usage` | Org settings nav entry | +| `integrations` | `/settings/integrations`, `/settings/integrations/jira/callback` (Atlassian OAuth), `/projects/:id/settings/integrations` | Org + project settings nav entries · global Jira job banner (and its job polling / STOMP topic) · backlog **Import from Jira**, **Push all to Jira**, **Push to Jira (n)** · story detail **Push to Jira** · the backlog's Jira-target lookup | +| `members` | `/settings/members`, `/projects/:id/settings/members`, `/invitations/accept` (plus the legacy `/members` redirects) | Org + project settings nav entries · project overview **Members** card · command-palette **Members** action and member search hits · org General **Transfer ownership** card (and its member fetch) | +| `customRoles` | `/projects/:id/settings/roles`, `…/roles/new`, `…/roles/:roleId/edit` | Project settings nav entry (and the settings landing / overview "Settings" card no longer count it) | +| `notifications` | `/account/notifications` | Account nav "Soon" placeholder | +| `tokens` | `/account/tokens` | Account nav "Soon" placeholder | + +A hidden route does not match at all (`canMatch` returns `false`), so it behaves exactly like an +unknown URL: the router falls through to the wildcard `**` → `/projects` (query params are kept by +that redirect, as for any unknown URL). No blank page, no "no access" toast, and the lazy chunk is +never downloaded. + +The sidebar also hides a **Settings** entry when none of its sub-pages is reachable (e.g. an org +admin while `members` and `integrations` are off), and the org-level entry opens the first +reachable sub-page instead of the owner-only General. + +### Decisions + +- **Invitation links** (`/invitations/accept`) are behind `members`. While it is off nobody can + send an invitation from the UI, and an owner has no screen to see or remove whoever joins, so a + stale invite link must not add people to an org. Turning `members` on restores the flow. +- **Transfer ownership** (org General settings) is behind `members`: without members there is + nobody to pick, and its empty state asks the owner to invite someone. +- The story status filter still lists `EXPORTED`: it is data a story may already carry, not a link. + +## Turning a feature on + +1. Set the flag to `true` in **both** environment files (the production build uses + `environment.prod.ts` via `fileReplacements`). +2. Rebuild / redeploy. Nothing else changes: routes, nav entries and buttons come back as they were. + +Tests override flags with `provideFeatureFlags({ members: true })` in the TestBed providers. + +## Adding a flag + +1. Add the key to the `FeatureKey` union in `src/app/core/features/feature-flags.ts`; both + environment files then fail to compile until they define it (`satisfies FeatureFlagMap`). +2. Routes: `canMatch: [featureGuard('key')]`. When the route already has a `canMatch` guard, chain + it — `featureGuard('key', requirePermissionMatch('X'))` — because Angular runs every guard of a + `canMatch` array eagerly. +3. Sidebar: add `feature: 'key'` to the item in `src/app/layout/shell/shell-nav.ts`. +4. Anything else (buttons, cards, palette actions, data fetches): + `inject(FeatureFlags).isEnabled('key')`. +5. Extend the URL lists in `src/app/app.routes.spec.ts` and update this table. + +## Backend coupling + +Flags are **UI-only**: they are not a security boundary, and every backend endpoint stays live +(role/permission checks still apply server-side). + +- **Plan limits are enforced by the backend.** Each new organization gets a FREE subscription and + the workspace module rejects work beyond its limits with `422`: 25 projects + (`PROJECT_PLAN_LIMIT_EXCEEDED`), 50 glossary terms per project + (`GLOSSARY_TERM_PLAN_LIMIT_EXCEEDED`), 10 documents per project + (`PROJECT_DOCUMENT_PLAN_LIMIT_EXCEEDED`) and 3 members (`MEMBER_PLAN_LIMIT_EXCEEDED`). The UI shows + the localized error toast; with `billing` off there is no upgrade path, so raise the FREE limits + server-side if the MVP needs more. +- **AI token usage is metered, not enforced**: the backend records consumption against the plan + quota but no MVP flow is blocked by it, and no frontend guard or banner depends on billing. From 78ab854e91ff6c3e383c54cd8a1814c40b88d172 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:34 -0500 Subject: [PATCH 9/9] docs(changelog): note the MVP feature flags --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab50ae..12f28cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress. ### Added +- **MVP feature flags** (`feature/mvp-feature-flags`): a typed `features` map in both environment + files (`FeatureKey` union + `FeatureFlags.isEnabled()`), all **off** by default, so the deployed + product shows only the MVP. A `featureGuard()` `canMatch` makes a disabled route behave like an + unknown URL (falls through to `/projects`), and the sidebar, command palette, buttons and cards + drop what it hides: `billing` (billing pages, checkout returns, Upgrade CTA), `usage`, + `integrations` (Jira pages + OAuth callback, job banner, import/push actions), `members` (org + + project members, invitation landing, palette action/hits, ownership transfer), `customRoles` + (project roles), and the `notifications` / `tokens` account placeholders. The sidebar also hides + a **Settings** entry with no reachable sub-page. Flipping a flag restores the feature with no + other change; see `docs/FEATURE-FLAGS.md` (incl. the backend's FREE-plan limits). - **Discovery — live session presence** (`feature/discovery-presence`): the discovery chat now shows who else is viewing the **live** session — an overlapping avatar stack with a live pulse, a "+N" overflow bubble and a viewer count, in both the page header and the live session bar. It is fed by a new