Repair setup admin branding and action routes
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
@@ -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 | - |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
|
||||
@@ -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 |
|
||||
|
||||
231
src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs
Normal file
231
src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/** Shape returned by the Authority /console/branding endpoint. */
|
||||
interface AuthorityBrandingDto {
|
||||
tenantId: string;
|
||||
@@ -25,11 +48,26 @@ interface AuthorityBrandingDto {
|
||||
themeTokens: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AuthorityAdminBrandingEnvelopeDto {
|
||||
branding: AuthorityBrandingDto;
|
||||
metadata?: BrandingMetadata;
|
||||
}
|
||||
|
||||
interface AuthorityUpdateBrandingRequestDto {
|
||||
displayName?: string;
|
||||
logoUri?: string;
|
||||
faviconUri?: string;
|
||||
themeTokens?: Record<string, string>;
|
||||
}
|
||||
|
||||
@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<BrandingConfiguration | null>(null);
|
||||
@@ -45,29 +83,81 @@ export class BrandingService {
|
||||
/**
|
||||
* Fetch branding configuration from the Authority API
|
||||
*/
|
||||
fetchBranding(tenantId: string = 'default'): Observable<BrandingResponse> {
|
||||
fetchBranding(
|
||||
tenantId?: string | null,
|
||||
options: { fallbackToDefault?: boolean } = {}
|
||||
): Observable<BrandingResponse> {
|
||||
const resolvedTenantId = this.getActiveTenantId(tenantId);
|
||||
|
||||
return this.http.get<AuthorityBrandingDto>('/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<AdminBrandingResponse> {
|
||||
const resolvedTenantId = this.getActiveTenantId(tenantId);
|
||||
|
||||
return this.http.get<AuthorityAdminBrandingEnvelopeDto>('/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<BrandingResponse> {
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'branding',
|
||||
label: 'Branding',
|
||||
route: '/console/admin/branding',
|
||||
route: '/setup/tenant-branding',
|
||||
icon: 'palette',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('NotificationRuleListComponent', () => {
|
||||
let component: NotificationRuleListComponent;
|
||||
let fixture: ComponentFixture<NotificationRuleListComponent>;
|
||||
let mockApi: jasmine.SpyObj<any>;
|
||||
let mockRouter: jasmine.SpyObj<Router>;
|
||||
let mockRouter: { navigate: jasmine.Spy };
|
||||
let mockParentRoute: Record<string, unknown>;
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="loadCurrentBranding()"
|
||||
[disabled]="isLoading">
|
||||
[disabled]="isLoading()">
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="applyBranding()"
|
||||
[disabled]="!canWrite || isSaving || !hasChanges">
|
||||
{{ isSaving ? 'Applying...' : 'Apply Changes' }}
|
||||
[disabled]="!canWrite || isSaving() || !hasChanges()">
|
||||
{{ isSaving() ? 'Applying...' : 'Apply Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
|
||||
@if (!canWrite) {
|
||||
<div class="alert alert-info">
|
||||
Branding is read-only for this session. Changes require branding write permission.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (success) {
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
@if (error()) {
|
||||
<div class="alert alert-error">{{ error() }}</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
@if (success()) {
|
||||
<div class="alert alert-success">{{ success() }}</div>
|
||||
}
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading">Loading branding configuration...</div>
|
||||
} @else {
|
||||
<div class="branding-sections">
|
||||
@@ -58,6 +63,7 @@ interface ThemeToken {
|
||||
type="text"
|
||||
[(ngModel)]="formData.title"
|
||||
(ngModelChange)="markAsChanged()"
|
||||
[readonly]="!canWrite"
|
||||
placeholder="Stella Ops Dashboard"
|
||||
maxlength="100">
|
||||
<small class="form-hint">Displayed in browser tab and header</small>
|
||||
@@ -76,6 +82,7 @@ interface ThemeToken {
|
||||
<img [src]="formData.logoUrl" alt="Logo preview" class="logo-preview">
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
[disabled]="!canWrite"
|
||||
(click)="removeLogo()"
|
||||
type="button">
|
||||
Remove
|
||||
@@ -86,11 +93,13 @@ interface ThemeToken {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
[disabled]="!canWrite"
|
||||
(change)="onLogoSelected($event)"
|
||||
#logoInput>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="logoInput.click()"
|
||||
[disabled]="!canWrite"
|
||||
type="button">
|
||||
Upload Logo
|
||||
</button>
|
||||
@@ -108,6 +117,7 @@ interface ThemeToken {
|
||||
<img [src]="formData.faviconUrl" alt="Favicon preview" class="favicon-preview">
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
[disabled]="!canWrite"
|
||||
(click)="removeFavicon()"
|
||||
type="button">
|
||||
Remove
|
||||
@@ -118,11 +128,13 @@ interface ThemeToken {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/x-icon,image/png"
|
||||
[disabled]="!canWrite"
|
||||
(change)="onFaviconSelected($event)"
|
||||
#faviconInput>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="faviconInput.click()"
|
||||
[disabled]="!canWrite"
|
||||
type="button">
|
||||
Upload Favicon
|
||||
</button>
|
||||
@@ -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">
|
||||
}
|
||||
</div>
|
||||
@@ -177,17 +191,19 @@ interface ThemeToken {
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newToken.key"
|
||||
[readonly]="!canWrite"
|
||||
placeholder="--theme-custom-color"
|
||||
class="token-key-input">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newToken.value"
|
||||
[readonly]="!canWrite"
|
||||
placeholder="var(--color-text-heading)"
|
||||
class="token-value-input">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="addCustomToken()"
|
||||
[disabled]="!newToken.key || !newToken.value">
|
||||
[disabled]="!canWrite || !newToken.key || !newToken.value">
|
||||
Add Token
|
||||
</button>
|
||||
</div>
|
||||
@@ -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<string | null>(null);
|
||||
readonly success = signal<string | null>(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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div class="branding-settings">
|
||||
<h1 class="page-title">Tenant & Branding</h1>
|
||||
<p class="page-subtitle">Customize appearance and branding for your tenant</p>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section class="settings-section">
|
||||
<h2>Logo</h2>
|
||||
<p>Upload your organization's logo.</p>
|
||||
<div class="logo-preview">
|
||||
<span class="logo-placeholder">🏢</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn--secondary" (click)="openEditor()">Upload Logo</button>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Title & Name</h2>
|
||||
<p>Customize the application title.</p>
|
||||
<div class="form-group">
|
||||
<label>Application Title</label>
|
||||
<input type="text" value="Stella Ops" class="form-input" />
|
||||
</div>
|
||||
<button type="button" class="btn btn--primary" (click)="openEditor()">Save</button>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Theme Tokens</h2>
|
||||
<p>Customize colors and theme variables.</p>
|
||||
<div class="color-preview">
|
||||
<div class="color-swatch" style="background: var(--color-status-info)"></div>
|
||||
<span>Primary Color</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn--secondary" (click)="openEditor()">Edit Theme</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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: `<app-branding-editor />`,
|
||||
})
|
||||
export class BrandingSettingsPageComponent {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
openEditor(): void {
|
||||
void this.router.navigate(['/console-admin/branding']);
|
||||
}
|
||||
}
|
||||
export class BrandingSettingsPageComponent {}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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: `
|
||||
<div class="system-settings">
|
||||
@@ -23,25 +24,25 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
<span class="health-indicator health-indicator--ok"></span>
|
||||
<span>All systems operational</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn--secondary">View Details</button>
|
||||
<a class="btn btn--secondary" routerLink="/ops/operations/system-health">View Details</a>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Doctor</h2>
|
||||
<p>Run diagnostic checks on the system.</p>
|
||||
<button type="button" class="btn btn--secondary">Run Doctor</button>
|
||||
<a class="btn btn--secondary" routerLink="/ops/operations/doctor">Run Doctor</a>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>SLO Monitoring</h2>
|
||||
<p>View and configure Service Level Objectives.</p>
|
||||
<button type="button" class="btn btn--secondary">View SLOs</button>
|
||||
<a class="btn btn--secondary" routerLink="/ops/operations/health-slo">View SLOs</a>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Background Jobs</h2>
|
||||
<p>Monitor and manage background job processing.</p>
|
||||
<button type="button" class="btn btn--secondary">View Jobs</button>
|
||||
<a class="btn btn--secondary" routerLink="/ops/operations/jobs-queues">View Jobs</a>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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); }
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -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: `
|
||||
<div class="usage-settings">
|
||||
@@ -52,7 +53,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
<section class="settings-section">
|
||||
<h2>Quota Configuration</h2>
|
||||
<p>Configure limits and throttle settings for your tenant.</p>
|
||||
<button type="button" class="btn btn--secondary">Configure Quotas</button>
|
||||
<a class="btn btn--secondary" routerLink="/ops/operations/quotas">Configure Quotas</a>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
@@ -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); }
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user