Repair setup admin branding and action routes

This commit is contained in:
master
2026-03-11 17:05:49 +02:00
parent dc98d5a758
commit 8cf132798d
18 changed files with 719 additions and 188 deletions

View 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&regions=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);
});

View File

@@ -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';

View File

@@ -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'));
}
}

View File

@@ -570,7 +570,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'branding',
label: 'Branding',
route: '/console/admin/branding',
route: '/setup/tenant-branding',
icon: 'palette',
},
{

View File

@@ -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', () => {

View File

@@ -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;
}
}

View File

@@ -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);
}
});
}

View File

@@ -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 {}

View File

@@ -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,
},
{

View File

@@ -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); }
`]
})

View File

@@ -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); }
`]
})

View File

@@ -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&regions=us-east&environments=stage');
});
});

View File

@@ -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",