diff --git a/docs/features/checked/web/settings-ia-rationalization-ui.md b/docs/features/checked/web/settings-ia-rationalization-ui.md index a104e7da1..ec6fa5e8a 100644 --- a/docs/features/checked/web/settings-ia-rationalization-ui.md +++ b/docs/features/checked/web/settings-ia-rationalization-ui.md @@ -20,7 +20,7 @@ The Settings shell has been rationalized from a mixed bucket of user preferences |---|---| | `/settings/admin` | `/administration/admin` | | `/settings/admin/:page` | `/administration/admin/:page` | -| `/settings/branding` | `/console/admin/branding` | +| `/settings/branding` | `/setup/tenant-branding` | | `/settings/identity-providers` | `/administration/identity-providers` | | `/settings/system` | `/administration/system` | | `/settings/security-data` | `/administration/security-data` | diff --git a/docs/implplan/SPRINT_20260311_005_FE_setup_admin_truthful_branding_and_notifications_routes.md b/docs/implplan/SPRINT_20260311_005_FE_setup_admin_truthful_branding_and_notifications_routes.md new file mode 100644 index 000000000..54ed88a56 --- /dev/null +++ b/docs/implplan/SPRINT_20260311_005_FE_setup_admin_truthful_branding_and_notifications_routes.md @@ -0,0 +1,69 @@ +# Sprint 20260311_005 - FE Setup Admin Truthful Branding And Notifications Routes + +## Topic & Scope +- Repair setup/admin pages that looked valid in route sweeps but failed as a first-time operator once actions were exercised. +- Restore truthful branding behavior on `/setup/tenant-branding`, including correct Authority contracts, reliable hydration, and honest read-only semantics when the session lacks write scope. +- Repair setup notifications, usage, and system action handoffs so setup pages lead to the intended working surfaces instead of dead or fallback routes. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/ui-analysis/01_SHELL_AND_NAVIGATION.md`, `docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md`, `docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md`, `docs/features/checked/web/settings-ia-rationalization-ui.md`, `docs/implplan/SPRINT_20260311_005_FE_setup_admin_truthful_branding_and_notifications_routes.md`. +- Expected evidence: focused Angular specs, rebuilt web bundle synced into `compose_console-dist`, a passing live setup/admin Playwright sweep, and a passing live canonical route sweep. + +## Dependencies & Concurrency +- Depends on the live compose stack at `https://stella-ops.local`. +- Safe parallelism: stay inside setup/admin route ownership, branding contracts, and related route tests. Do not broaden this slice into Authority backend contract changes beyond the already-shipped endpoints. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/technical/architecture/console-branding.md` +- `docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md` + +## Delivery Tracker + +### FE-SETUP-ADMIN-001 - Make setup branding truthful and tenant-aware +Status: DONE +Dependency: none +Owners: QA, 3rd Line Support, Product Manager, Architect, Developer +Task description: +- Live Playwright verification showed `/setup/tenant-branding` was not a truthful setup/admin surface. The page rendered a facade that suggested inline editing, while the real Authority contracts required tenant-aware reads and admin writes through `/console/admin/branding`. Direct probes confirmed the setup route was triggering `GET /console/branding` without a tenant and surfacing `tenantId query parameter is required`. +- The clean fix is to make the canonical setup route host the real branding editor, centralize tenant resolution and admin update contracts inside the shared branding service, and expose honest read-only UX when the current session has branding read scope but not write scope. + +Completion criteria: +- [x] `/setup/tenant-branding` hosts the real branding editor instead of a facade. +- [x] Branding reads and writes use the correct tenant-aware Authority contracts. +- [x] Async branding hydration reliably clears loading state on the live shell. +- [x] Read-only sessions show explicit non-editable controls and a truthful permission message. +- [x] Focused branding service and route tests pass. + +### FE-SETUP-ADMIN-002 - Repair setup notifications, usage, and system action handoffs +Status: DONE +Dependency: FE-SETUP-ADMIN-001 +Owners: QA, 3rd Line Support, Product Manager, Architect, Developer +Task description: +- Manual Playwright action testing found `/setup/notifications` navigating `Create Rule` into a broken `/setup/notifications/new` path, while `/setup/usage` and `/setup/system` exposed inert buttons that did not carry operators into the actual working pages. +- The correct product behavior is to keep setup pages as navigational truth surfaces: notification actions must land on canonical rule/simulator children, and usage/system actions must link directly into the corresponding operational pages. + +Completion criteria: +- [x] `Create Rule`, edit, and simulator handoffs under setup notifications use canonical child routes. +- [x] Usage and system CTA buttons are real links to the operational surfaces they advertise. +- [x] Route ownership regression coverage protects the setup/admin aliases. +- [x] Live setup/admin Playwright sweep passes with zero runtime issues. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-11 | Sprint created after live Playwright action testing found that `/setup/tenant-branding` failed to load branding under the real session, `/setup/notifications` misrouted `Create Rule`, and `/setup/usage` plus `/setup/system` exposed inert buttons. | Developer | +| 2026-03-11 | Replaced the setup branding facade with the real branding editor, centralized tenant-aware admin branding reads/writes in `BrandingService`, converted async editor state to signals so the live shell clears loading reliably, and made read-only branding sessions truthful instead of deceptively editable. | Developer | +| 2026-03-11 | Repaired setup notifications child navigation, rewired usage/system CTAs to canonical operational pages, refreshed route ownership coverage, and updated stale user-facing docs to point at `/setup/tenant-branding` as the canonical route. | Developer | +| 2026-03-11 | `npx ng test --watch=false --progress=false --ts-config tsconfig.spec.features.json --include=src/app/core/branding/branding.service.spec.ts` passed `9/9`; `npx ng test --watch=false --progress=false --ts-config tsconfig.spec.features.json --include=src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts` passed `49/49`; `npx ng test --watch=false --progress=false --include=src/app/routes/route-surface-ownership.spec.ts` passed `7/7`; `npm run build` passed; the rebuilt bundle was synced into `compose_console-dist`, `stellaops-router-gateway` was restarted, `node ./scripts/live-setup-admin-action-sweep.mjs` passed with `failedActionCount=0` and `runtimeIssueCount=0`, and `node ./scripts/live-frontdoor-canonical-route-sweep.mjs` returned `111/111` against `https://stella-ops.local`. | QA | + +## Decisions & Risks +- Decision: `/setup/tenant-branding` is the canonical user-facing branding route. Legacy settings/admin aliases may remain, but the setup route must host the truthful experience. +- Decision: branding contract knowledge belongs in `BrandingService`, not scattered raw HTTP calls inside the editor component. This keeps tenant resolution and Authority headers consistent across reads and writes. +- Decision: when the session lacks branding write scope, the UI must be explicitly read-only. Disabled saves with still-editable inputs are deceptive and fail the zero-tolerance QA bar. +- Risk: `docs/ui-analysis/**` contains broad analytical snapshots of the UI. This sprint updated the specific canonical-route references touched by the fix, but those analysis docs may still contain other stale historical entries outside this slice. + +## Next Checkpoints +- Commit the setup/admin truth-surface repair. +- Clean transient Playwright output from the working tree. +- Start the next deep page-action sweep from the freshly rebuilt stack and take the next failing page family through the same fix loop. diff --git a/docs/ui-analysis/01_SHELL_AND_NAVIGATION.md b/docs/ui-analysis/01_SHELL_AND_NAVIGATION.md index 704e8900d..7eb75648e 100644 --- a/docs/ui-analysis/01_SHELL_AND_NAVIGATION.md +++ b/docs/ui-analysis/01_SHELL_AND_NAVIGATION.md @@ -164,7 +164,7 @@ Source: `src/app/core/navigation/navigation.config.ts` | clients | OAuth Clients | `/console/admin/clients` | app | - | | tokens | Tokens | `/console/admin/tokens` | token | - | | audit | Unified Audit Log | `/admin/audit` | log | Has children: Dashboard, All Events, Policy Audit, Authority Audit, VEX Audit, Integration Audit, Export | -| branding | Branding | `/console/admin/branding` | palette | - | +| branding | Branding | `/setup/tenant-branding` | palette | Canonical setup/admin surface backed by `BrandingEditorComponent`. | | platform-status | Platform Status | `/console/status` | monitor | - | | trivy-db | Trivy DB Settings | `/concelier/trivy-db-settings` | database | - | | admin-notifications | Notification Admin | `/admin/notifications` | bell-config | - | diff --git a/docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md b/docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md index 012cf9a38..2569b44c5 100644 --- a/docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md +++ b/docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md @@ -19,7 +19,7 @@ | `/console/admin/clients` | `ClientsListComponent` | authority:clients:read | | `/console/admin/tokens` | `TokensListComponent` | authority:tokens:read | | `/console/admin/audit` | `AuditLogComponent` | authority:audit:read | -| `/console/admin/branding` | `BrandingEditorComponent` | authority:branding:read | +| `/setup/tenant-branding` | `BrandingEditorComponent` | authority:branding:read | ``` ┌────────────────────────────────────────────────────────────────────────────────┐ diff --git a/docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md b/docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md index e9dc8fe61..147107903 100644 --- a/docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md +++ b/docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md @@ -113,7 +113,7 @@ | `/console/admin/clients` | `ClientsListComponent` | features/console-admin/clients/ | authority:clients:read | | `/console/admin/tokens` | `TokensListComponent` | features/console-admin/tokens/ | authority:tokens:read | | `/console/admin/audit` | `AuditLogComponent` | features/console-admin/audit/ | authority:audit:read | -| `/console/admin/branding` | `BrandingEditorComponent` | features/console-admin/branding/ | authority:branding:read | +| `/setup/tenant-branding` | `BrandingEditorComponent` | features/console-admin/branding/ | authority:branding:read | | `/admin/audit` | auditLogRoutes | features/audit-log/ | requireAuthGuard | | `/admin/notifications` | adminNotificationsRoutes | features/admin-notifications/ | requireAuthGuard | | `/admin/trust` | trustAdminRoutes | features/trust-admin/ | requireAuthGuard + signer:read | diff --git a/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs new file mode 100644 index 000000000..e029241a8 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDirectory = path.join(webRoot, 'output', 'playwright'); +const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); +const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); +const resultPath = path.join(outputDirectory, 'live-setup-admin-action-sweep.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; + +function createRuntime() { + return { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + }; +} + +function attachRuntimeListeners(page, runtime) { + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push({ + timestamp: Date.now(), + page: page.url(), + text: message.text(), + }); + } + }); + + page.on('pageerror', (error) => { + runtime.pageErrors.push({ + timestamp: Date.now(), + page: page.url(), + message: error.message, + }); + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + const errorText = request.failure()?.errorText ?? 'unknown'; + if (errorText === 'net::ERR_ABORTED') { + return; + } + + runtime.requestFailures.push({ + timestamp: Date.now(), + page: page.url(), + method: request.method(), + url, + error: errorText, + }); + }); + + page.on('response', (response) => { + const url = response.url(); + if (!url.includes('/api/') && !url.includes('/console/')) { + return; + } + + if (response.status() >= 400) { + runtime.responseErrors.push({ + timestamp: Date.now(), + page: page.url(), + method: response.request().method(), + status: response.status(), + url, + }); + } + }); +} + +async function captureSnapshot(page, label) { + const heading = await page.locator('h1,h2').first().textContent().catch(() => ''); + const alerts = await page.locator('[role="alert"], .alert, .toast').allTextContents().catch(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: (heading || '').trim(), + alerts: alerts.map((text) => text.trim()).filter(Boolean), + }; +} + +async function gotoRoute(page, route) { + await page.goto(`https://stella-ops.local${route}?${scopeQuery}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await page.waitForTimeout(2_000); +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath, + reportPath, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath }); + const page = await context.newPage(); + const runtime = createRuntime(); + attachRuntimeListeners(page, runtime); + + const startedAt = Date.now(); + const results = []; + + await gotoRoute(page, '/setup/tenant-branding'); + const brandingTitleInput = page.locator('#title').first(); + const applyChangesButton = page.getByRole('button', { name: 'Apply Changes', exact: true }).first(); + const brandingBefore = await captureSnapshot(page, 'branding-before'); + await brandingTitleInput.waitFor({ state: 'visible', timeout: 10_000 }); + const applyDisabledBefore = await applyChangesButton.isDisabled().catch(() => true); + const titleEditable = await brandingTitleInput.isEditable().catch(() => false); + let applyDisabledAfter = applyDisabledBefore; + + if (titleEditable) { + const originalTitle = await brandingTitleInput.inputValue(); + await brandingTitleInput.fill(`${originalTitle} QA`); + await page.waitForTimeout(300); + applyDisabledAfter = await applyChangesButton.isDisabled().catch(() => true); + } + + const brandingAfter = await captureSnapshot(page, 'branding-after-edit'); + results.push({ + action: 'tenant-branding-editor', + ok: brandingBefore.url.includes('/setup/tenant-branding') + && /branding configuration/i.test(brandingBefore.heading) + && !brandingBefore.alerts.some((alert) => /failed to load branding/i.test(alert)) + && ( + (titleEditable && applyDisabledBefore && !applyDisabledAfter) + || (!titleEditable + && applyDisabledBefore + && applyDisabledAfter + && brandingAfter.alerts.some((alert) => /read-only for this session/i.test(alert))) + ) + && !brandingAfter.alerts.some((alert) => /failed to load branding/i.test(alert)), + titleEditable, + applyDisabledBefore, + applyDisabledAfter, + snapshot: brandingAfter, + }); + + await gotoRoute(page, '/setup/notifications'); + await page.getByRole('button', { name: 'Create Rule', exact: true }).click({ timeout: 10_000 }); + await page.waitForTimeout(2_000); + results.push({ + action: 'notifications-create-rule', + ok: page.url().includes('/setup/notifications/rules/new'), + snapshot: await captureSnapshot(page, 'notifications-create-rule'), + }); + + await gotoRoute(page, '/setup/usage'); + await page.getByRole('link', { name: 'Configure Quotas', exact: true }).click({ timeout: 10_000 }); + await page.waitForTimeout(2_000); + results.push({ + action: 'usage-configure-quotas', + ok: page.url().includes('/ops/operations/quotas'), + snapshot: await captureSnapshot(page, 'usage-configure-quotas'), + }); + + const systemActions = [ + { name: 'View Details', expected: '/ops/operations/system-health' }, + { name: 'Run Doctor', expected: '/ops/operations/doctor' }, + { name: 'View SLOs', expected: '/ops/operations/health-slo' }, + { name: 'View Jobs', expected: '/ops/operations/jobs-queues' }, + ]; + + for (const action of systemActions) { + await gotoRoute(page, '/setup/system'); + await page.getByRole('link', { name: action.name, exact: true }).click({ timeout: 10_000 }); + await page.waitForTimeout(2_000); + results.push({ + action: `system-${action.name.toLowerCase().replace(/\s+/g, '-')}`, + ok: page.url().includes(action.expected), + snapshot: await captureSnapshot(page, `system-${action.name}`), + }); + } + + const runtimeIssues = [ + ...runtime.consoleErrors.map((entry) => `console:${entry.text}`), + ...runtime.pageErrors.map((entry) => `pageerror:${entry.message}`), + ...runtime.requestFailures.map((entry) => `requestfailed:${entry.method} ${entry.url} ${entry.error}`), + ...runtime.responseErrors.map((entry) => `response:${entry.status} ${entry.method} ${entry.url}`), + ]; + + const result = { + generatedAtUtc: new Date().toISOString(), + durationMs: Date.now() - startedAt, + results, + runtime, + failedActionCount: results.filter((entry) => !entry.ok).length, + runtimeIssueCount: runtimeIssues.length, + runtimeIssues, + }; + + writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + + await context.close(); + await browser.close(); + + if (result.failedActionCount > 0 || result.runtimeIssueCount > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + process.stderr.write(`[live-setup-admin-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts index faa1f2d6c..9908b56b6 100644 --- a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts @@ -2,14 +2,36 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { BrandingService } from './branding.service'; +import { AuthSessionStore } from '../auth/auth-session.store'; +import { ConsoleSessionStore } from '../console/console-session.store'; +import { PlatformContextStore } from '../context/platform-context.store'; +import { StellaOpsHeaders } from '../http/stella-ops-headers'; describe('BrandingService', () => { let service: BrandingService; let httpMock: HttpTestingController; + let mockAuthSession: { getActiveTenantId: jasmine.Spy }; + let mockConsoleSession: { selectedTenantId: jasmine.Spy }; + let mockContextStore: { tenantId: jasmine.Spy }; beforeEach(() => { + mockAuthSession = { + getActiveTenantId: jasmine.createSpy('getActiveTenantId').and.returnValue(null), + }; + mockConsoleSession = { + selectedTenantId: jasmine.createSpy('selectedTenantId').and.returnValue(null), + }; + mockContextStore = { + tenantId: jasmine.createSpy('tenantId').and.returnValue(null), + }; + TestBed.configureTestingModule({ imports: [HttpClientTestingModule], + providers: [ + { provide: AuthSessionStore, useValue: mockAuthSession }, + { provide: ConsoleSessionStore, useValue: mockConsoleSession }, + { provide: PlatformContextStore, useValue: mockContextStore }, + ], }); service = TestBed.inject(BrandingService); httpMock = TestBed.inject(HttpTestingController); @@ -59,6 +81,22 @@ describe('BrandingService', () => { }); }); + it('uses the scoped tenant when no explicit tenant argument is provided', () => { + mockContextStore.tenantId.and.returnValue('demo-prod'); + + service.fetchBranding().subscribe(); + + const req = httpMock.expectOne('/console/branding?tenantId=demo-prod'); + expect(req.request.method).toBe('GET'); + req.flush({ + tenantId: 'demo-prod', + displayName: 'Demo Production', + logoUri: null, + faviconUri: null, + themeTokens: {}, + }); + }); + it('should fall back to defaults on HTTP error without console.warn', () => { const warnSpy = spyOn(console, 'warn'); @@ -110,6 +148,72 @@ describe('BrandingService', () => { expect(service.isLoaded()).toBe(true); }); + it('reads admin branding with the canonical tenant header', () => { + mockContextStore.tenantId.and.returnValue('demo-prod'); + + service.fetchAdminBranding().subscribe((response) => { + expect(response.branding.tenantId).toBe('demo-prod'); + expect(response.branding.title).toBe('Demo Production'); + expect(response.metadata?.hash).toBe('hash-123'); + }); + + const req = httpMock.expectOne('/console/admin/branding'); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('demo-prod'); + req.flush({ + branding: { + tenantId: 'demo-prod', + displayName: 'Demo Production', + logoUri: null, + faviconUri: null, + themeTokens: {}, + }, + metadata: { + tenantId: 'demo-prod', + hash: 'hash-123', + }, + }); + }); + + it('updates admin branding through the admin endpoint with tenant header', () => { + mockContextStore.tenantId.and.returnValue('demo-prod'); + + service.updateBranding({ + title: 'Demo Production', + logoUrl: 'data:image/png;base64,AAAA', + faviconUrl: 'data:image/png;base64,BBBB', + themeTokens: { + '--theme-brand-primary': '#112233', + }, + }).subscribe((response) => { + expect(response.branding.title).toBe('Demo Production'); + expect(response.branding.logoUrl).toBe('data:image/png;base64,AAAA'); + }); + + const req = httpMock.expectOne('/console/admin/branding'); + expect(req.request.method).toBe('PUT'); + expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('demo-prod'); + expect(req.request.body).toEqual({ + displayName: 'Demo Production', + logoUri: 'data:image/png;base64,AAAA', + faviconUri: 'data:image/png;base64,BBBB', + themeTokens: { + '--theme-brand-primary': '#112233', + }, + }); + req.flush({ + branding: { + tenantId: 'demo-prod', + displayName: 'Demo Production', + logoUri: 'data:image/png;base64,AAAA', + faviconUri: 'data:image/png;base64,BBBB', + themeTokens: { + '--theme-brand-primary': '#112233', + }, + }, + }); + }); + it('does not overwrite the current route title when branding is applied', () => { document.title = 'Reachability - Stella Ops Dashboard'; diff --git a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts index 5f9366623..3946376d7 100644 --- a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts @@ -1,8 +1,13 @@ import { Injectable, inject, signal } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, of } from 'rxjs'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable, of, throwError } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators'; +import { AuthSessionStore } from '../auth/auth-session.store'; +import { ConsoleSessionStore } from '../console/console-session.store'; +import { PlatformContextStore } from '../context/platform-context.store'; +import { StellaOpsHeaders } from '../http/stella-ops-headers'; + export interface BrandingConfiguration { tenantId: string; title?: string; @@ -16,6 +21,24 @@ export interface BrandingResponse { branding: BrandingConfiguration; } +export interface BrandingMetadata { + tenantId: string; + updatedAtUtc?: string; + updatedBy?: string; + hash?: string; +} + +export interface AdminBrandingResponse extends BrandingResponse { + metadata?: BrandingMetadata; +} + +export interface BrandingUpdateRequest { + title?: string; + logoUrl?: string; + faviconUrl?: string; + themeTokens?: Record; +} + /** Shape returned by the Authority /console/branding endpoint. */ interface AuthorityBrandingDto { tenantId: string; @@ -25,11 +48,26 @@ interface AuthorityBrandingDto { themeTokens: Record; } +interface AuthorityAdminBrandingEnvelopeDto { + branding: AuthorityBrandingDto; + metadata?: BrandingMetadata; +} + +interface AuthorityUpdateBrandingRequestDto { + displayName?: string; + logoUri?: string; + faviconUri?: string; + themeTokens?: Record; +} + @Injectable({ providedIn: 'root' }) export class BrandingService { private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + private readonly consoleSession = inject(ConsoleSessionStore); + private readonly context = inject(PlatformContextStore); // Signal for current branding configuration readonly currentBranding = signal(null); @@ -45,29 +83,81 @@ export class BrandingService { /** * Fetch branding configuration from the Authority API */ - fetchBranding(tenantId: string = 'default'): Observable { + fetchBranding( + tenantId?: string | null, + options: { fallbackToDefault?: boolean } = {} + ): Observable { + const resolvedTenantId = this.getActiveTenantId(tenantId); + return this.http.get('/console/branding', { - params: { tenantId }, + params: { tenantId: resolvedTenantId }, }).pipe( - map((dto) => ({ - branding: { - tenantId: dto.tenantId, - title: dto.displayName || undefined, - logoUrl: dto.logoUri || undefined, - faviconUrl: dto.faviconUri || undefined, - themeTokens: dto.themeTokens, - } satisfies BrandingConfiguration, - })), + map((dto) => this.mapBrandingResponse(dto)), tap((response) => { this.applyBranding(response.branding); }), - catchError(() => { + catchError((error) => { + if (options.fallbackToDefault === false) { + return throwError(() => error); + } + this.applyBranding(this.defaultBranding); return of({ branding: this.defaultBranding }); }) ); } + fetchAdminBranding(tenantId?: string | null): Observable { + const resolvedTenantId = this.getActiveTenantId(tenantId); + + return this.http.get('/console/admin/branding', { + headers: this.buildTenantHeaders(resolvedTenantId), + }).pipe( + map((response) => ({ + branding: this.mapBrandingResponse(response.branding).branding, + metadata: response.metadata, + })), + tap((response) => { + this.applyBranding(response.branding); + }) + ); + } + + updateBranding( + request: BrandingUpdateRequest, + tenantId?: string | null + ): Observable { + const resolvedTenantId = this.getActiveTenantId(tenantId); + const payload: AuthorityUpdateBrandingRequestDto = { + displayName: request.title || undefined, + logoUri: request.logoUrl || undefined, + faviconUri: request.faviconUrl || undefined, + themeTokens: request.themeTokens ?? {}, + }; + + return this.http.put<{ branding: AuthorityBrandingDto }>( + '/console/admin/branding', + payload, + { + headers: this.buildTenantHeaders(resolvedTenantId), + } + ).pipe( + map((response) => this.mapBrandingResponse(response.branding)), + tap((response) => { + this.applyBranding(response.branding); + }) + ); + } + + getActiveTenantId(tenantId?: string | null): string { + return this.normalizeTenantId(tenantId) + ?? this.normalizeTenantId(this.context.tenantId()) + ?? this.normalizeTenantId(this.consoleSession.selectedTenantId()) + ?? this.normalizeTenantId(this.authSession.getActiveTenantId()) + ?? this.readTenantIdFromLocation() + ?? this.defaultBranding.tenantId; + } + /** * Apply branding configuration to the UI */ @@ -202,4 +292,37 @@ export class BrandingService { reader.readAsDataURL(file); }); } + + private mapBrandingResponse(dto: AuthorityBrandingDto): BrandingResponse { + return { + branding: { + tenantId: dto.tenantId, + title: dto.displayName || undefined, + logoUrl: dto.logoUri || undefined, + faviconUrl: dto.faviconUri || undefined, + themeTokens: dto.themeTokens, + } satisfies BrandingConfiguration, + }; + } + + private buildTenantHeaders(tenantId: string): HttpHeaders { + return new HttpHeaders({ + [StellaOpsHeaders.Tenant]: tenantId, + }); + } + + private normalizeTenantId(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; + } + + private readTenantIdFromLocation(): string | null { + if (typeof window === 'undefined') { + return null; + } + + const params = new URLSearchParams(window.location.search); + return this.normalizeTenantId(params.get('tenant')) + ?? this.normalizeTenantId(params.get('tenantId')); + } } diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 7097ce56d..48018a2d1 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -570,7 +570,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'branding', label: 'Branding', - route: '/console/admin/branding', + route: '/setup/tenant-branding', icon: 'palette', }, { diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts index 2624c0b27..c3dc2c873 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts @@ -15,7 +15,8 @@ describe('NotificationRuleListComponent', () => { let component: NotificationRuleListComponent; let fixture: ComponentFixture; let mockApi: jasmine.SpyObj; - let mockRouter: jasmine.SpyObj; + let mockRouter: { navigate: jasmine.Spy }; + let mockParentRoute: Record; const mockRules: NotifierRule[] = [ { @@ -78,7 +79,10 @@ describe('NotificationRuleListComponent', () => { 'updateRule', 'deleteRule', ]); - mockRouter = jasmine.createSpyObj('Router', ['navigate']); + mockRouter = { + navigate: jasmine.createSpy('navigate'), + }; + mockParentRoute = {}; mockApi.listRules.and.returnValue(of({ items: mockRules, total: 4 })); @@ -86,8 +90,8 @@ describe('NotificationRuleListComponent', () => { imports: [NotificationRuleListComponent], providers: [ { provide: NOTIFIER_API, useValue: mockApi }, - { provide: Router, useValue: mockRouter }, - { provide: ActivatedRoute, useValue: {} }, + { provide: Router, useValue: mockRouter as unknown as Router }, + { provide: ActivatedRoute, useValue: { parent: mockParentRoute } }, ], }).compileComponents(); @@ -268,7 +272,9 @@ describe('NotificationRuleListComponent', () => { it('should navigate to new rule page', () => { component.createRule(); - expect(mockRouter.navigate).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['rules', 'new'], { + relativeTo: mockParentRoute, + }); }); }); @@ -276,7 +282,9 @@ describe('NotificationRuleListComponent', () => { it('should navigate to edit rule page', () => { component.editRule(mockRules[0]); - expect(mockRouter.navigate).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['rules', 'rule-1'], { + relativeTo: mockParentRoute, + }); }); }); @@ -284,7 +292,10 @@ describe('NotificationRuleListComponent', () => { it('should navigate to simulator with rule ID', async () => { await component.testRule(mockRules[0]); - expect(mockRouter.navigate).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['simulator'], { + relativeTo: mockParentRoute, + queryParams: { ruleId: 'rule-1' }, + }); }); }); @@ -377,6 +388,7 @@ describe('NotificationRuleListComponent', () => { describe('template rendering', () => { beforeEach(async () => { await component.ngOnInit(); + await fixture.whenStable(); fixture.detectChanges(); }); @@ -396,8 +408,9 @@ describe('NotificationRuleListComponent', () => { }); it('should display rules table', () => { + fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.data-table')).toBeTruthy(); + expect(compiled.querySelectorAll('tbody tr').length).toBe(mockRules.length); }); it('should display loading state when loading', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts index 700e683be..a3622da72 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts @@ -540,17 +540,17 @@ export class NotificationRuleListComponent implements OnInit { } createRule(): void { - this.router.navigate(['new'], { relativeTo: this.route }); + this.router.navigate(['rules', 'new'], { relativeTo: this.dashboardRoute() }); } editRule(rule: NotifierRule): void { - this.router.navigate([rule.ruleId, 'edit'], { relativeTo: this.route }); + this.router.navigate(['rules', rule.ruleId], { relativeTo: this.dashboardRoute() }); } async testRule(rule: NotifierRule): Promise { // Navigate to simulator with pre-selected rule - this.router.navigate(['..', 'simulator'], { - relativeTo: this.route, + this.router.navigate(['simulator'], { + relativeTo: this.dashboardRoute(), queryParams: { ruleId: rule.ruleId }, }); } @@ -594,4 +594,8 @@ export class NotificationRuleListComponent implements OnInit { // Format "chn-slack-security" -> "slack-security" return channelId.replace(/^chn-/, ''); } + + private dashboardRoute(): ActivatedRoute { + return this.route.parent ?? this.route; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts index 5c3ad1851..bb54ccc04 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts @@ -1,8 +1,7 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { HttpClient } from '@angular/common/http'; -import { BrandingService, BrandingConfiguration } from '../../../core/branding/branding.service'; +import { BrandingService } from '../../../core/branding/branding.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service'; import { StellaOpsScopes } from '../../../core/auth/scopes'; @@ -24,27 +23,33 @@ interface ThemeToken { - - @if (error) { -
{{ error }}
+ + @if (!canWrite) { +
+ Branding is read-only for this session. Changes require branding write permission. +
} - @if (success) { -
{{ success }}
+ @if (error()) { +
{{ error() }}
} - @if (isLoading) { + @if (success()) { +
{{ success() }}
+ } + + @if (isLoading()) {
Loading branding configuration...
} @else {
@@ -58,6 +63,7 @@ interface ThemeToken { type="text" [(ngModel)]="formData.title" (ngModelChange)="markAsChanged()" + [readonly]="!canWrite" placeholder="Stella Ops Dashboard" maxlength="100"> Displayed in browser tab and header @@ -76,6 +82,7 @@ interface ThemeToken { Logo preview @@ -108,6 +117,7 @@ interface ThemeToken { Favicon preview @@ -154,6 +166,7 @@ interface ThemeToken { type="text" [(ngModel)]="token.value" (ngModelChange)="markAsChanged()" + [readonly]="!canWrite" placeholder="var(--color-surface-primary)" maxlength="50" class="token-input"> @@ -162,6 +175,7 @@ interface ThemeToken { type="color" [(ngModel)]="token.value" (ngModelChange)="markAsChanged()" + [disabled]="!canWrite" class="color-picker"> }
@@ -177,17 +191,19 @@ interface ThemeToken { @@ -526,16 +542,15 @@ interface ThemeToken { `] }) export class BrandingEditorComponent implements OnInit { - private readonly http = inject(HttpClient); private readonly brandingService = inject(BrandingService); private readonly freshAuth = inject(FreshAuthService); private readonly auth = inject(AUTH_SERVICE); - isLoading = false; - isSaving = false; - error: string | null = null; - success: string | null = null; - hasChanges = false; + readonly isLoading = signal(false); + readonly isSaving = signal(false); + readonly error = signal(null); + readonly success = signal(null); + readonly hasChanges = signal(false); formData = { title: '', @@ -564,10 +579,10 @@ export class BrandingEditorComponent implements OnInit { } loadCurrentBranding(): void { - this.isLoading = true; - this.error = null; + this.isLoading.set(true); + this.error.set(null); - this.http.get<{ branding: BrandingConfiguration }>('/console/branding').subscribe({ + this.brandingService.fetchAdminBranding().subscribe({ next: (response) => { const branding = response.branding; this.formData.title = branding.title || ''; @@ -576,12 +591,12 @@ export class BrandingEditorComponent implements OnInit { this.formData.themeTokens = branding.themeTokens || {}; this.initializeThemeTokens(); - this.isLoading = false; - this.hasChanges = false; + this.isLoading.set(false); + this.hasChanges.set(false); }, error: (err) => { - this.error = 'Failed to load branding: ' + (err.error?.message || err.message); - this.isLoading = false; + this.error.set('Failed to load branding: ' + (err.error?.message || err.message)); + this.isLoading.set(false); this.initializeThemeTokens(); } }); @@ -619,11 +634,19 @@ export class BrandingEditorComponent implements OnInit { } markAsChanged(): void { - this.hasChanges = true; - this.success = null; + if (!this.canWrite) { + return; + } + + this.hasChanges.set(true); + this.success.set(null); } async onLogoSelected(event: Event): Promise { + if (!this.canWrite) { + return; + } + const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; @@ -632,19 +655,23 @@ export class BrandingEditorComponent implements OnInit { const dataUri = await this.brandingService.fileToDataUri(file); if (!this.brandingService.validateAssetSize(dataUri)) { - this.error = 'Logo file is too large (max 256KB)'; + this.error.set('Logo file is too large (max 256KB)'); return; } this.formData.logoUrl = dataUri; this.markAsChanged(); - this.error = null; + this.error.set(null); } catch (err) { - this.error = 'Failed to process logo file'; + this.error.set('Failed to process logo file'); } } async onFaviconSelected(event: Event): Promise { + if (!this.canWrite) { + return; + } + const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; @@ -653,29 +680,41 @@ export class BrandingEditorComponent implements OnInit { const dataUri = await this.brandingService.fileToDataUri(file); if (!this.brandingService.validateAssetSize(dataUri)) { - this.error = 'Favicon file is too large (max 256KB)'; + this.error.set('Favicon file is too large (max 256KB)'); return; } this.formData.faviconUrl = dataUri; this.markAsChanged(); - this.error = null; + this.error.set(null); } catch (err) { - this.error = 'Failed to process favicon file'; + this.error.set('Failed to process favicon file'); } } removeLogo(): void { + if (!this.canWrite) { + return; + } + this.formData.logoUrl = ''; this.markAsChanged(); } removeFavicon(): void { + if (!this.canWrite) { + return; + } + this.formData.faviconUrl = ''; this.markAsChanged(); } addCustomToken(): void { + if (!this.canWrite) { + return; + } + if (!this.newToken.key || !this.newToken.value) return; // Ensure key starts with --theme- @@ -698,9 +737,9 @@ export class BrandingEditorComponent implements OnInit { const freshAuthOk = await this.freshAuth.requireFreshAuth('Apply branding requires fresh authentication'); if (!freshAuthOk) return; - this.isSaving = true; - this.error = null; - this.success = null; + this.isSaving.set(true); + this.error.set(null); + this.success.set(null); // Build theme tokens object from themeTokens array const themeTokens: Record = {}; @@ -715,27 +754,21 @@ export class BrandingEditorComponent implements OnInit { themeTokens }; - this.http.put('/console/branding', payload).subscribe({ + this.brandingService.updateBranding(payload).subscribe({ next: () => { - this.success = 'Branding applied successfully! Refreshing page...'; - this.hasChanges = false; - - // Apply branding immediately - this.brandingService.applyBranding({ - tenantId: 'current', - ...payload - }); + this.success.set('Branding applied successfully! Refreshing page...'); + this.hasChanges.set(false); // Reload page after 2 seconds to ensure all components reflect the changes setTimeout(() => { window.location.reload(); }, 2000); - this.isSaving = false; + this.isSaving.set(false); }, error: (err) => { - this.error = 'Failed to apply branding: ' + (err.error?.message || err.message); - this.isSaving = false; + this.error.set('Failed to apply branding: ' + (err.error?.message || err.message)); + this.isSaving.set(false); } }); } diff --git a/src/Web/StellaOps.Web/src/app/features/settings/branding/branding-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/branding/branding-settings-page.component.ts index 07294489d..f4a48d914 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/branding/branding-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/branding/branding-settings-page.component.ts @@ -1,107 +1,19 @@ /** * Branding Settings Page * Sprint: SPRINT_20260118_002_FE_settings_consolidation + * + * Canonical setup/admin branding routes must expose the real branding editor, + * not a facade with inert inline save actions. */ -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { BrandingEditorComponent } from '../../console-admin/branding/branding-editor.component'; @Component({ - selector: 'app-branding-settings-page', - imports: [], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-

Tenant & Branding

-

Customize appearance and branding for your tenant

- -
-
-

Logo

-

Upload your organization's logo.

-
- 🏢 -
- -
- -
-

Title & Name

-

Customize the application title.

-
- - -
- -
- -
-

Theme Tokens

-

Customize colors and theme variables.

-
-
- Primary Color -
- -
-
-
- `, - styles: [` - .branding-settings { max-width: 1000px; } - .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } - .page-subtitle { margin: 0 0 2rem; color: var(--color-text-secondary); } - .settings-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1.5rem; - } - .settings-section { - padding: 1.5rem; - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - } - .settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); } - .settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); } - .logo-preview { - display: flex; - align-items: center; - justify-content: center; - width: 100px; - height: 100px; - background: var(--color-surface-secondary); - border: 1px dashed var(--color-border-primary); - border-radius: var(--radius-lg); - margin-bottom: 1rem; - } - .logo-placeholder { font-size: 3rem; } - .form-group { margin-bottom: 1rem; } - .form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); } - .form-input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - font-size: 0.875rem; - } - .color-preview { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 1rem; - } - .color-swatch { width: 32px; height: 32px; border-radius: var(--radius-md); } - .btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; } - .btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); } - .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } - `] + selector: 'app-branding-settings-page', + imports: [BrandingEditorComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, }) -export class BrandingSettingsPageComponent { - private readonly router = inject(Router); - - openEditor(): void { - void this.router.navigate(['/console-admin/branding']); - } -} +export class BrandingSettingsPageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts b/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts index d7a43d4ad..e55efb768 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts @@ -95,7 +95,7 @@ export const SETTINGS_ROUTES: Routes = [ { path: 'branding', title: 'Tenant & Branding', - redirectTo: redirectToCanonical('/console/admin/branding'), + redirectTo: redirectToCanonical('/setup/tenant-branding'), pathMatch: 'full' as const, }, { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts index 9b1846dc8..29f5615f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts @@ -4,11 +4,12 @@ */ import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink } from '@angular/router'; @Component({ selector: 'app-system-settings-page', - imports: [], + imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -23,25 +24,25 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; All systems operational
- + View Details

Doctor

Run diagnostic checks on the system.

- + Run Doctor

SLO Monitoring

View and configure Service Level Objectives.

- + View SLOs

Background Jobs

Monitor and manage background job processing.

- + View Jobs
@@ -75,7 +76,15 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; border-radius: var(--radius-full); } .health-indicator--ok { background: var(--color-status-success); } - .btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; } + .btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } `] }) diff --git a/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts index 264465751..d53f752f7 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts @@ -4,11 +4,12 @@ */ import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink } from '@angular/router'; @Component({ selector: 'app-usage-settings-page', - imports: [], + imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -52,7 +53,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';

Quota Configuration

Configure limits and throttle settings for your tenant.

- + Configure Quotas
`, @@ -93,7 +94,15 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; } .settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); } .settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); } - .btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; } + .btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } `] }) diff --git a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts index 1fc008e6e..4ccd188b4 100644 --- a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts +++ b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter, type Route } from '@angular/router'; import { routes } from '../app.routes'; +import { SETTINGS_ROUTES } from '../features/settings/settings.routes'; import { OPERATIONS_ROUTES } from './operations.routes'; import { RELEASES_ROUTES } from './releases.routes'; import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from './legacy-redirects.routes'; @@ -122,4 +123,25 @@ describe('Route surface ownership', () => { expect(releaseOrchestratorRoute?.redirectTo).toBe('/releases/environments'); expect(releaseControlRoute?.redirectTo).toBe('/releases/environments'); }); + + it('redirects legacy settings branding into the canonical setup branding surface', () => { + const settingsRoot = SETTINGS_ROUTES.find((route) => route.path === ''); + const brandingRoute = findRouteByPath(settingsRoot?.children ?? [], 'branding'); + const redirect = brandingRoute?.redirectTo; + + if (typeof redirect !== 'function') { + throw new Error('settings branding alias must expose a redirect function.'); + } + + expect( + invokeRedirect(redirect, { + params: {}, + queryParams: { + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + }, + }), + ).toBe('/setup/tenant-branding?tenant=demo-prod®ions=us-east&environments=stage'); + }); }); diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 1fc9e46a5..57ef34c29 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -5,12 +5,14 @@ "src/test-setup.ts", "src/app/app.config-paths.spec.ts", "src/app/types/monaco-workers.d.ts", + "src/app/core/branding/branding.service.spec.ts", "src/app/core/api/first-signal.client.spec.ts", "src/app/core/api/vulnerability-http.client.spec.ts", "src/app/core/api/watchlist.client.spec.ts", "src/app/core/auth/tenant-activation.service.spec.ts", "src/app/core/console/console-status.service.spec.ts", "src/app/features/change-trace/change-trace-viewer.component.spec.ts", + "src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts", "src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts", "src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts", "src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",