diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ae88a..a031a33 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 — virtual-meeting audio capture** (`feature/system-audio-capture`): the analyst can now record a Zoom / Google Meet / Teams call, not only a face-to-face meeting. A compact **audio-source picker** next to the record button chooses **In person (microphone)** or **Virtual meeting (microphone + 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. 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/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/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 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; 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 { 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 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); 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;