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 }}
{{ 'stories.subtitle' | transloco }}
- {{ 'orgSettings.transferDesc' | transloco }} -
+ {{ 'orgSettings.transferDesc' | transloco }} +