Web UI: feature updates across all modules

Broad UI improvements spanning auth, branding, notifications, agents, analytics,
approvals, audit-log, bundles, configuration, console-admin, dashboard,
deployments, doctor, environments, evidence, feed-mirror, graph, integration-hub,
issuer-trust, lineage, notify, offline-kit, policy, promotions, quota, registry,
release-orchestrator, releases, sbom, scans, secret-detection, security, settings,
setup-wizard, system-health, topology, triage, trust-admin, unknowns, vex-hub,
vulnerabilities, and watchlist features.

Adds new shared components (page-action-outlet, stella-action-card, stella-form-field),
scripts feature module, audit-trust component, e2e test helpers, and release page
e2e specs. Updates auth session model, branding service, color tokens, form styles,
and i18n translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-27 12:28:48 +02:00
parent f767489e26
commit 95357ffbb9
199 changed files with 6315 additions and 2222 deletions

View File

@@ -112,6 +112,61 @@ Do NOT create custom `.stat-card`, `.summary-card`, `.kpi-card`, or `.posture-ca
- If `route` is set: card is clickable with hover lift + arrow - If `route` is set: card is clickable with hover lift + arrow
- If no `route`: static display, no hover effect - If no `route`: static display, no hover effect
## Horizontal Scroll Card Lane Pattern (Reusable)
A reusable pattern for displaying actionable items as horizontally scrollable cards with
gradient fades and scroll arrows. Used in the dashboard (environment cards, pending actions)
and the approvals inbox (approval cards with inline actions).
### Architecture
Three layers:
1. **Wrapper** (`*-lane-wrapper`) — relative-positioned container with `::before`/`::after` gradient pseudo-elements
2. **Scroll container** (`*-lane`) — flex row with `overflow-x: auto`, hidden scrollbar, `scroll-behavior: smooth`
3. **Cards** — fixed-width (`280px`) flex items with `flex-shrink: 0`
### Scroll arrow signals (TypeScript)
```typescript
@ViewChild('myScroll') myScrollRef?: ElementRef<HTMLDivElement>;
readonly showLeftArrow = signal(false);
readonly showRightArrow = signal(false);
onScroll(): void { this.updateArrows(); }
scrollCards(direction: 'left' | 'right'): void {
this.myScrollRef?.nativeElement?.scrollBy({ left: direction === 'left' ? -300 : 300, behavior: 'smooth' });
}
private updateArrows(): void {
const el = this.myScrollRef?.nativeElement;
if (!el) { this.showLeftArrow.set(false); this.showRightArrow.set(false); return; }
this.showLeftArrow.set(el.scrollLeft > 1);
this.showRightArrow.set(el.scrollWidth - el.scrollLeft - el.clientWidth > 1);
}
```
### Gradient fades (CSS)
```scss
.my-wrapper.can-scroll-left::before {
content: ''; position: absolute; top: 0; left: 0; bottom: 0; width: 56px;
background: linear-gradient(to right, var(--color-surface-primary) 0%, transparent 100%);
pointer-events: none; z-index: 1;
}
.my-wrapper.can-scroll-right::after { /* mirror for right side */ }
```
### Confirmation dialogs for card actions
- **Production approve**: `<app-confirm-dialog>` with `variant="warning"` and `ViewChild` ref, call `.open()` programmatically
- **Reject with reason**: Custom inline dialog overlay with `<textarea [(ngModel)]>` for optional reason
- **Detail popup**: `<app-modal size="lg">` — shows summary immediately, loads full detail via API on open
### Reference implementations
- Dashboard environment cards: `features/dashboard-v3/dashboard-v3.component.ts` (`.env-grid-wrapper`)
- Approvals inbox cards: `features/approvals/approvals-inbox.component.ts` (`.cards-lane-wrapper`, `.apc` cards)
- Stella Action Card List: `shared/components/stella-action-card/` (simpler variant without arrows)
## Tab Navigation Convention (MANDATORY) ## Tab Navigation Convention (MANDATORY)
All page-level tab navigation **must** use `<stella-page-tabs>`. All page-level tab navigation **must** use `<stella-page-tabs>`.
@@ -271,6 +326,24 @@ Three filter component types:
- Use `app-filter-bar` when search + multiple dropdowns + active chips are needed - Use `app-filter-bar` when search + multiple dropdowns + active chips are needed
- Compact inline chips: 28px height, no border default, dropdown on click - Compact inline chips: 28px height, no border default, dropdown on click
**Filter container overflow (CRITICAL):**
Filter containers that hold `stella-filter-chip` or `stella-filter-multi` MUST use
`overflow: visible` so dropdown panels are not clipped. Do NOT use `overflow-x: auto`
or `overflow: hidden` on filter row containers — this clips the absolute-positioned
dropdown panels (`z-index: 200`) below the fold.
```css
/* CORRECT — dropdowns escape the container */
.filters { display: flex; flex-wrap: wrap; overflow: visible; gap: 0.5rem; }
/* WRONG — clips dropdown panels */
.filters { display: flex; flex-wrap: nowrap; overflow-x: auto; }
```
The topbar header row uses `overflow: visible` for this reason — all page-level
filter rows must follow the same pattern.
## Filter Convention (MANDATORY) ## Filter Convention (MANDATORY)
Three filter component types: Three filter component types:

View File

@@ -0,0 +1,87 @@
/**
* Seed + cleanup helpers for release e2e tests.
*
* Inserts deterministic rows into:
* release.control_bundles
* release.control_bundle_versions
* release.control_bundle_materialization_runs
*
* Uses docker exec to run psql against the compose postgres container.
*/
import { execSync } from 'node:child_process';
const TENANT_ID = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016';
// Deterministic UUIDs for seeded entities
const BUNDLE_API_GW = 'e2e00001-0001-4000-a000-000000000001';
const BUNDLE_AUTH_SVC = 'e2e00001-0002-4000-a000-000000000002';
const BUNDLE_HOTFIX = 'e2e00001-0003-4000-a000-000000000003';
const VERSION_API_GW = 'e2e00002-0001-4000-a000-000000000001';
const VERSION_AUTH_SVC = 'e2e00002-0002-4000-a000-000000000002';
const VERSION_HOTFIX = 'e2e00002-0003-4000-a000-000000000003';
const RUN_01 = 'e2e00003-0001-4000-a000-000000000001';
const RUN_02 = 'e2e00003-0002-4000-a000-000000000002';
const RUN_03 = 'e2e00003-0003-4000-a000-000000000003';
const RUN_04 = 'e2e00003-0004-4000-a000-000000000004';
const RUN_05 = 'e2e00003-0005-4000-a000-000000000005';
function psql(sql: string): void {
const escaped = sql.replace(/'/g, "'\\''");
const cmd = `docker exec stellaops-postgres psql -U stellaops -d stellaops_platform -c '${escaped}'`;
execSync(cmd, { stdio: 'pipe', timeout: 15_000 });
}
/**
* Insert seed data for release e2e tests.
* Safe to call multiple times (uses ON CONFLICT DO NOTHING).
*/
export function seedReleaseData(): void {
// 1. Bundles
psql(`
INSERT INTO release.control_bundles (id, tenant_id, slug, name, description, created_by)
VALUES
('${BUNDLE_API_GW}', '${TENANT_ID}', 'e2e-api-gateway', 'e2e-api-gateway', 'E2E test bundle: API Gateway', 'e2e-seed'),
('${BUNDLE_AUTH_SVC}', '${TENANT_ID}', 'e2e-auth-service', 'e2e-auth-service', 'E2E test bundle: Auth Service', 'e2e-seed'),
('${BUNDLE_HOTFIX}', '${TENANT_ID}', 'e2e-hotfix-patch', 'e2e-hotfix-patch', 'E2E test bundle: Hotfix Patch', 'e2e-seed')
ON CONFLICT DO NOTHING;
`);
// 2. Versions (one per bundle, published)
psql(`
INSERT INTO release.control_bundle_versions
(id, tenant_id, bundle_id, version_number, digest, status, components_count, changelog, published_at, created_by)
VALUES
('${VERSION_API_GW}', '${TENANT_ID}', '${BUNDLE_API_GW}', 1, 'sha256:e2eaaa111', 'published', 4, 'Initial API Gateway release', NOW(), 'e2e-seed'),
('${VERSION_AUTH_SVC}', '${TENANT_ID}', '${BUNDLE_AUTH_SVC}', 1, 'sha256:e2ebbb222', 'published', 3, 'Initial Auth Service release', NOW(), 'e2e-seed'),
('${VERSION_HOTFIX}', '${TENANT_ID}', '${BUNDLE_HOTFIX}', 1, 'sha256:e2eccc333', 'published', 1, 'Emergency hotfix patch', NOW(), 'e2e-seed')
ON CONFLICT DO NOTHING;
`);
// 3. Materialization runs
psql(`
INSERT INTO release.control_bundle_materialization_runs
(run_id, tenant_id, bundle_id, bundle_version_id, status, target_environment, reason, requested_by)
VALUES
('${RUN_01}', '${TENANT_ID}', '${BUNDLE_API_GW}', '${VERSION_API_GW}', 'running', 'prod-us-east', 'Pending approval: prod deploy', 'e2e-admin'),
('${RUN_02}', '${TENANT_ID}', '${BUNDLE_AUTH_SVC}', '${VERSION_AUTH_SVC}', 'running', 'prod-eu-west', 'Pending approval: EU deployment', 'e2e-admin'),
('${RUN_03}', '${TENANT_ID}', '${BUNDLE_HOTFIX}', '${VERSION_HOTFIX}', 'queued', 'staging', 'Pending: staging validation', 'e2e-admin'),
('${RUN_04}', '${TENANT_ID}', '${BUNDLE_API_GW}', '${VERSION_API_GW}', 'succeeded', 'staging', 'Approved: staging deployment passed', 'e2e-admin'),
('${RUN_05}', '${TENANT_ID}', '${BUNDLE_AUTH_SVC}', '${VERSION_AUTH_SVC}', 'failed', 'prod-us-east', 'Rejected: gate policy violation', 'e2e-admin')
ON CONFLICT DO NOTHING;
`);
}
/**
* Remove all e2e seed data (rows with e2e- prefix bundles).
* Cascades via FK delete rules on versions + runs.
*/
export function cleanupReleaseData(): void {
psql(`
DELETE FROM release.control_bundles
WHERE tenant_id = '${TENANT_ID}'
AND slug LIKE 'e2e-%';
`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,12 @@
<div class="app-shell"> <div class="app-shell">
<!-- Session expired banner -->
@if (authStatus() === 'expired') {
<div class="session-expired-banner" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>Your session has expired.</span>
<button type="button" class="session-expired-banner__action" (click)="onSignIn()">Sign In Again</button>
</div>
}
<!-- Legacy URL Banner (ROUTE-003) --> <!-- Legacy URL Banner (ROUTE-003) -->
@if (legacyRouteInfo(); as legacy) { @if (legacyRouteInfo(); as legacy) {
<app-legacy-url-banner <app-legacy-url-banner

View File

@@ -17,6 +17,41 @@
min-height: 100vh; min-height: 100vh;
} }
.session-expired-banner {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-6);
background-color: var(--color-warning-surface, #fef3c7);
color: var(--color-warning-text, #92400e);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
&__action {
appearance: none;
border: 1px solid currentColor;
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
color: inherit;
background: transparent;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
background-color: rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
}
.app-header { .app-header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -99,8 +99,10 @@ export class AppComponent {
.subscribe(() => removeSplash()); .subscribe(() => removeSplash());
// Defensive fallback: if first navigation never settles (e.g. test/misconfigured // Defensive fallback: if first navigation never settles (e.g. test/misconfigured
// backend), remove splash so the shell remains interactive. // backend), remove splash so the shell remains interactive. Use 10s to match
setTimeout(() => removeSplash(), 5000); // the silent-refresh iframe timeout so the splash stays visible while OIDC
// session restoration is in-flight.
setTimeout(() => removeSplash(), 10_000);
// Initialize branding on app start // Initialize branding on app start
this.brandingService.fetchBranding().subscribe(); this.brandingService.fetchBranding().subscribe();
@@ -151,6 +153,7 @@ export class AppComponent {
} }
readonly isAuthenticated = this.sessionStore.isAuthenticated; readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly authStatus = this.sessionStore.status;
readonly activeTenant = this.consoleStore.selectedTenantId; readonly activeTenant = this.consoleStore.selectedTenantId;
readonly freshAuthSummary = computed(() => { readonly freshAuthSummary = computed(() => {

View File

@@ -0,0 +1,567 @@
/**
* Scripts API Client
* Follows the approval.client.ts pattern: InjectionToken + interface + HttpClient + MockClient
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, delay, of } from 'rxjs';
import type {
Script,
ScriptVersion,
ScriptVersionDetail,
ScriptVariableDeclaration,
ScriptDiagnostic,
CreateScriptRequest,
UpdateScriptRequest,
ScriptSearchCriteria,
ScriptValidationResult,
CheckCompatibilityRequest,
CompatibilityResult,
CompatibilityIssue,
} from './scripts.models';
export const SCRIPTS_API = new InjectionToken<ScriptsApi>('SCRIPTS_API');
export interface ScriptsApi {
listScripts(criteria?: ScriptSearchCriteria): Observable<Script[]>;
getScript(id: string): Observable<Script>;
createScript(request: CreateScriptRequest): Observable<Script>;
updateScript(id: string, request: UpdateScriptRequest): Observable<Script>;
deleteScript(id: string): Observable<void>;
validateScript(language: string, content: string, declaredVariables?: ScriptVariableDeclaration[]): Observable<ScriptValidationResult>;
getVersions(scriptId: string): Observable<ScriptVersion[]>;
getVersionContent(scriptId: string, version: number): Observable<ScriptVersionDetail>;
checkCompatibility(scriptId: string, request: CheckCompatibilityRequest): Observable<CompatibilityResult>;
}
// HTTP Client Implementation
@Injectable()
export class ScriptsHttpClient implements ScriptsApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v2/scripts';
listScripts(criteria?: ScriptSearchCriteria): Observable<Script[]> {
let params = new HttpParams();
if (criteria?.search) params = params.set('search', criteria.search);
if (criteria?.language) params = params.set('language', criteria.language);
if (criteria?.visibility) params = params.set('visibility', criteria.visibility);
if (criteria?.limit != null) params = params.set('limit', criteria.limit.toString());
if (criteria?.offset != null) params = params.set('offset', criteria.offset.toString());
return this.http.get<Script[]>(this.baseUrl, { params });
}
getScript(id: string): Observable<Script> {
return this.http.get<Script>(`${this.baseUrl}/${id}`);
}
createScript(request: CreateScriptRequest): Observable<Script> {
return this.http.post<Script>(this.baseUrl, request);
}
updateScript(id: string, request: UpdateScriptRequest): Observable<Script> {
return this.http.patch<Script>(`${this.baseUrl}/${id}`, request);
}
deleteScript(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
validateScript(language: string, content: string, declaredVariables?: ScriptVariableDeclaration[]): Observable<ScriptValidationResult> {
return this.http.post<ScriptValidationResult>(`${this.baseUrl}/validate`, {
language,
content,
declaredVariables,
});
}
getVersions(scriptId: string): Observable<ScriptVersion[]> {
return this.http.get<ScriptVersion[]>(`${this.baseUrl}/${scriptId}/versions`);
}
getVersionContent(scriptId: string, version: number): Observable<ScriptVersionDetail> {
return this.http.get<ScriptVersionDetail>(`${this.baseUrl}/${scriptId}/versions/${version}/content`);
}
checkCompatibility(scriptId: string, request: CheckCompatibilityRequest): Observable<CompatibilityResult> {
return this.http.post<CompatibilityResult>(`${this.baseUrl}/${scriptId}/check-compatibility`, request);
}
}
// Mock Client Implementation
@Injectable()
export class MockScriptsClient implements ScriptsApi {
private scripts: Script[] = [
{
id: 'scr-001',
name: 'Pre-deploy Health Check',
description: 'Validates service health endpoints before deployment proceeds. Checks HTTP status, response time, and dependency connectivity.',
language: 'bash',
content: `#!/bin/bash
# Pre-deploy health check script
set -euo pipefail
SERVICE_URL="\${SERVICE_URL:-http://localhost:8080}"
TIMEOUT=\${TIMEOUT:-10}
echo "Checking health at $SERVICE_URL/health..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$SERVICE_URL/health")
if [ "$HTTP_CODE" -eq 200 ]; then
echo "Health check passed (HTTP $HTTP_CODE)"
exit 0
else
echo "Health check failed (HTTP $HTTP_CODE)"
exit 1
fi`,
version: 3,
visibility: 'organization',
ownerId: 'user-001',
tags: ['health-check', 'pre-deploy', 'infrastructure'],
contentHash: 'sha256:a1b2c3d4e5f6',
variables: [
{ name: 'SERVICE_URL', description: 'Target service URL for health check', isRequired: true, defaultValue: 'http://localhost:8080', isSecret: false },
{ name: 'TIMEOUT', description: 'Request timeout in seconds', isRequired: false, defaultValue: '10', isSecret: false },
],
isSample: true,
sampleCategory: 'deployment',
createdAt: '2026-01-10T08:00:00Z',
updatedAt: '2026-03-15T14:30:00Z',
},
{
id: 'scr-002',
name: 'Database Migration Validator',
description: 'Validates pending database migrations against schema constraints and checks for backward compatibility.',
language: 'python',
content: `"""Database migration validator."""
import sys
import hashlib
def validate_migration(migration_path: str) -> bool:
"""Validate a single migration file."""
with open(migration_path, 'r') as f:
content = f.read()
# Check for destructive operations
destructive_ops = ['DROP TABLE', 'DROP COLUMN', 'TRUNCATE']
for op in destructive_ops:
if op in content.upper():
print(f"WARNING: Destructive operation found: {op}")
return False
# Verify checksum
checksum = hashlib.sha256(content.encode()).hexdigest()
print(f"Migration checksum: {checksum[:16]}")
return True
if __name__ == '__main__':
path = sys.argv[1] if len(sys.argv) > 1 else 'migrations/'
result = validate_migration(path)
sys.exit(0 if result else 1)`,
version: 2,
visibility: 'team',
ownerId: 'user-002',
teamId: 'team-platform',
tags: ['database', 'migration', 'validation'],
variables: [
{ name: 'DB_CONNECTION', description: 'Database connection string', isRequired: true, isSecret: true },
{ name: 'MIGRATION_DIR', description: 'Path to migrations directory', isRequired: false, defaultValue: 'migrations/', isSecret: false },
],
contentHash: 'sha256:b2c3d4e5f6a7',
isSample: true,
sampleCategory: 'database',
createdAt: '2026-02-01T10:00:00Z',
updatedAt: '2026-03-10T09:15:00Z',
},
{
id: 'scr-003',
name: 'Release Notes Generator',
description: 'Generates release notes from git commit history between two tags, grouped by conventional commit type.',
language: 'typescript',
content: `/**
* Release notes generator.
* Parses conventional commits and groups them by type.
*/
interface CommitEntry {
hash: string;
type: string;
scope?: string;
message: string;
}
function parseConventionalCommit(line: string): CommitEntry | null {
const match = line.match(/^(\\w+)(\\((\\w+)\\))?:\\s+(.+)$/);
if (!match) return null;
return {
hash: '',
type: match[1],
scope: match[3],
message: match[4],
};
}
function generateNotes(commits: CommitEntry[]): string {
const groups: Record<string, CommitEntry[]> = {};
for (const commit of commits) {
const key = commit.type;
if (!groups[key]) groups[key] = [];
groups[key].push(commit);
}
let output = '# Release Notes\\n\\n';
for (const [type, entries] of Object.entries(groups)) {
output += \`## \${type}\\n\`;
for (const entry of entries) {
const scope = entry.scope ? \`**\${entry.scope}**: \` : '';
output += \`- \${scope}\${entry.message}\\n\`;
}
output += '\\n';
}
return output;
}
console.log('Release notes generator ready.');`,
version: 1,
visibility: 'public',
ownerId: 'user-001',
tags: ['release-notes', 'git', 'automation'],
variables: [],
contentHash: 'sha256:c3d4e5f6a7b8',
isSample: true,
sampleCategory: 'release',
createdAt: '2026-03-01T12:00:00Z',
updatedAt: '2026-03-01T12:00:00Z',
},
{
id: 'scr-004',
name: 'Container Image Scan Wrapper',
description: 'Wraps Trivy container scanning with custom policy checks and outputs results in SARIF format.',
language: 'csharp',
content: `// Container image scan wrapper
// Invokes Trivy and applies custom policy filters
using System;
using System.Diagnostics;
using System.Text.Json;
var imageRef = Environment.GetEnvironmentVariable("IMAGE_REF")
?? throw new InvalidOperationException("IMAGE_REF not set");
var severityThreshold = Environment.GetEnvironmentVariable("SEVERITY_THRESHOLD") ?? "HIGH";
Console.WriteLine($"Scanning {imageRef} with threshold {severityThreshold}...");
var psi = new ProcessStartInfo("trivy", $"image --format json --severity {severityThreshold},CRITICAL {imageRef}")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = Process.Start(psi)!;
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
var stderr = await process.StandardError.ReadToEndAsync();
Console.Error.WriteLine($"Trivy scan failed: {stderr}");
Environment.Exit(1);
}
var doc = JsonDocument.Parse(output);
var vulnCount = 0;
foreach (var result in doc.RootElement.GetProperty("Results").EnumerateArray())
{
if (result.TryGetProperty("Vulnerabilities", out var vulns))
vulnCount += vulns.GetArrayLength();
}
Console.WriteLine($"Found {vulnCount} vulnerabilities at or above {severityThreshold}");
Environment.Exit(vulnCount > 0 ? 1 : 0);`,
version: 5,
visibility: 'organization',
ownerId: 'user-003',
tags: ['security', 'scanning', 'trivy', 'container'],
variables: [
{ name: 'IMAGE_REF', description: 'Container image reference to scan', isRequired: true, isSecret: false },
{ name: 'SEVERITY_THRESHOLD', description: 'Minimum severity to report', isRequired: false, defaultValue: 'HIGH', isSecret: false },
],
contentHash: 'sha256:d4e5f6a7b8c9',
isSample: false,
createdAt: '2026-01-20T16:00:00Z',
updatedAt: '2026-03-20T11:45:00Z',
},
];
private versionContents = new Map<string, Map<number, string>>();
private static readonly STELLA_SYSTEM_VARS = [
'STELLA_RELEASE_ID', 'STELLA_RELEASE_NAME', 'STELLA_RELEASE_VERSION', 'STELLA_RELEASE_TYPE',
'STELLA_COMPONENT_NAME', 'STELLA_TARGET_ENVIRONMENT', 'STELLA_TARGET_REGION',
'STELLA_DEPLOYMENT_STRATEGY', 'STELLA_TENANT_ID', 'STELLA_ACTOR_ID', 'STELLA_TIMESTAMP',
'STELLA_TIMEOUT_SECONDS', 'STELLA_WORKING_DIR', 'STELLA_AGENT_TYPE', 'STELLA_COMPOSE_LOCK',
'STELLA_VERSION_STICKER',
];
constructor() {
for (const s of this.scripts) {
const map = new Map<number, string>();
map.set(s.version, s.content);
if (s.version > 1) {
map.set(s.version - 1, `// Previous version ${s.version - 1}\n${s.content.substring(0, Math.min(s.content.length, 200))}\n// ... (truncated)`);
}
this.versionContents.set(s.id, map);
}
}
listScripts(criteria?: ScriptSearchCriteria): Observable<Script[]> {
let result = [...this.scripts];
if (criteria?.search) {
const term = criteria.search.toLowerCase();
result = result.filter(
(s) =>
s.name.toLowerCase().includes(term) ||
s.description.toLowerCase().includes(term) ||
s.tags.some((t) => t.toLowerCase().includes(term))
);
}
if (criteria?.language) {
result = result.filter((s) => s.language === criteria.language);
}
if (criteria?.visibility) {
result = result.filter((s) => s.visibility === criteria.visibility);
}
const offset = criteria?.offset ?? 0;
const limit = criteria?.limit ?? 20;
result = result.slice(offset, offset + limit);
return of(result).pipe(delay(200));
}
getScript(id: string): Observable<Script> {
const script = this.scripts.find((s) => s.id === id);
if (!script) {
throw new Error(`Script not found: ${id}`);
}
return of({ ...script }).pipe(delay(100));
}
createScript(request: CreateScriptRequest): Observable<Script> {
const now = new Date().toISOString();
const newScript: Script = {
id: 'scr-' + Math.random().toString(36).substring(2, 9),
name: request.name,
description: request.description,
language: request.language,
content: request.content,
version: 1,
visibility: request.visibility,
ownerId: 'current-user',
tags: request.tags ?? [],
variables: request.variables ?? [],
contentHash: 'sha256:' + Math.random().toString(36).substring(2, 14),
isSample: false,
createdAt: now,
updatedAt: now,
};
this.scripts.push(newScript);
const map = new Map<number, string>();
map.set(1, request.content);
this.versionContents.set(newScript.id, map);
return of({ ...newScript }).pipe(delay(300));
}
updateScript(id: string, request: UpdateScriptRequest): Observable<Script> {
const index = this.scripts.findIndex((s) => s.id === id);
if (index === -1) throw new Error(`Script not found: ${id}`);
const script = this.scripts[index];
const updated: Script = {
...script,
name: request.name ?? script.name,
description: request.description ?? script.description,
content: request.content ?? script.content,
visibility: request.visibility ?? script.visibility,
tags: request.tags ?? script.tags,
variables: request.variables !== undefined ? request.variables : script.variables,
version: request.content ? script.version + 1 : script.version,
contentHash: request.content
? 'sha256:' + Math.random().toString(36).substring(2, 14)
: script.contentHash,
updatedAt: new Date().toISOString(),
};
this.scripts[index] = updated;
if (request.content) {
let map = this.versionContents.get(id);
if (!map) {
map = new Map<number, string>();
this.versionContents.set(id, map);
}
map.set(updated.version, request.content);
}
return of({ ...updated }).pipe(delay(200));
}
deleteScript(id: string): Observable<void> {
const index = this.scripts.findIndex((s) => s.id === id);
if (index === -1) throw new Error(`Script not found: ${id}`);
this.scripts.splice(index, 1);
return of(undefined).pipe(delay(200));
}
validateScript(language: string, content: string, declaredVariables?: ScriptVariableDeclaration[]): Observable<ScriptValidationResult> {
const errors: ScriptDiagnostic[] = [];
const warnings: ScriptDiagnostic[] = [];
if (!content.trim()) {
errors.push({
line: 1,
column: 1,
message: 'Script content cannot be empty',
severity: 'error',
category: 'syntax',
});
}
if (content.length > 100000) {
warnings.push({
line: 1,
column: 1,
message: 'Script exceeds recommended size limit (100KB)',
severity: 'warning',
category: 'performance',
});
}
// Variable reference scanning for bash
if (language === 'bash') {
const varRefRegex = /\$\{(\w+)\}/g;
const declaredNames = new Set((declaredVariables ?? []).map(v => v.name));
let match: RegExpExecArray | null;
const lines = content.split('\n');
while ((match = varRefRegex.exec(content)) !== null) {
const varName = match[1];
if (!MockScriptsClient.STELLA_SYSTEM_VARS.includes(varName) && !declaredNames.has(varName)) {
// Find line number for this match
let pos = 0;
let lineNum = 1;
for (const line of lines) {
if (pos + line.length >= match.index) {
break;
}
pos += line.length + 1; // +1 for newline
lineNum++;
}
warnings.push({
line: lineNum,
column: match.index - pos + 1,
message: `Variable '\${${varName}}' is not declared and not a known STELLA_* system variable`,
severity: 'warning',
category: 'variable',
});
}
}
// Safety check for set -euo pipefail
if (!content.includes('set -euo pipefail')) {
warnings.push({
line: 1,
column: 1,
message: "Missing 'set -euo pipefail' for safe execution",
severity: 'warning',
category: 'safety',
});
}
}
return of({
isValid: errors.length === 0,
errors,
warnings,
}).pipe(delay(150));
}
getVersions(scriptId: string): Observable<ScriptVersion[]> {
const script = this.scripts.find((s) => s.id === scriptId);
if (!script) throw new Error(`Script not found: ${scriptId}`);
const versions: ScriptVersion[] = [];
for (let v = script.version; v >= 1; v--) {
versions.push({
version: v,
contentHash: v === script.version ? script.contentHash : 'sha256:prev-' + v,
createdBy: script.ownerId,
createdAt: v === script.version
? script.updatedAt
: new Date(new Date(script.createdAt).getTime() + (v - 1) * 86400000).toISOString(),
changeNotes: v === 1 ? 'Initial version' : `Version ${v} update`,
});
}
return of(versions).pipe(delay(150));
}
getVersionContent(scriptId: string, version: number): Observable<ScriptVersionDetail> {
const script = this.scripts.find(s => s.id === scriptId);
if (!script) throw new Error(`Script not found: ${scriptId}`);
const map = this.versionContents.get(scriptId);
const content = map?.get(version) ?? `// Version ${version} content not available`;
return of({
version,
contentHash: 'sha256:ver-' + version,
createdBy: script.ownerId,
createdAt: new Date(new Date(script.createdAt).getTime() + (version - 1) * 86400000).toISOString(),
changeNotes: version === 1 ? 'Initial version' : `Version ${version} update`,
content,
}).pipe(delay(100));
}
checkCompatibility(scriptId: string, request: CheckCompatibilityRequest): Observable<CompatibilityResult> {
const script = this.scripts.find(s => s.id === scriptId);
if (!script) throw new Error(`Script not found: ${scriptId}`);
const issues: CompatibilityIssue[] = [];
// Language-target matrix
if (script.language === 'bash' && request.targetType === 'ecs_service') {
issues.push({ category: 'runtime', severity: 'warning', message: 'Bash scripts on ECS require ECS Exec with proper IAM permissions' });
}
if (script.language === 'powershell' && ['docker_host', 'compose_host'].includes(request.targetType)) {
issues.push({ category: 'runtime', severity: 'warning', message: 'PowerShell may not be available on Linux-based targets' });
}
// Variable resolution
const vars = script.variables ?? [];
const requiredVars = vars.filter(v => v.isRequired && !v.isSecret);
const metaKeys = request.targetMetadata ? Object.keys(request.targetMetadata) : [];
for (const v of requiredVars) {
if (!metaKeys.includes(v.name)) {
issues.push({ category: 'variable', severity: 'error', message: `Required variable '${v.name}' not provided in target metadata` });
}
}
// Secret availability
const secretVars = vars.filter(v => v.isSecret);
const availableSecrets = request.availableSecrets ?? [];
for (const v of secretVars) {
if (!availableSecrets.includes(v.name)) {
issues.push({ category: 'secret', severity: 'warning', message: `Secret '${v.name}' not available in target secrets store` });
}
}
// Runtime notes
if (request.targetType === 'ecs_service') {
issues.push({ category: 'info', severity: 'info', message: 'ECS Exec requires ssmmessages:* IAM permissions' });
}
if (request.targetType === 'nomad_job') {
issues.push({ category: 'info', severity: 'info', message: 'Script runs via Nomad exec in task allocation' });
}
const hasErrors = issues.some(i => i.severity === 'error');
return of({ isCompatible: !hasErrors, issues }).pipe(delay(200));
}
}

View File

@@ -0,0 +1,175 @@
/**
* Scripts Management Models
* TypeScript equivalents of StellaOps.ReleaseOrchestrator.Scripts.ScriptModels
*/
export type ScriptLanguage = 'csharp' | 'python' | 'bash' | 'powershell' | 'typescript' | 'go' | 'java';
export type ScriptVisibility = 'private' | 'team' | 'organization' | 'public';
export interface ScriptVariableDeclaration {
name: string;
description?: string;
isRequired: boolean;
defaultValue?: string;
isSecret: boolean;
}
export interface Script {
id: string;
name: string;
description: string;
language: ScriptLanguage;
content: string;
version: number;
visibility: ScriptVisibility;
ownerId: string;
teamId?: string;
tags: string[];
variables?: ScriptVariableDeclaration[];
contentHash: string;
isSample: boolean;
sampleCategory?: string;
createdAt: string;
updatedAt: string;
}
export interface ScriptVersion {
version: number;
contentHash: string;
createdBy: string;
createdAt: string;
changeNotes?: string;
}
export interface ScriptVersionDetail extends ScriptVersion {
content: string;
}
export interface CheckCompatibilityRequest {
targetType: string;
targetMetadata?: Record<string, string>;
availableSecrets?: string[];
}
export interface CompatibilityResult {
isCompatible: boolean;
issues: CompatibilityIssue[];
}
export interface CompatibilityIssue {
category: string;
severity: string;
message: string;
}
export interface CreateScriptRequest {
name: string;
description: string;
language: ScriptLanguage;
content: string;
visibility: ScriptVisibility;
tags?: string[];
variables?: ScriptVariableDeclaration[];
}
export interface UpdateScriptRequest {
name?: string;
description?: string;
content?: string;
visibility?: ScriptVisibility;
tags?: string[];
variables?: ScriptVariableDeclaration[];
changeNotes?: string;
}
export interface ScriptSearchCriteria {
search?: string;
language?: ScriptLanguage;
visibility?: ScriptVisibility;
limit?: number;
offset?: number;
}
export interface ScriptValidationResult {
isValid: boolean;
errors: ScriptDiagnostic[];
warnings: ScriptDiagnostic[];
}
export interface ScriptDiagnostic {
line: number;
column: number;
message: string;
severity: 'error' | 'warning' | 'info';
category?: string;
}
// Helper functions
export function getLanguageLabel(language: ScriptLanguage): string {
const labels: Record<ScriptLanguage, string> = {
csharp: 'C#',
python: 'Python',
bash: 'Bash',
powershell: 'PowerShell',
typescript: 'TypeScript',
go: 'Go',
java: 'Java',
};
return labels[language] || language;
}
export function getLanguageExtension(language: ScriptLanguage): string {
const extensions: Record<ScriptLanguage, string> = {
csharp: '.csx',
python: '.py',
bash: '.sh',
powershell: '.ps1',
typescript: '.ts',
go: '.go',
java: '.java',
};
return extensions[language] || '.txt';
}
export function getLanguageColor(language: ScriptLanguage): string {
const colors: Record<ScriptLanguage, string> = {
csharp: 'var(--color-status-info)',
python: 'var(--color-status-warning)',
bash: 'var(--color-status-success)',
powershell: '#6A1B9A',
typescript: 'var(--color-status-info)',
go: 'var(--color-status-info)',
java: 'var(--color-status-error)',
};
return colors[language] || 'var(--color-text-secondary)';
}
export function getVisibilityLabel(visibility: ScriptVisibility): string {
const labels: Record<ScriptVisibility, string> = {
private: 'Private',
team: 'Team',
organization: 'Organization',
public: 'Public',
};
return labels[visibility] || visibility;
}
export function getVisibilityColor(visibility: ScriptVisibility): string {
const colors: Record<ScriptVisibility, string> = {
private: 'var(--color-text-secondary)',
team: 'var(--color-status-info)',
organization: 'var(--color-status-warning)',
public: 'var(--color-status-success)',
};
return colors[visibility] || 'var(--color-text-secondary)';
}
export function getSeverityColor(severity: 'error' | 'warning' | 'info'): string {
const colors: Record<string, string> = {
error: 'var(--color-status-error)',
warning: 'var(--color-status-warning)',
info: 'var(--color-status-info)',
};
return colors[severity] || 'var(--color-text-secondary)';
}

View File

@@ -1,7 +1,7 @@
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, firstValueFrom, from, throwError } from 'rxjs'; import { Observable, firstValueFrom, from, throwError, timer } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators'; import { catchError, filter, switchMap, take } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service'; import { AppConfigService } from '../config/app-config.service';
import { DpopService } from './dpop/dpop.service'; import { DpopService } from './dpop/dpop.service';
@@ -14,6 +14,7 @@ const RETRY_HEADER = StellaOpsHeaders.DpopRetry;
export class AuthHttpInterceptor implements HttpInterceptor { export class AuthHttpInterceptor implements HttpInterceptor {
private excludedPrefixes: string[] = []; private excludedPrefixes: string[] = [];
private authorityResolved = false; private authorityResolved = false;
private logoutInFlight = false;
constructor( constructor(
private readonly auth: AuthorityAuthService, private readonly auth: AuthorityAuthService,
@@ -63,6 +64,22 @@ export class AuthHttpInterceptor implements HttpInterceptor {
error: HttpErrorResponse, error: HttpErrorResponse,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<unknown>> { ): Observable<HttpEvent<unknown>> {
// Network error (status 0) — connection closed, DNS failure, etc.
// Retry once after a short delay before giving up.
if (error.status === 0 && request.headers.get(RETRY_HEADER) !== '1') {
return timer(1000).pipe(
switchMap(() => {
const retried = request.clone({
headers: request.headers.set(RETRY_HEADER, '1'),
});
return next.handle(retried);
}),
catchError(() => throwError(() => error))
);
}
// Non-401 errors (including 403 Forbidden) propagate directly to subscribers.
// 403 means insufficient permissions — retrying with a refreshed token won't help.
if (error.status !== 401) { if (error.status !== 401) {
return throwError(() => error); return throwError(() => error);
} }
@@ -81,6 +98,17 @@ export class AuthHttpInterceptor implements HttpInterceptor {
); );
} }
/** Send a retried request and wait for the final HttpResponse (skip intermediate events). */
private handleRetry(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
filter((event) => event instanceof HttpResponse),
take(1)
);
}
private async retryWithNonce( private async retryWithNonce(
request: HttpRequest<unknown>, request: HttpRequest<unknown>,
nonce: string, nonce: string,
@@ -103,7 +131,39 @@ export class AuthHttpInterceptor implements HttpInterceptor {
headers: request.headers.set(RETRY_HEADER, '1'), headers: request.headers.set(RETRY_HEADER, '1'),
}); });
return firstValueFrom(next.handle(retried)); return firstValueFrom(this.handleRetry(retried, next));
}
private async retryAfterRefresh(
request: HttpRequest<unknown>,
next: HttpHandler
): Promise<HttpEvent<unknown>> {
await this.auth.refreshAccessToken();
const headers = await this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
);
if (!headers) {
throw new Error('Unable to obtain credentials after refresh.');
}
const retried = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '1'),
});
return firstValueFrom(this.handleRetry(retried, next));
}
private async recoverOrLogout(): Promise<void> {
if (this.logoutInFlight) return;
this.logoutInFlight = true;
console.warn('[AuthHttpInterceptor] Session invalid. Redirecting to login.');
await this.auth.logout();
} }
private shouldSkip(url: string): boolean { private shouldSkip(url: string): boolean {

View File

@@ -42,7 +42,8 @@ export type AuthStatus =
| 'unauthenticated' | 'unauthenticated'
| 'authenticated' | 'authenticated'
| 'refreshing' | 'refreshing'
| 'loading'; | 'loading'
| 'expired';
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000; export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;

View File

@@ -80,7 +80,7 @@ describe('AuthSessionStore', () => {
expect(rehydrated.session()?.tokens.accessToken).toBe('token-abc'); expect(rehydrated.session()?.tokens.accessToken).toBe('token-abc');
}); });
it('drops expired persisted full session and keeps unauthenticated state', () => { it('drops expired persisted full session and sets expired status', () => {
const expired = createSession(Date.now() - 5_000); const expired = createSession(Date.now() - 5_000);
sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(expired)); sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(expired));
sessionStorage.setItem( sessionStorage.setItem(
@@ -96,6 +96,7 @@ describe('AuthSessionStore', () => {
const rehydrated = createStore(); const rehydrated = createStore();
expect(rehydrated.isAuthenticated()).toBeFalse(); expect(rehydrated.isAuthenticated()).toBeFalse();
expect(rehydrated.status()).toBe('expired');
expect(rehydrated.session()).toBeNull(); expect(rehydrated.session()).toBeNull();
expect(rehydrated.subjectHint()).toBe('user-123'); expect(rehydrated.subjectHint()).toBe('user-123');
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull(); expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull();

View File

@@ -12,12 +12,15 @@ import {
providedIn: 'root', providedIn: 'root',
}) })
export class AuthSessionStore { export class AuthSessionStore {
private restoredSessionExpired = false;
private readonly restoredSession = this.readPersistedSession(); private readonly restoredSession = this.readPersistedSession();
private readonly sessionSignal = signal<AuthSession | null>( private readonly sessionSignal = signal<AuthSession | null>(
this.restoredSession this.restoredSession
); );
private readonly statusSignal = signal<AuthStatus>( private readonly statusSignal = signal<AuthStatus>(
this.restoredSession ? 'authenticated' : 'unauthenticated' this.restoredSession ? 'authenticated'
: this.restoredSessionExpired ? 'expired'
: 'unauthenticated'
); );
private readonly persistedSignal = signal<PersistedSessionMetadata | null>( private readonly persistedSignal = signal<PersistedSessionMetadata | null>(
this.readPersistedMetadata(this.restoredSession) this.readPersistedMetadata(this.restoredSession)
@@ -201,6 +204,7 @@ export class AuthSessionStore {
if (parsed.tokens.expiresAtEpochMs <= Date.now()) { if (parsed.tokens.expiresAtEpochMs <= Date.now()) {
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY); sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
this.restoredSessionExpired = true;
return null; return null;
} }

View File

@@ -64,6 +64,7 @@ export class AuthorityAuthService {
private refreshInFlight: Promise<void> | null = null; private refreshInFlight: Promise<void> | null = null;
private silentRefreshInFlight: Promise<boolean> | null = null; private silentRefreshInFlight: Promise<boolean> | null = null;
private lastError: AuthErrorReason | null = null; private lastError: AuthErrorReason | null = null;
private loggingOut = false;
constructor( constructor(
httpBackend: HttpBackend, httpBackend: HttpBackend,
@@ -142,8 +143,9 @@ export class AuthorityAuthService {
return this.silentRefreshInFlight; return this.silentRefreshInFlight;
} }
const statusBeforeRefresh = this.sessionStore.status();
this.sessionStore.setStatus('loading'); this.sessionStore.setStatus('loading');
this.silentRefreshInFlight = this.executeSilentRefresh() this.silentRefreshInFlight = this.executeSilentRefresh(statusBeforeRefresh)
.finally(() => { .finally(() => {
this.silentRefreshInFlight = null; this.silentRefreshInFlight = null;
}); });
@@ -151,7 +153,9 @@ export class AuthorityAuthService {
return this.silentRefreshInFlight; return this.silentRefreshInFlight;
} }
private async executeSilentRefresh(): Promise<boolean> { private async executeSilentRefresh(
statusBeforeRefresh: string
): Promise<boolean> {
const authority = this.config.authority; const authority = this.config.authority;
const silentRedirectUri = this.resolveSilentRefreshRedirectUri(authority); const silentRedirectUri = this.resolveSilentRefreshRedirectUri(authority);
const pkce = await createPkcePair(); const pkce = await createPkcePair();
@@ -204,14 +208,18 @@ export class AuthorityAuthService {
resolve(true); resolve(true);
} else if (data.type === 'silent-refresh-error') { } else if (data.type === 'silent-refresh-error') {
cleanup(); cleanup();
this.sessionStore.setStatus('unauthenticated'); this.sessionStore.setStatus(
statusBeforeRefresh === 'expired' ? 'expired' : 'unauthenticated'
);
resolve(false); resolve(false);
} }
}; };
const timer = setTimeout(() => { const timer = setTimeout(() => {
cleanup(); cleanup();
this.sessionStore.setStatus('unauthenticated'); this.sessionStore.setStatus(
statusBeforeRefresh === 'expired' ? 'expired' : 'unauthenticated'
);
resolve(false); resolve(false);
}, AuthorityAuthService.SILENT_REFRESH_TIMEOUT_MS); }, AuthorityAuthService.SILENT_REFRESH_TIMEOUT_MS);
@@ -316,6 +324,7 @@ export class AuthorityAuthService {
.catch((error) => { .catch((error) => {
this.lastError = 'refresh_failed'; this.lastError = 'refresh_failed';
this.sessionStore.clear(); this.sessionStore.clear();
this.sessionStore.setStatus('expired');
this.getConsoleSession().clear(); this.getConsoleSession().clear();
throw error; throw error;
}) })
@@ -327,6 +336,8 @@ export class AuthorityAuthService {
} }
async logout(): Promise<void> { async logout(): Promise<void> {
if (this.loggingOut) return;
this.loggingOut = true;
const session = this.sessionStore.session(); const session = this.sessionStore.session();
this.cancelRefreshTimer(); this.cancelRefreshTimer();
this.sessionStore.clear(); this.sessionStore.clear();

View File

@@ -165,6 +165,31 @@ export class BrandingService {
?? this.defaultBranding.tenantId; ?? this.defaultBranding.tenantId;
} }
/**
* Remove all branding CSS overrides from :root, restoring design system defaults.
*/
clearThemeTokens(tokenKeys: string[]): void {
const root = document.documentElement;
for (const key of tokenKeys) {
root.style.removeProperty(key);
const colorKeys = BrandingService.THEME_TO_COLOR_MAP[key];
if (colorKeys) {
for (const ck of colorKeys) root.style.removeProperty(ck);
}
}
// Also clear derived accent colors (NOT button colors — those use design system defaults)
const derivedProps = [
'--color-brand-primary-hover', '--color-focus-ring',
'--color-brand-light', '--color-brand-muted',
'--color-brand-primary-10', '--color-brand-primary-20', '--color-brand-primary-30',
'--color-brand-soft', '--color-border-emphasis',
'--color-sidebar-active-bg', '--color-sidebar-hover',
'--color-selection-bg', '--color-selection-border', '--color-selection-text',
'--color-card-heading',
];
for (const prop of derivedProps) root.style.removeProperty(prop);
}
/** /**
* Apply branding configuration to the UI * Apply branding configuration to the UI
*/ */
@@ -200,6 +225,39 @@ export class BrandingService {
document.head.appendChild(link); document.head.appendChild(link);
} }
/**
* Mapping from --theme-* branding tokens to --color-* design system variables.
* The branding editor stores tokens with --theme-* prefix, but the CSS design
* system uses --color-* variables. Both must be set for branding changes to
* take effect in the live UI.
*/
/** Direct 1:1 mapping from --theme-* branding tokens to --color-* design system variables. */
private static readonly THEME_TO_COLOR_MAP: Record<string, string[]> = {
// Brand
'--theme-brand-primary': ['--color-brand-primary', '--color-border-focus', '--color-sidebar-active-border', '--color-sidebar-badge-bg'],
'--theme-brand-secondary': ['--color-brand-secondary'],
'--theme-brand-night': ['--color-brand-primary'],
// Surfaces
'--theme-bg-primary': ['--color-surface-primary'],
'--theme-bg-secondary': ['--color-surface-secondary'],
'--theme-bg-tertiary': ['--color-surface-tertiary'],
'--theme-bg-sidebar': ['--color-sidebar-bg'],
'--theme-bg-night': ['--color-surface-primary'],
'--theme-bg-night-secondary': ['--color-surface-secondary'],
// Text
'--theme-text-primary': ['--color-text-primary', '--color-text-heading'],
'--theme-text-secondary': ['--color-text-secondary'],
'--theme-text-muted': ['--color-text-muted'],
'--theme-text-night': ['--color-text-primary'],
// Border
'--theme-border-primary': ['--color-border-primary'],
// Status
'--theme-status-success': ['--color-status-success'],
'--theme-status-warning': ['--color-status-warning'],
'--theme-status-error': ['--color-status-error'],
'--theme-status-info': ['--color-status-info'],
};
/** /**
* Apply theme tokens as CSS custom properties on :root * Apply theme tokens as CSS custom properties on :root
*/ */
@@ -219,13 +277,45 @@ export class BrandingService {
Object.entries(tokens).forEach(([key, value]) => { Object.entries(tokens).forEach(([key, value]) => {
// Only apply whitelisted tokens // Only apply whitelisted tokens
if (allowedPrefixes.some(prefix => key.startsWith(prefix))) { if (allowedPrefixes.some(prefix => key.startsWith(prefix))) {
// Sanitize value to prevent CSS injection
const sanitizedValue = this.sanitizeCssValue(value); const sanitizedValue = this.sanitizeCssValue(value);
if (sanitizedValue) { if (sanitizedValue) {
// Set the --theme-* token (used by branding preview)
root.style.setProperty(key, sanitizedValue); root.style.setProperty(key, sanitizedValue);
// Set all corresponding --color-* design system variables
const colorKeys = BrandingService.THEME_TO_COLOR_MAP[key];
if (colorKeys) {
for (const ck of colorKeys) {
root.style.setProperty(ck, sanitizedValue);
}
}
} }
} }
}); });
// Derive opacity-based variants from brand-primary (accent highlights, not buttons)
const brandPrimary = this.sanitizeCssValue(tokens['--theme-brand-primary'] ?? '');
if (brandPrimary) {
root.style.setProperty('--color-brand-primary-hover', brandPrimary);
root.style.setProperty('--color-focus-ring', brandPrimary + '66'); // 40%
root.style.setProperty('--color-brand-light', brandPrimary + '1a'); // 10%
root.style.setProperty('--color-brand-muted', brandPrimary + '26'); // 15%
root.style.setProperty('--color-brand-primary-10', brandPrimary + '1a');
root.style.setProperty('--color-brand-primary-20', brandPrimary + '20');
root.style.setProperty('--color-brand-primary-30', brandPrimary + '4d');
root.style.setProperty('--color-brand-soft', brandPrimary + '14');
root.style.setProperty('--color-sidebar-active-bg', brandPrimary + '2e'); // 18%
root.style.setProperty('--color-sidebar-hover', brandPrimary + '0f'); // 6%
root.style.setProperty('--color-border-emphasis', brandPrimary + '66'); // 40%
root.style.setProperty('--color-selection-bg', brandPrimary + '14'); // 8%
root.style.setProperty('--color-selection-border', brandPrimary + '40'); // 25%
root.style.setProperty('--color-selection-text', brandPrimary);
}
// Derive secondary-brand variants
const brandSecondary = this.sanitizeCssValue(tokens['--theme-brand-secondary'] ?? '');
if (brandSecondary) {
root.style.setProperty('--color-card-heading', brandSecondary);
}
} }
/** /**

View File

@@ -120,6 +120,7 @@ const NOTIFY_ADMIN_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="NOTIFY_ADMIN_TABS" [tabs]="NOTIFY_ADMIN_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Notification admin tabs" ariaLabel="Notification admin tabs"
/> />

View File

@@ -382,13 +382,6 @@ interface ChannelTypeOption {
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.filter-group select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.channel-grid { .channel-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@@ -588,30 +581,6 @@ interface ChannelTypeOption {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-status-info-text);
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View File

@@ -369,19 +369,6 @@ import {
gap: 0.25rem; gap: 0.25rem;
} }
.filter-group label {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.filter-group select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
min-width: 150px;
}
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View File

@@ -377,10 +377,6 @@ import {
.section-desc { margin: 0 0 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); } .section-desc { margin: 0 0 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); font-size: 0.875rem;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }

View File

@@ -28,6 +28,7 @@ import {
NotifierDeliveryStats, NotifierDeliveryStats,
} from '../../../core/api/notifier.models'; } from '../../../core/api/notifier.models';
import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../../shared/components/page-action-outlet/page-action-outlet.component';
export type NotificationTab = 'rules' | 'channels' | 'templates' | 'delivery' | 'simulator' | 'config'; export type NotificationTab = 'rules' | 'channels' | 'templates' | 'delivery' | 'simulator' | 'config';
@@ -81,7 +82,7 @@ interface ConfigSubTab {
@Component({ @Component({
selector: 'app-notification-dashboard', selector: 'app-notification-dashboard',
imports: [CommonModule, RouterModule, StellaPageTabsComponent], imports: [CommonModule, RouterModule, StellaPageTabsComponent, PageActionOutletComponent],
template: ` template: `
<div class="nd"> <div class="nd">
<header class="nd__header"> <header class="nd__header">
@@ -188,9 +189,11 @@ interface ConfigSubTab {
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
ariaLabel="Notification administration tabs" ariaLabel="Notification administration tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >
<app-page-action-outlet tabBarAction />
<!-- Config Sub-Navigation --> <!-- Config Sub-Navigation -->
@if (activeTab() === 'config') { @if (activeTab() === 'config') {
<nav class="nd__sub-nav" role="tablist"> <nav class="nd__sub-nav" role="tablist">

View File

@@ -303,14 +303,6 @@ import {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.form-group input[type="text"], .form-group input[type="text"],
.form-group input[type="number"], .form-group input[type="number"],
.form-group select, .form-group select,
@@ -323,19 +315,6 @@ import {
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-status-info-text);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.form-group textarea {
resize: vertical;
min-height: 60px;
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View File

@@ -212,14 +212,6 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
box-shadow: 0 0 0 2px var(--color-focus-ring); box-shadow: 0 0 0 2px var(--color-focus-ring);
} }
.filter-group select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-primary);
}
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View File

@@ -378,10 +378,6 @@ import {
.form-section h4 { margin: 0 0 1rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); } .form-section h4 { margin: 0 0 1rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); font-size: 0.875rem;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }

View File

@@ -371,8 +371,6 @@ import {
} }
.filter-group { display: flex; flex-direction: column; gap: 0.25rem; } .filter-group { display: flex; flex-direction: column; gap: 0.25rem; }
.filter-group label { font-size: 0.75rem; color: var(--color-text-secondary); }
.filter-group select { padding: 0.375rem 0.5rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-sm); font-size: 0.875rem; }
.override-list { display: flex; flex-direction: column; gap: 1rem; } .override-list { display: flex; flex-direction: column; gap: 1rem; }
@@ -469,10 +467,6 @@ import {
.form-section h4 { margin: 0 0 0.75rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); } .form-section h4 { margin: 0 0 0.75rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); font-size: 0.875rem;
}
.field-hint { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--color-text-secondary); } .field-hint { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--color-text-secondary); }

View File

@@ -303,10 +303,6 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
.section-desc { margin: 0 0 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); } .section-desc { margin: 0 0 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); font-size: 0.875rem;
}
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; align-items: end; } .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; align-items: end; }

View File

@@ -276,34 +276,6 @@ import {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
}
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-family: inherit;
}
.form-group textarea {
font-family: monospace;
resize: vertical;
}
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-status-info-text);
}
.help-text { .help-text {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;

View File

@@ -268,30 +268,6 @@ import {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-status-info-text);
}
.code-editor { .code-editor {
font-family: 'Fira Code', 'Consolas', monospace; font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem; font-size: 0.8125rem;

View File

@@ -448,10 +448,6 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event';
.form-section h4 { margin: 0 0 0.75rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); } .form-section h4 { margin: 0 0 0.75rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); font-size: 0.875rem;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.form-row.three-col { grid-template-columns: repeat(3, 1fr); } .form-row.three-col { grid-template-columns: repeat(3, 1fr); }

View File

@@ -138,6 +138,7 @@ interface ActionFeedback {
<stella-page-tabs <stella-page-tabs
[tabs]="tabs" [tabs]="tabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Agent detail tabs" ariaLabel="Agent detail tabs"
> >

View File

@@ -300,16 +300,6 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
.modal__input { .modal__input {
width: 100%; width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px var(--color-focus-ring);
}
} }
.modal__input-error { .modal__input-error {

View File

@@ -579,19 +579,6 @@ const SEVERITY_RANK: Record<string, number> = {
gap: 0.35rem; gap: 0.35rem;
min-width: 180px; min-width: 180px;
} }
.filter-group label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.filter-group select {
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
font-size: 0.875rem;
}
.btn { .btn {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;

View File

@@ -238,6 +238,7 @@ interface HistoryEvent {
<stella-page-tabs <stella-page-tabs
[tabs]="approvalDetailTabs" [tabs]="approvalDetailTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setActiveTab($any($event))" (tabChange)="setActiveTab($any($event))"
ariaLabel="Approval detail tabs" ariaLabel="Approval detail tabs"
/> />
@@ -870,7 +871,7 @@ interface HistoryEvent {
.decision-form textarea:focus { .decision-form textarea:focus {
outline: none; outline: none;
border-color: var(--color-brand-primary); border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(245, 166, 35, 0.3)); box-shadow: 0 0 0 2px var(--color-focus-ring);
} }
.decision-form__counter { .decision-form__counter {

View File

@@ -29,6 +29,7 @@ const AUTHORITY_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="authorityTabs" [tabs]="authorityTabs"
[activeTab]="tab" [activeTab]="tab"
urlParam="tab"
(tabChange)="switchTab($any($event))" (tabChange)="switchTab($any($event))"
ariaLabel="Authority audit tabs" ariaLabel="Authority audit tabs"
/> />

View File

@@ -7,54 +7,21 @@ export const auditLogRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./audit-log-dashboard.component').then((m) => m.AuditLogDashboardComponent), import('./audit-log-dashboard.component').then((m) => m.AuditLogDashboardComponent),
}, },
{ // Event detail (deep link)
path: 'events',
loadComponent: () =>
import('./audit-log-table.component').then((m) => m.AuditLogTableComponent),
},
{ {
path: 'events/:eventId', path: 'events/:eventId',
loadComponent: () => loadComponent: () =>
import('./audit-event-detail.component').then((m) => m.AuditEventDetailComponent), import('./audit-event-detail.component').then((m) => m.AuditEventDetailComponent),
}, },
{ // Backward-compatible redirects for old child-route URLs → dashboard with ?tab=
path: 'timeline', { path: 'events', redirectTo: '?tab=all-events', pathMatch: 'full' },
loadComponent: () => { path: 'policy', redirectTo: '?tab=policy', pathMatch: 'full' },
import('./audit-timeline-search.component').then((m) => m.AuditTimelineSearchComponent), { path: 'authority', redirectTo: '?tab=authority', pathMatch: 'full' },
}, { path: 'vex', redirectTo: '?tab=vex', pathMatch: 'full' },
{ { path: 'integrations', redirectTo: '?tab=integrations', pathMatch: 'full' },
path: 'correlations', { path: 'trust', redirectTo: '?tab=trust', pathMatch: 'full' },
loadComponent: () => { path: 'timeline', redirectTo: '?tab=timeline', pathMatch: 'full' },
import('./audit-correlations.component').then((m) => m.AuditCorrelationsComponent), { path: 'correlations', redirectTo: '?tab=correlations', pathMatch: 'full' },
}, { path: 'anomalies', redirectTo: '?tab=overview', pathMatch: 'full' },
{ { path: 'export', redirectTo: '/evidence/exports', pathMatch: 'full' },
path: 'anomalies',
loadComponent: () =>
import('./audit-anomalies.component').then((m) => m.AuditAnomaliesComponent),
},
{
path: 'export',
loadComponent: () =>
import('./audit-export.component').then((m) => m.AuditExportComponent),
},
{
path: 'policy',
loadComponent: () =>
import('./audit-policy.component').then((m) => m.AuditPolicyComponent),
},
{
path: 'authority',
loadComponent: () =>
import('./audit-authority.component').then((m) => m.AuditAuthorityComponent),
},
{
path: 'vex',
loadComponent: () =>
import('./audit-vex.component').then((m) => m.AuditVexComponent),
},
{
path: 'integrations',
loadComponent: () =>
import('./audit-integrations.component').then((m) => m.AuditIntegrationsComponent),
},
]; ];

View File

@@ -0,0 +1,62 @@
/**
* Trust Audit — shell component for trust-related audit events.
* Renders TrustAuditLogComponent, AirgapAuditComponent, and IncidentAuditComponent
* as signal-based tabs within the Audit Log section.
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { RouterModule } from '@angular/router';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { TrustAuditLogComponent } from '../trust-admin/trust-audit-log.component';
import { AirgapAuditComponent } from '../trust-admin/airgap-audit.component';
import { IncidentAuditComponent } from '../trust-admin/incident-audit.component';
const TRUST_AUDIT_TABS: StellaPageTab[] = [
{ id: 'events', label: 'Trust Events', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'airgap', label: 'Air-Gap', icon: 'M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z|||M13 14l-2 2 2 2' },
{ id: 'incidents', label: 'Incidents', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' },
];
@Component({
selector: 'app-audit-trust',
standalone: true,
imports: [RouterModule, StellaPageTabsComponent, TrustAuditLogComponent, AirgapAuditComponent, IncidentAuditComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="trust-audit-page">
<header class="page-header">
<div class="breadcrumb">
<a routerLink="/evidence/audit-log">Audit Log</a> / Trust Audit
</div>
<h1>Trust Audit</h1>
<p class="description">Key rotation events, air-gap transport logs, and security incidents related to trust material.</p>
</header>
<stella-page-tabs
[tabs]="tabs"
[activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Trust audit tabs"
>
@switch (activeTab()) {
@case ('events') { <app-trust-audit-log /> }
@case ('airgap') { <app-airgap-audit /> }
@case ('incidents') { <app-incident-audit /> }
}
</stella-page-tabs>
</div>
`,
styles: [`
.trust-audit-page { max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1rem; }
.page-header h1 { margin: 0.25rem 0; font-size: 1.25rem; font-weight: var(--font-weight-semibold); }
.description { margin: 0; font-size: 0.8rem; color: var(--color-text-secondary); }
.breadcrumb { font-size: 0.75rem; color: var(--color-text-muted); }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
`],
})
export class AuditTrustComponent {
readonly tabs = TRUST_AUDIT_TABS;
readonly activeTab = signal<string>('events');
}

View File

@@ -114,7 +114,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: background:
radial-gradient(ellipse 70% 50% at 50% 30%, rgba(245, 184, 74, 0.05) 0%, transparent 60%), radial-gradient(ellipse 70% 50% at 50% 30%, var(--color-brand-soft) 0%, transparent 60%),
radial-gradient(ellipse 40% 50% at 80% 90%, rgba(59, 130, 246, 0.03) 0%, transparent 50%), radial-gradient(ellipse 40% 50% at 80% 90%, rgba(59, 130, 246, 0.03) 0%, transparent 50%),
#060a14; #060a14;
} }
@@ -152,9 +152,9 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
background: rgba(8, 14, 26, 0.5); background: rgba(8, 14, 26, 0.5);
backdrop-filter: blur(20px) saturate(1.3); backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3); -webkit-backdrop-filter: blur(20px) saturate(1.3);
border: 1px solid rgba(245, 184, 74, 0.08); border: 1px solid var(--color-brand-soft);
box-shadow: box-shadow:
0 0 60px rgba(245, 184, 74, 0.03), 0 0 60px var(--color-brand-soft),
0 16px 48px rgba(0, 0, 0, 0.3), 0 16px 48px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.03); inset 0 1px 0 rgba(255, 255, 255, 0.03);
animation: card-in 600ms cubic-bezier(0.18, 0.89, 0.32, 1) both; animation: card-in 600ms cubic-bezier(0.18, 0.89, 0.32, 1) both;
@@ -195,7 +195,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
/* Outer orbit — slow CW */ /* Outer orbit — slow CW */
.orbit--1 { .orbit--1 {
stroke: rgba(245, 184, 74, 0.2); stroke: var(--color-brand-primary-20);
stroke-width: 1; stroke-width: 1;
stroke-dasharray: 80 196; stroke-dasharray: 80 196;
animation: orbit-spin-cw 3s linear infinite; animation: orbit-spin-cw 3s linear infinite;
@@ -203,7 +203,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
/* Middle orbit — medium CCW */ /* Middle orbit — medium CCW */
.orbit--2 { .orbit--2 {
stroke: rgba(245, 184, 74, 0.3); stroke: var(--color-brand-primary-30);
stroke-width: 1.2; stroke-width: 1.2;
stroke-dasharray: 55 159; stroke-dasharray: 55 159;
animation: orbit-spin-ccw 2.2s linear infinite; animation: orbit-spin-ccw 2.2s linear infinite;
@@ -211,7 +211,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
/* Inner orbit — fast CW */ /* Inner orbit — fast CW */
.orbit--3 { .orbit--3 {
stroke: rgba(245, 184, 74, 0.45); stroke: var(--color-border-emphasis);
stroke-width: 1.5; stroke-width: 1.5;
stroke-dasharray: 35 116; stroke-dasharray: 35 116;
animation: orbit-spin-cw 1.6s linear infinite; animation: orbit-spin-cw 1.6s linear infinite;
@@ -224,7 +224,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: rgba(245, 184, 74, 0.7); color: var(--color-brand-primary);
animation: icon-breathe 3s ease-in-out infinite; animation: icon-breathe 3s ease-in-out infinite;
} }
@@ -307,10 +307,10 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.625rem 1.5rem; padding: 0.625rem 1.5rem;
border: 1px solid rgba(245, 184, 74, 0.2); border: 1px solid var(--color-brand-primary-20);
border-radius: 12px; border-radius: 12px;
background: rgba(245, 184, 74, 0.06); background: var(--color-sidebar-hover);
color: rgba(245, 184, 74, 0.8); color: var(--color-brand-primary);
font-family: inherit; font-family: inherit;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
@@ -326,15 +326,15 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
} }
.retry-btn:hover { .retry-btn:hover {
background: rgba(245, 184, 74, 0.12); background: var(--color-brand-primary-10);
border-color: rgba(245, 184, 74, 0.35); border-color: var(--color-brand-primary-30);
color: rgba(245, 184, 74, 1); color: var(--color-brand-primary);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(245, 184, 74, 0.1); box-shadow: 0 4px 16px var(--color-brand-primary-10);
} }
.retry-btn:focus-visible { .retry-btn:focus-visible {
outline: 2px solid rgba(245, 184, 74, 0.4); outline: 2px solid var(--color-focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }

View File

@@ -89,6 +89,7 @@ const BINARY_INDEX_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="BINARY_INDEX_TABS" [tabs]="BINARY_INDEX_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setTab($any($event))" (tabChange)="setTab($any($event))"
ariaLabel="BinaryIndex operations" ariaLabel="BinaryIndex operations"
> >

View File

@@ -294,19 +294,6 @@ interface ComponentDraft {
gap: 0.25rem; gap: 0.25rem;
} }
.form-field label {
font-size: 0.875rem;
font-weight: 500;
}
.form-field input,
.form-field textarea {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
}
.bundle-builder__hint { .bundle-builder__hint {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-secondary, #666); color: var(--color-text-secondary, #666);

View File

@@ -74,6 +74,7 @@ const BUNDLE_DETAIL_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="BUNDLE_DETAIL_TABS" [tabs]="BUNDLE_DETAIL_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Bundle details" ariaLabel="Bundle details"
> >

View File

@@ -90,6 +90,7 @@ const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="BUNDLE_VERSION_TABS" [tabs]="BUNDLE_VERSION_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Bundle version details" ariaLabel="Bundle version details"
> >

View File

@@ -261,20 +261,6 @@ import {
gap: 8px; gap: 8px;
} }
.filter-group label {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--theme-text-secondary);
}
.filter-group select {
padding: 6px 12px;
border: 1px solid var(--theme-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
background: var(--theme-bg-primary);
}
.config-content { .config-content {
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 400px;

View File

@@ -61,6 +61,7 @@ const CONFIG_DETAIL_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="configDetailTabs" [tabs]="configDetailTabs"
[activeTab]="activeTab" [activeTab]="activeTab"
urlParam="tab"
(tabChange)="activeTab = $any($event)" (tabChange)="activeTab = $any($event)"
ariaLabel="Integration detail tabs" ariaLabel="Integration detail tabs"
/> />

View File

@@ -1,12 +1,15 @@
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002) // Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002)
import { Component, OnInit, inject, signal } from '@angular/core'; import { Component, OnInit, inject, signal, ChangeDetectorRef } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service'; import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes'; import { StellaOpsScopes } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component'; import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component';
import { I18nService } from '../../../core/i18n';
@Component({ @Component({
selector: 'app-audit-log', selector: 'app-audit-log',
@@ -51,7 +54,19 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/
</div> </div>
@if (isLoading) { @if (isLoading) {
<div class="loading">Loading audit events...</div> <div class="skeleton-list">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.2"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:0.5"></div>
</div>
}
</div>
} @else if (filteredEvents.length === 0) { } @else if (filteredEvents.length === 0) {
<div class="empty-state">No audit events found</div> <div class="empty-state">No audit events found</div>
} @else { } @else {
@@ -458,20 +473,10 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/
color: white; color: white;
} }
.loading { .skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; }
text-align: center; .skeleton-row { display: flex; gap: 0.75rem; padding: 0.6rem 0; }
padding: 48px; .skeleton-cell { height: 14px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary, var(--theme-bg-tertiary)); animation: skeleton-pulse 1.2s ease-in-out infinite; }
color: var(--theme-text-secondary); @keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
background: linear-gradient(90deg, var(--theme-bg-secondary, var(--color-surface-primary)) 25%, var(--theme-bg-tertiary, var(--color-surface-elevated)) 50%, var(--theme-bg-secondary, var(--color-surface-primary)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-lg);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -493,6 +498,8 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/
export class AuditLogComponent implements OnInit { export class AuditLogComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService); private readonly api = inject(ConsoleAdminApiService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly cdr = inject(ChangeDetectorRef);
private readonly i18n = inject(I18nService);
events: AuditEvent[] = []; events: AuditEvent[] = [];
filteredEvents: AuditEvent[] = []; filteredEvents: AuditEvent[] = [];
@@ -548,25 +555,32 @@ export class AuditLogComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
if (this.canRead) { this.loadAuditLog();
this.loadAuditLog(); setTimeout(() => { if (this.isLoading) { this.error = this.i18n.t('common.error.timeout') + ' ' + this.i18n.t('ui.error.service_unavailable'); this.isLoading = false; this.cdr.markForCheck(); } }, 15_000);
}
} }
loadAuditLog(): void { loadAuditLog(): void {
this.isLoading = true; this.isLoading = true;
this.error = null; this.error = null;
this.api.listAuditEvents().subscribe({ this.api.listAuditEvents().pipe(
next: (response: { events: AuditEvent[] }) => { timeout(15_000),
this.events = response.events; catchError((err: any) => {
this.applyFilters(); const reason = err.name === 'TimeoutError'
? this.i18n.t('common.error.timeout')
: err.status ? this.i18n.t('ui.error.http_status', { status: err.status })
: (err.message || this.i18n.t('common.error.generic'));
this.error = this.i18n.t('ui.error.load_failed', { resource: 'audit log', reason });
this.isLoading = false; this.isLoading = false;
}, this.cdr.markForCheck();
error: (err: { error?: { message?: string }; message?: string }) => { return of(null);
this.error = 'Failed to load audit log: ' + (err.error?.message || err.message); })
this.isLoading = false; ).subscribe((response: any) => {
} if (response === null) return;
this.events = Array.isArray(response) ? response : (response?.events ?? []);
this.applyFilters();
this.isLoading = false;
this.cdr.markForCheck();
}); });
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal, computed } from '@angular/core'; import { Component, OnInit, inject, signal, computed, ChangeDetectorRef } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { BrandingService } from '../../../core/branding/branding.service'; import { BrandingService } from '../../../core/branding/branding.service';
@@ -58,7 +58,7 @@ const DARK_DEFAULTS = {
<div class="branding-settings"> <div class="branding-settings">
<header class="page-header"> <header class="page-header">
<div class="page-header-text"> <div class="page-header-text">
<h1>Tenant Branding</h1> <h1>Theme & Branding</h1>
<p class="page-subtitle">Customize the look and feel of your Stella Ops instance.</p> <p class="page-subtitle">Customize the look and feel of your Stella Ops instance.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
@@ -568,12 +568,24 @@ const DARK_DEFAULTS = {
{{ formData.title || 'Stella Ops' }} {{ formData.title || 'Stella Ops' }}
</span> </span>
<div class="preview-topbar-pill" <div class="preview-topbar-pill"
[style.background]="resolvedBrandColor()" [style.background]="resolvedBrandSecondary()"
[style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'"> [style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'">
v3.2 v3.2
</div> </div>
</div> </div>
<!-- Tabs (showing elevated surface) -->
<div class="preview-tabs" [style.border-bottom-color]="resolvedBorderColor()">
<span class="preview-tab preview-tab--active"
[style.background]="resolvedSurfaceTertiary()"
[style.color]="resolvedTextPrimary()"
[style.border-bottom-color]="resolvedBrandColor()">Overview</span>
<span class="preview-tab"
[style.color]="resolvedTextMuted()">Details</span>
<span class="preview-tab"
[style.color]="resolvedTextMuted()">Gates</span>
</div>
<!-- Cards --> <!-- Cards -->
<div class="preview-cards"> <div class="preview-cards">
<div class="preview-stat-card" <div class="preview-stat-card"
@@ -624,12 +636,20 @@ const DARK_DEFAULTS = {
</span> </span>
</div> </div>
<!-- Action button --> <!-- Action buttons -->
<button class="preview-action-btn" <div class="preview-actions">
[style.background]="resolvedBrandColor()" <button class="preview-action-btn preview-action-btn--primary"
[style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'"> [style.background]="resolvedBrandColor()"
Create Release [style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'">
</button> Create Release
</button>
<button class="preview-action-btn preview-action-btn--secondary"
[style.border-color]="resolvedBorderColor()"
[style.color]="resolvedTextPrimary()"
[style.background]="resolvedSurfaceTertiary()">
Cancel
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -916,14 +936,6 @@ const DARK_DEFAULTS = {
margin-top: 1rem; margin-top: 1rem;
} }
.form-field label {
display: block;
margin-bottom: 0.375rem;
font-size: var(--font-size-sm, 0.75rem);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text-primary);
}
.input { .input {
width: 100%; width: 100%;
padding: 0.4375rem 0.75rem; padding: 0.4375rem 0.75rem;
@@ -1003,7 +1015,7 @@ const DARK_DEFAULTS = {
.upload-zone:hover { .upload-zone:hover {
border-color: var(--color-brand-primary); border-color: var(--color-brand-primary);
background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.1)); background: var(--color-brand-primary-10);
} }
.upload-zone.has-preview { .upload-zone.has-preview {
@@ -1305,7 +1317,7 @@ const DARK_DEFAULTS = {
} }
.preview-nav-item.active { .preview-nav-item.active {
background: rgba(245, 184, 74, 0.12); background: var(--color-brand-primary-10);
} }
.preview-nav-item span { .preview-nav-item span {
@@ -1406,8 +1418,36 @@ const DARK_DEFAULTS = {
border: 1px solid; border: 1px solid;
} }
.preview-action-btn { .preview-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid;
margin: 0 0.625rem;
padding: 0;
}
.preview-tab {
font-size: 6px;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-bottom: 2px solid transparent;
cursor: default;
}
.preview-tab--active {
font-weight: 700;
border-bottom-width: 2px;
border-bottom-style: solid;
}
.preview-actions {
display: flex;
gap: 0.375rem;
margin: auto 0.625rem 0.625rem; margin: auto 0.625rem 0.625rem;
}
.preview-action-btn {
flex: 1;
padding: 0.3125rem 0.5rem; padding: 0.3125rem 0.5rem;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
@@ -1416,11 +1456,16 @@ const DARK_DEFAULTS = {
cursor: default; cursor: default;
text-align: center; text-align: center;
} }
.preview-action-btn--secondary {
border: 1px solid;
}
`] `]
}) })
export class BrandingEditorComponent implements OnInit { export class BrandingEditorComponent implements OnInit {
private readonly brandingService = inject(BrandingService); private readonly brandingService = inject(BrandingService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
readonly isLoading = signal(false); readonly isLoading = signal(false);
@@ -1491,45 +1536,53 @@ export class BrandingEditorComponent implements OnInit {
// Resolved preview values (check token first, then fallback) // Resolved preview values (check token first, then fallback)
// ----------------------------------------------------------------- // -----------------------------------------------------------------
resolvedBrandColor = computed(() => resolvedBrandColor(): string {
this.getTokenValue('--theme-brand-primary') || LIGHT_DEFAULTS['--color-brand-primary'] return this.getTokenValue('--theme-brand-primary') || LIGHT_DEFAULTS['--color-brand-primary'];
); }
resolvedSurfacePrimary = computed(() => resolvedBrandSecondary(): string {
this.getTokenValue('--theme-bg-primary') || LIGHT_DEFAULTS['--color-surface-primary'] return this.getTokenValue('--theme-brand-secondary') || LIGHT_DEFAULTS['--color-brand-secondary'];
); }
resolvedSurfaceSecondary = computed(() => resolvedSurfacePrimary(): string {
this.getTokenValue('--theme-bg-secondary') || LIGHT_DEFAULTS['--color-surface-secondary'] return this.getTokenValue('--theme-bg-primary') || LIGHT_DEFAULTS['--color-surface-primary'];
); }
resolvedTextPrimary = computed(() => resolvedSurfaceSecondary(): string {
this.getTokenValue('--theme-text-primary') || LIGHT_DEFAULTS['--color-text-primary'] return this.getTokenValue('--theme-bg-secondary') || LIGHT_DEFAULTS['--color-surface-secondary'];
); }
resolvedTextMuted = computed(() => resolvedSurfaceTertiary(): string {
this.getTokenValue('--theme-text-muted') || LIGHT_DEFAULTS['--color-text-muted'] return this.getTokenValue('--theme-bg-tertiary') || LIGHT_DEFAULTS['--color-surface-tertiary'];
); }
resolvedBorderColor = computed(() => resolvedTextPrimary(): string {
this.getTokenValue('--theme-border-primary') || LIGHT_DEFAULTS['--color-border-primary'] return this.getTokenValue('--theme-text-primary') || LIGHT_DEFAULTS['--color-text-primary'];
); }
resolvedStatusSuccess = computed(() => resolvedTextMuted(): string {
this.getTokenValue('--theme-status-success') || LIGHT_DEFAULTS['--color-status-success'] return this.getTokenValue('--theme-text-muted') || LIGHT_DEFAULTS['--color-text-muted'];
); }
resolvedStatusWarning = computed(() => resolvedBorderColor(): string {
this.getTokenValue('--theme-status-warning') || LIGHT_DEFAULTS['--color-status-warning'] return this.getTokenValue('--theme-border-primary') || LIGHT_DEFAULTS['--color-border-primary'];
); }
resolvedStatusError = computed(() => resolvedStatusSuccess(): string {
this.getTokenValue('--theme-status-error') || LIGHT_DEFAULTS['--color-status-error'] return this.getTokenValue('--theme-status-success') || LIGHT_DEFAULTS['--color-status-success'];
); }
resolvedStatusInfo = computed(() => resolvedStatusWarning(): string {
this.getTokenValue('--theme-status-info') || LIGHT_DEFAULTS['--color-status-info'] return this.getTokenValue('--theme-status-warning') || LIGHT_DEFAULTS['--color-status-warning'];
); }
resolvedStatusError(): string {
return this.getTokenValue('--theme-status-error') || LIGHT_DEFAULTS['--color-status-error'];
}
resolvedStatusInfo(): string {
return this.getTokenValue('--theme-status-info') || LIGHT_DEFAULTS['--color-status-info'];
}
get canWrite(): boolean { get canWrite(): boolean {
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_BRANDING_WRITE); return this.auth.hasScope(StellaOpsScopes.AUTHORITY_BRANDING_WRITE);
@@ -1612,6 +1665,7 @@ export class BrandingEditorComponent implements OnInit {
}); });
} }
this.markAsChanged(); this.markAsChanged();
this.cdr.markForCheck();
} }
markAsChanged(): void { markAsChanged(): void {
@@ -1622,16 +1676,24 @@ export class BrandingEditorComponent implements OnInit {
this.success.set(null); this.success.set(null);
} }
resetAllToDefaults(): void { async resetAllToDefaults(): Promise<void> {
if (!this.canWrite) return; if (!this.canWrite) return;
if (!confirm('Reset all branding to defaults and save immediately?')) return;
// Clear all color tokens back to empty (fallbacks will show) // Remove applied CSS overrides so design system defaults show immediately
this.brandingService.clearThemeTokens(this.themeTokens.map(t => t.key));
// Clear all tokens back to empty (fallbacks will show)
this.themeTokens = []; this.themeTokens = [];
this.formData.themeTokens = {}; this.formData.themeTokens = {};
this.formData.title = ''; this.formData.title = '';
this.formData.logoUrl = ''; this.formData.logoUrl = '';
this.formData.faviconUrl = ''; this.formData.faviconUrl = '';
this.markAsChanged(); this.hasChanges.set(false);
this.cdr.markForCheck();
// Save immediately to backend so defaults persist on reload
await this.applyBranding();
} }
async onLogoSelected(event: Event): Promise<void> { async onLogoSelected(event: Event): Promise<void> {

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, inject, ChangeDetectorRef } from '@angular/core';
import { of } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, Client } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, Client } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
@@ -7,6 +8,8 @@ import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes'; import { StellaOpsScopes } from '../../../core/auth/scopes';
import { CopyToClipboardComponent } from '../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component'; import { CopyToClipboardComponent } from '../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { PageActionService } from '../../../core/services/page-action.service';
import { I18nService } from '../../../core/i18n';
@Component({ @Component({
selector: 'app-clients-list', selector: 'app-clients-list',
@@ -15,16 +18,14 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
<div class="admin-panel"> <div class="admin-panel">
<header class="admin-header"> <header class="admin-header">
<h1>OAuth2 Clients</h1> <h1>OAuth2 Clients</h1>
<button
class="btn-primary"
(click)="showCreateForm()"
[disabled]="!canWrite || isCreating">
Create Client
</button>
</header> </header>
@if (error) { @if (error) {
<div class="alert alert-error">{{ error }}</div> <div class="error-banner">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{{ error }}</span>
<button type="button" class="error-banner__retry" (click)="loadClients()">Retry</button>
</div>
} }
@if (isCreating || editingClient) { @if (isCreating || editingClient) {
@@ -113,7 +114,19 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
} }
@if (isLoading) { @if (isLoading) {
<div class="loading">Loading OAuth2 clients...</div> <div class="skeleton-list">
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.2"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:0.7"></div>
<div class="skeleton-cell" style="flex:0.7"></div>
</div>
}
</div>
} @else if (clients.length === 0 && !isCreating) { } @else if (clients.length === 0 && !isCreating) {
<div class="empty-state">No OAuth2 clients configured</div> <div class="empty-state">No OAuth2 clients configured</div>
} @else { } @else {
@@ -156,7 +169,9 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
</td> </td>
<td> <td>
<div class="action-buttons"> <div class="action-buttons">
@if (canWrite) { @if (isBuiltInClient(client)) {
<span class="badge badge-system">System</span>
} @else if (canWrite) {
<button <button
class="btn-sm" class="btn-sm"
(click)="editClient(client)" (click)="editClient(client)"
@@ -230,25 +245,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
margin-bottom: 16px; margin-bottom: 16px;
} }
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: var(--font-weight-medium);
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--theme-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
}
.form-group input:disabled {
background: var(--theme-bg-tertiary);
cursor: not-allowed;
}
.form-hint { .form-hint {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
@@ -342,6 +338,15 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
background: var(--theme-status-info); background: var(--theme-status-info);
} }
.badge-system {
background: var(--color-surface-tertiary, #e5e7eb);
color: var(--color-text-muted, #6b7280);
font-size: 0.7rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.status-badge { .status-badge {
display: inline-block; display: inline-block;
padding: 4px 8px; padding: 4px 8px;
@@ -432,13 +437,31 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
color: white; color: white;
} }
.loading,
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 48px; padding: 48px;
color: var(--theme-text-secondary); color: var(--theme-text-secondary);
} }
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; }
.skeleton-row { display: flex; gap: 0.75rem; padding: 0.6rem 0; }
.skeleton-cell { height: 14px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: skeleton-pulse 1.2s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.error-banner {
display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem;
background: var(--color-status-error-bg); border: 1px solid var(--color-status-error);
border-radius: var(--radius-md); color: var(--color-status-error-text); font-size: 0.8125rem;
margin-bottom: 16px;
}
.error-banner span { flex: 1; }
.error-banner__retry {
padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 600;
border: 1px solid var(--color-status-error); border-radius: var(--radius-sm);
background: transparent; color: var(--color-status-error-text); cursor: pointer;
}
.error-banner__retry:hover { background: var(--color-status-error); color: #fff; }
code { code {
font-family: 'Monaco', 'Courier New', monospace; font-family: 'Monaco', 'Courier New', monospace;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
@@ -448,10 +471,13 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
} }
`] `]
}) })
export class ClientsListComponent implements OnInit { export class ClientsListComponent implements OnInit, OnDestroy {
private readonly api = inject(ConsoleAdminApiService); private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly pageAction = inject(PageActionService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly i18n = inject(I18nService);
clients: Client[] = []; clients: Client[] = [];
isLoading = false; isLoading = false;
@@ -476,25 +502,44 @@ export class ClientsListComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Create Client', action: () => this.showCreateForm() });
this.loadClients(); this.loadClients();
setTimeout(() => { if (this.isLoading) { this.error = this.i18n.t('common.error.timeout') + ' ' + this.i18n.t('ui.error.service_unavailable'); this.isLoading = false; this.cdr.markForCheck(); } }, 15_000);
}
ngOnDestroy(): void {
this.pageAction.clear();
} }
loadClients(): void { loadClients(): void {
this.isLoading = true; this.isLoading = true;
this.error = null; this.error = null;
this.api.listClients().subscribe({ this.api.listClients().pipe(
next: (response) => { timeout(15_000),
this.clients = response.clients; catchError((err) => {
const reason = err.name === 'TimeoutError'
? this.i18n.t('common.error.timeout')
: err.status ? this.i18n.t('ui.error.http_status', { status: err.status })
: (err.message || this.i18n.t('common.error.generic'));
this.error = this.i18n.t('ui.error.load_failed', { resource: 'clients', reason });
this.isLoading = false; this.isLoading = false;
}, this.cdr.markForCheck();
error: (err) => { return of(null);
this.error = 'Failed to load OAuth2 clients: ' + (err.error?.message || err.message); })
this.isLoading = false; ).subscribe((response: any) => {
} if (response === null) return;
this.clients = Array.isArray(response) ? response : (response?.clients ?? []);
this.isLoading = false;
this.cdr.markForCheck();
}); });
} }
/** Built-in clients seeded by the Authority bootstrap process — not user-editable. */
isBuiltInClient(client: Client): boolean {
return client.clientId === 'stella-ops-ui';
}
showCreateForm(): void { showCreateForm(): void {
this.isCreating = true; this.isCreating = true;
this.editingClient = null; this.editingClient = null;
@@ -507,9 +552,11 @@ export class ClientsListComponent implements OnInit {
redirectUrisInput: '', redirectUrisInput: '',
scopesInput: '' scopesInput: ''
}; };
this.cdr.markForCheck();
} }
editClient(client: Client): void { editClient(client: Client): void {
if (this.isBuiltInClient(client)) return;
this.isCreating = false; this.isCreating = false;
this.editingClient = client; this.editingClient = client;
this.newClientSecret = null; this.newClientSecret = null;
@@ -521,6 +568,7 @@ export class ClientsListComponent implements OnInit {
redirectUrisInput: (client.redirectUris ?? []).join(','), redirectUrisInput: (client.redirectUris ?? []).join(','),
scopesInput: client.scopes.join(',') scopesInput: client.scopes.join(',')
}; };
this.cdr.markForCheck();
} }
cancelForm(): void { cancelForm(): void {
@@ -535,6 +583,7 @@ export class ClientsListComponent implements OnInit {
redirectUrisInput: '', redirectUrisInput: '',
scopesInput: '' scopesInput: ''
}; };
this.cdr.markForCheck();
} }
async createClient(): Promise<void> { async createClient(): Promise<void> {
@@ -562,10 +611,12 @@ export class ClientsListComponent implements OnInit {
} }
this.newClientSecret = response.clientSecret ?? null; this.newClientSecret = response.clientSecret ?? null;
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to create client: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'create client', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
} }
}); });
} }
@@ -596,10 +647,12 @@ export class ClientsListComponent implements OnInit {
} }
this.cancelForm(); this.cancelForm();
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to update client: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'update client', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
} }
}); });
} }
@@ -617,9 +670,11 @@ export class ClientsListComponent implements OnInit {
this.newClientSecret = response.newSecret; this.newClientSecret = response.newSecret;
this.isCreating = false; this.isCreating = false;
this.editingClient = null; this.editingClient = null;
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to rotate client secret: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'rotate client secret', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }
@@ -634,9 +689,11 @@ export class ClientsListComponent implements OnInit {
if (client) { if (client) {
client.status = 'disabled'; client.status = 'disabled';
} }
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to disable client: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'disable client', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }
@@ -651,9 +708,11 @@ export class ClientsListComponent implements OnInit {
if (client) { if (client) {
client.status = 'active'; client.status = 'active';
} }
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to enable client: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'enable client', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/ro
import { filter } from 'rxjs'; import { filter } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
type TabType = 'tenants' | 'users' | 'roles' | 'clients' | 'tokens' | 'audit' | 'branding'; type TabType = 'tenants' | 'users' | 'roles' | 'clients' | 'tokens' | 'audit' | 'branding';
@@ -26,7 +27,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
@Component({ @Component({
selector: 'app-console-admin-layout', selector: 'app-console-admin-layout',
standalone: true, standalone: true,
imports: [RouterOutlet, StellaPageTabsComponent], imports: [RouterOutlet, StellaPageTabsComponent, PageActionOutletComponent],
template: ` template: `
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
@@ -34,6 +35,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
ariaLabel="Console admin tabs" ariaLabel="Console admin tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >
<app-page-action-outlet tabBarAction />
<router-outlet /> <router-outlet />
</stella-page-tabs> </stella-page-tabs>
`, `,

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, inject, ChangeDetectorRef, NgZone } from '@angular/core';
import { of } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, Role } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, Role } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
@@ -7,6 +8,7 @@ import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes'; import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { PageActionService } from '../../../core/services/page-action.service'; import { PageActionService } from '../../../core/services/page-action.service';
import { I18nService } from '../../../core/i18n';
interface RoleBundle { interface RoleBundle {
module: string; module: string;
@@ -41,7 +43,11 @@ interface RoleBundle {
</div> </div>
@if (error) { @if (error) {
<div class="alert alert-error">{{ error }}</div> <div class="error-banner">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{{ error }}</span>
<button type="button" class="error-banner__retry" (click)="loadCustomRoles()">Retry</button>
</div>
} }
@if (activeTab === 'catalog') { @if (activeTab === 'catalog') {
@@ -111,19 +117,43 @@ interface RoleBundle {
required> required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Scopes</label> <label>Scopes ({{ formData.selectedScopes.length }} selected)</label>
<div class="scope-selector"> <div class="scope-tag-input" (click)="scopeInputEl.focus()">
@for (scope of availableScopes; track scope) { <div class="scope-tag-input__tags">
<label class="scope-checkbox"> @for (scope of formData.selectedScopes; track scope) {
<input <span class="scope-tag">
type="checkbox" <code>{{ scope }}</code>
[checked]="formData.selectedScopes.includes(scope)" <button type="button" class="scope-tag__remove" (click)="toggleScope(scope); $event.stopPropagation()" title="Remove">&times;</button>
(change)="toggleScope(scope)"> </span>
<span class="scope-label">{{ getScopeLabel(scope) }}</span> }
<app-inline-code [code]="scope"></app-inline-code> <input
</label> #scopeInputEl
} type="text"
class="scope-tag-input__field"
[(ngModel)]="scopeSearchQuery"
(focus)="scopeDropdownOpen = true"
(blur)="closeScopeDropdown()"
placeholder="{{ formData.selectedScopes.length ? 'Add more...' : 'Type to search scopes...' }}">
</div>
</div> </div>
@if (scopeDropdownOpen && scopeSuggestions.length > 0) {
<div class="scope-dropdown">
@for (group of groupedSuggestions; track group.module) {
<div class="scope-dropdown__group">
<div class="scope-dropdown__group-header">
<span>{{ group.module }}</span>
<button type="button" class="scope-dropdown__add-all" (mousedown)="addGroupScopes(group); $event.preventDefault()">Add all</button>
</div>
@for (scope of group.scopes; track scope) {
<button type="button" class="scope-dropdown__item" (mousedown)="toggleScope(scope); $event.preventDefault()">
<span class="scope-dropdown__label">{{ getScopeLabel(scope) }}</span>
<code class="scope-dropdown__id">{{ scope }}</code>
</button>
}
</div>
}
</div>
}
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button <button
@@ -143,7 +173,16 @@ interface RoleBundle {
} }
@if (isLoading) { @if (isLoading) {
<div class="loading">Loading custom roles...</div> <div class="skeleton-list">
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:2"></div>
<div class="skeleton-cell" style="flex:2.5"></div>
<div class="skeleton-cell" style="flex:0.7"></div>
</div>
}
</div>
} @else if (customRoles.length === 0 && !isCreating) { } @else if (customRoles.length === 0 && !isCreating) {
<div class="empty-state">No custom roles defined</div> <div class="empty-state">No custom roles defined</div>
} @else { } @else {
@@ -239,6 +278,20 @@ interface RoleBundle {
color: var(--theme-brand-primary); color: var(--theme-brand-primary);
} }
.btn-add-inline {
margin-left: auto;
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-sm);
background: var(--color-brand-primary);
color: #fff;
cursor: pointer;
transition: opacity 0.12s;
}
.btn-add-inline:hover { opacity: 0.85; }
.catalog-section { .catalog-section {
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -281,25 +334,22 @@ interface RoleBundle {
} }
.bundle-card { .bundle-card {
background: var(--theme-bg-secondary); background: var(--color-surface-primary);
border: 2px solid var(--theme-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-md);
padding: 16px; padding: 0.75rem;
} }
.bundle-card.tier-viewer { .bundle-card.tier-viewer {
border-left-color: var(--theme-status-info); border-left: 3px solid var(--color-status-info);
border-left-width: 4px;
} }
.bundle-card.tier-operator { .bundle-card.tier-operator {
border-left-color: var(--theme-status-warning); border-left: 3px solid var(--color-status-warning);
border-left-width: 4px;
} }
.bundle-card.tier-admin { .bundle-card.tier-admin {
border-left-color: var(--theme-status-error); border-left: 3px solid var(--color-status-error);
border-left-width: 4px;
} }
.bundle-header { .bundle-header {
@@ -310,48 +360,58 @@ interface RoleBundle {
} }
.bundle-name { .bundle-name {
font-weight: var(--font-weight-semibold); font-weight: 600;
font-family: 'Monaco', 'Courier New', monospace; font-family: var(--font-family-mono, monospace);
font-size: var(--font-size-base); font-size: 0.75rem;
color: var(--color-text-primary);
} }
.bundle-tier { .bundle-tier {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: var(--font-size-xs); font-size: 0.5625rem;
font-weight: var(--font-weight-medium); font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
background: var(--theme-bg-tertiary); letter-spacing: 0.04em;
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
} }
.bundle-description { .bundle-description {
font-size: var(--font-size-base); font-size: 0.75rem;
color: var(--theme-text-secondary); color: var(--color-text-muted);
margin-bottom: 12px; margin-bottom: 0.5rem;
} }
.bundle-scopes { .bundle-scopes {
border-top: 1px solid var(--theme-border-primary); border-top: 1px solid var(--color-border-primary);
padding-top: 12px; padding-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
} }
.scopes-header { .scopes-header {
font-size: var(--font-size-xs); font-size: 0.5625rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
color: var(--theme-text-secondary); letter-spacing: 0.04em;
margin-bottom: 8px; color: var(--color-text-muted);
margin-right: 0.25rem;
} }
.scope-badge { .scope-badge {
display: inline-block; display: inline-block;
padding: 4px 8px; padding: 0.1rem 0.4rem;
margin: 2px; background: var(--color-surface-secondary);
background: var(--theme-bg-tertiary); border-radius: 999px;
border-radius: var(--radius-sm); font-size: 0.625rem;
font-size: var(--font-size-xs); font-family: var(--font-family-mono, monospace);
font-family: 'Monaco', 'Courier New', monospace; color: var(--color-text-secondary);
white-space: nowrap;
line-height: 1.4;
} }
.admin-form { .admin-form {
@@ -372,12 +432,6 @@ interface RoleBundle {
margin-bottom: 16px; margin-bottom: 16px;
} }
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: var(--font-weight-medium);
}
.form-group input[type="text"] { .form-group input[type="text"] {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
@@ -386,41 +440,73 @@ interface RoleBundle {
font-size: var(--font-size-base); font-size: var(--font-size-base);
} }
.scope-selector { .scope-tag-input {
max-height: 400px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
overflow-y: auto; background: var(--color-surface-tertiary); padding: 0.35rem 0.5rem;
border: 1px solid var(--theme-border-primary); min-height: 34px; cursor: text;
border-radius: var(--radius-sm); transition: border-color 0.12s, box-shadow 0.12s;
padding: 12px;
} }
.scope-tag-input:focus-within {
.scope-checkbox { border-color: var(--color-brand-primary);
display: flex; box-shadow: 0 0 0 1px var(--color-brand-primary-20);
align-items: center; background: var(--color-surface-primary);
padding: 8px;
cursor: pointer;
border-radius: var(--radius-sm);
} }
.scope-tag-input__tags { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
.scope-checkbox:hover { .scope-tag-input__field {
background: var(--theme-bg-hover); border: none; background: transparent; outline: none;
flex: 1; min-width: 140px; height: 24px;
font-size: 0.8125rem; color: var(--color-text-primary); padding: 0;
} }
.scope-tag-input__field::placeholder { color: var(--color-text-muted); font-size: 0.75rem; }
.scope-checkbox input[type="checkbox"] { .scope-tag {
margin-right: 12px; display: inline-flex; align-items: center; gap: 0.15rem;
padding: 0.1rem 0.15rem 0.1rem 0.4rem;
background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary);
border-radius: 999px; font-size: 0.625rem;
} }
.scope-tag code { font-family: var(--font-family-mono, monospace); color: var(--color-text-primary); white-space: nowrap; }
.scope-label { .scope-tag__remove {
flex: 1; display: flex; align-items: center; justify-content: center;
font-weight: var(--font-weight-medium); width: 16px; height: 16px; border: none; background: transparent;
color: var(--color-text-muted); cursor: pointer; font-size: 0.8rem;
border-radius: 50%; padding: 0;
} }
.scope-tag__remove:hover { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.scope-checkbox code { .scope-dropdown {
font-family: 'Monaco', 'Courier New', monospace; position: relative; margin-top: 0.25rem;
font-size: var(--font-size-sm); max-height: 300px; overflow-y: auto;
background: var(--theme-bg-tertiary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
padding: 2px 6px; background: var(--color-surface-elevated);
border-radius: var(--radius-sm); box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.scope-dropdown__group { border-bottom: 1px solid var(--color-border-primary); }
.scope-dropdown__group:last-child { border-bottom: none; }
.scope-dropdown__group-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.35rem 0.6rem; font-size: 0.625rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted); background: var(--color-surface-secondary);
}
.scope-dropdown__add-all {
font-size: 0.5625rem; font-weight: 600; text-transform: uppercase;
padding: 0.1rem 0.3rem; border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm); background: transparent;
color: var(--color-text-secondary); cursor: pointer;
}
.scope-dropdown__add-all:hover { background: var(--color-surface-tertiary); }
.scope-dropdown__item {
display: flex; align-items: center; gap: 0.5rem;
width: 100%; padding: 0.3rem 0.6rem; border: none;
background: transparent; cursor: pointer; text-align: left;
font-size: 0.75rem; color: var(--color-text-primary);
transition: background 0.08s;
}
.scope-dropdown__item:hover { background: var(--color-surface-tertiary); }
.scope-dropdown__label { flex: 1; color: var(--color-text-secondary); }
.scope-dropdown__id {
font-family: var(--font-family-mono, monospace); font-size: 0.6875rem;
color: var(--color-text-primary);
} }
.form-actions { .form-actions {
@@ -538,13 +624,31 @@ interface RoleBundle {
color: white; color: white;
} }
.loading,
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 48px; padding: 48px;
color: var(--theme-text-secondary); color: var(--theme-text-secondary);
} }
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; }
.skeleton-row { display: flex; gap: 0.75rem; padding: 0.6rem 0; }
.skeleton-cell { height: 14px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: skeleton-pulse 1.2s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.error-banner {
display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem;
background: var(--color-status-error-bg); border: 1px solid var(--color-status-error);
border-radius: var(--radius-md); color: var(--color-status-error-text); font-size: 0.8125rem;
margin-bottom: 16px;
}
.error-banner span { flex: 1; }
.error-banner__retry {
padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 600;
border: 1px solid var(--color-status-error); border-radius: var(--radius-sm);
background: transparent; color: var(--color-status-error-text); cursor: pointer;
}
.error-banner__retry:hover { background: var(--color-status-error); color: #fff; }
code { code {
font-family: 'Monaco', 'Courier New', monospace; font-family: 'Monaco', 'Courier New', monospace;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
@@ -556,6 +660,9 @@ export class RolesListComponent implements OnInit, OnDestroy {
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly pageAction = inject(PageActionService); private readonly pageAction = inject(PageActionService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly zone = inject(NgZone);
private readonly i18n = inject(I18nService);
activeTab: 'catalog' | 'custom' = 'catalog'; activeTab: 'catalog' | 'custom' = 'catalog';
catalogFilter = ''; catalogFilter = '';
@@ -644,6 +751,84 @@ export class RolesListComponent implements OnInit, OnDestroy {
readonly availableScopes = Object.keys(ScopeLabels).sort(); readonly availableScopes = Object.keys(ScopeLabels).sort();
scopeSearchQuery = '';
scopeDropdownOpen = false;
get scopeSuggestions(): string[] {
const query = this.scopeSearchQuery.toLowerCase().trim();
return this.availableScopes
.filter(s => !this.formData.selectedScopes.includes(s))
.filter(s => !query || s.includes(query) || ((ScopeLabels as Record<string, string>)[s] ?? '').toLowerCase().includes(query));
}
get groupedSuggestions(): { module: string; scopes: string[] }[] {
const groups = new Map<string, string[]>();
for (const scope of this.scopeSuggestions) {
const module = scope.split(':')[0] || scope.split('.')[0] || 'other';
if (!groups.has(module)) groups.set(module, []);
groups.get(module)!.push(scope);
}
return Array.from(groups.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([module, scopes]) => ({ module, scopes }));
}
addGroupScopes(group: { module: string; scopes: string[] }): void {
for (const scope of group.scopes) {
if (!this.formData.selectedScopes.includes(scope)) {
this.formData.selectedScopes.push(scope);
}
}
}
closeScopeDropdown(): void {
setTimeout(() => { this.scopeDropdownOpen = false; }, 200);
}
get scopeGroups(): { module: string; scopes: { id: string; action: string }[]; selectedCount: number; hasSelected: boolean }[] {
const groups = new Map<string, { id: string; action: string }[]>();
for (const scope of this.availableScopes) {
const parts = scope.split(':');
const module = parts[0] || 'other';
const action = parts.slice(1).join(':') || scope;
if (!groups.has(module)) groups.set(module, []);
groups.get(module)!.push({ id: scope, action });
}
return Array.from(groups.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([module, scopes]) => ({
module,
scopes,
selectedCount: scopes.filter(s => this.formData.selectedScopes.includes(s.id)).length,
hasSelected: scopes.some(s => this.formData.selectedScopes.includes(s.id)),
}));
}
get filteredScopeGroups() {
const query = this.scopeSearchQuery.toLowerCase().trim();
if (!query) return this.scopeGroups;
return this.scopeGroups
.map(g => ({
...g,
scopes: g.scopes.filter(s => s.id.includes(query) || s.action.includes(query) || g.module.includes(query)),
}))
.filter(g => g.scopes.length > 0);
}
toggleGroupAll(group: { module: string; scopes: { id: string; action: string }[]; selectedCount: number }, event: Event): void {
event.preventDefault();
event.stopPropagation();
const allSelected = group.selectedCount === group.scopes.length;
for (const scope of group.scopes) {
const idx = this.formData.selectedScopes.indexOf(scope.id);
if (allSelected && idx >= 0) {
this.formData.selectedScopes.splice(idx, 1);
} else if (!allSelected && idx < 0) {
this.formData.selectedScopes.push(scope.id);
}
}
}
get canWrite(): boolean { get canWrite(): boolean {
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_ROLES_WRITE); return this.auth.hasScope(StellaOpsScopes.AUTHORITY_ROLES_WRITE);
} }
@@ -658,6 +843,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
this.pageAction.set({ label: 'Add Role', action: () => this.showCreateForm() }); this.pageAction.set({ label: 'Add Role', action: () => this.showCreateForm() });
if (this.activeTab === 'custom') { if (this.activeTab === 'custom') {
this.loadCustomRoles(); this.loadCustomRoles();
setTimeout(() => { if (this.isLoading) { this.error = this.i18n.t('common.error.timeout') + ' ' + this.i18n.t('ui.error.service_unavailable'); this.isLoading = false; this.cdr.markForCheck(); } }, 15_000);
} }
} }
@@ -677,26 +863,38 @@ export class RolesListComponent implements OnInit, OnDestroy {
this.isLoading = true; this.isLoading = true;
this.error = null; this.error = null;
this.api.listRoles().subscribe({ this.api.listRoles().pipe(
next: (response) => { timeout(15_000),
this.customRoles = response.roles; catchError((err: any) => {
const reason = err.name === 'TimeoutError'
? this.i18n.t('common.error.timeout')
: err.status ? this.i18n.t('ui.error.http_status', { status: err.status })
: (err.message || this.i18n.t('common.error.generic'));
this.error = this.i18n.t('ui.error.load_failed', { resource: 'roles', reason });
this.isLoading = false; this.isLoading = false;
}, this.cdr.markForCheck();
error: (err) => { return of(null);
this.error = 'Failed to load custom roles: ' + (err.error?.message || err.message); })
this.isLoading = false; ).subscribe((response: any) => {
} if (response === null) return;
this.customRoles = Array.isArray(response) ? response : (response?.roles ?? []);
this.isLoading = false;
this.cdr.markForCheck();
}); });
} }
showCreateForm(): void { showCreateForm(): void {
this.isCreating = true; this.zone.run(() => {
this.editingRole = null; this.activeTab = 'custom';
this.formData = { this.isCreating = true;
roleId: '', this.editingRole = null;
displayName: '', this.formData = {
selectedScopes: [] roleId: '',
}; displayName: '',
selectedScopes: []
};
this.cdr.markForCheck();
});
} }
editRole(role: Role): void { editRole(role: Role): void {
@@ -707,6 +905,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
displayName: role.displayName, displayName: role.displayName,
selectedScopes: [...role.scopes] selectedScopes: [...role.scopes]
}; };
this.cdr.markForCheck();
} }
cancelForm(): void { cancelForm(): void {
@@ -717,6 +916,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
displayName: '', displayName: '',
selectedScopes: [] selectedScopes: []
}; };
this.cdr.markForCheck();
} }
toggleScope(scope: string): void { toggleScope(scope: string): void {
@@ -745,10 +945,12 @@ export class RolesListComponent implements OnInit, OnDestroy {
this.loadCustomRoles(); this.loadCustomRoles();
this.cancelForm(); this.cancelForm();
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to create role: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'create role', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
} }
}); });
} }
@@ -771,10 +973,12 @@ export class RolesListComponent implements OnInit, OnDestroy {
this.loadCustomRoles(); this.loadCustomRoles();
this.cancelForm(); this.cancelForm();
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to update role: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'update role', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
} }
}); });
} }
@@ -788,6 +992,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
if (!freshAuthOk) return; if (!freshAuthOk) return;
// Note: deleteRole API not yet implemented - show message for now // Note: deleteRole API not yet implemented - show message for now
this.error = 'Role deletion is not yet implemented in the backend.'; this.error = this.i18n.t('ui.error.load_failed', { resource: 'delete role', reason: 'Not yet implemented in the backend' });
this.cdr.markForCheck();
} }
} }

View File

@@ -1,8 +1,12 @@
import { Component, inject, OnInit, OnDestroy } from '@angular/core'; import { Component, inject, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { catchError, timeout } from 'rxjs/operators';
import { of } from 'rxjs';
import { ConsoleAdminApiService, Tenant } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, Tenant } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { PageActionService } from '../../../core/services/page-action.service'; import { PageActionService } from '../../../core/services/page-action.service';
import { I18nService } from '../../../core/i18n';
/** /**
* Tenants List Component * Tenants List Component
@@ -12,18 +16,52 @@ import { PageActionService } from '../../../core/services/page-action.service';
*/ */
@Component({ @Component({
selector: 'app-tenants-list', selector: 'app-tenants-list',
imports: [CommonModule], imports: [CommonModule, FormsModule],
template: ` template: `
<div class="admin-panel"> <div class="admin-panel">
<header class="admin-header"> <header class="admin-header">
<h1>Tenants</h1> <h1>Tenants</h1>
</header> </header>
@if (isCreating) {
<div class="admin-form">
<h2>Create Tenant</h2>
<div class="form-group">
<label for="tenantId">Tenant ID</label>
<input id="tenantId" type="text" [(ngModel)]="formData.id" placeholder="my-tenant" required>
</div>
<div class="form-group">
<label for="displayName">Display Name</label>
<input id="displayName" type="text" [(ngModel)]="formData.displayName" placeholder="My Tenant" required>
</div>
<div class="form-actions">
<button class="btn-primary" (click)="submitCreateTenant()" [disabled]="isSaving || !formData.id || !formData.displayName">
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
<button class="btn-secondary" (click)="cancelCreate()" [disabled]="isSaving">Cancel</button>
</div>
</div>
}
<div class="admin-content"> <div class="admin-content">
@if (loading) { @if (loading) {
<div class="loading">Loading tenants...</div> <div class="skeleton-list">
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:2"></div>
<div class="skeleton-cell" style="flex:0.8"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:0.7"></div>
</div>
}
</div>
} @else if (error) { } @else if (error) {
<div class="error">{{ error }}</div> <div class="error-banner">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{{ error }}</span>
<button type="button" class="error-banner__retry" (click)="loadTenants()">Retry</button>
</div>
} @else if (tenants.length === 0) { } @else if (tenants.length === 0) {
<div class="empty-state">No tenants configured.</div> <div class="empty-state">No tenants configured.</div>
} @else { } @else {
@@ -124,57 +162,148 @@ import { PageActionService } from '../../../core/services/page-action.service';
color: var(--color-status-error-text); color: var(--color-status-error-text);
} }
.loading, .error-banner {
.error, display: flex;
align-items: center;
gap: var(--space-3, 12px);
padding: var(--space-3, 12px) var(--space-4, 16px);
background: var(--color-status-error-bg, #fef2f2);
color: var(--color-status-error-text, #991b1b);
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-sm, 0.875rem);
}
.error-banner__retry {
appearance: none;
border: 1px solid currentColor;
border-radius: var(--radius-sm, 4px);
padding: var(--space-1, 4px) var(--space-3, 12px);
font-size: var(--font-size-sm, 0.875rem);
font-weight: var(--font-weight-medium, 500);
cursor: pointer;
color: inherit;
background: transparent;
margin-left: auto;
}
.error-banner__retry:hover {
background: rgba(0, 0, 0, 0.06);
}
.empty-state { .empty-state {
padding: 48px; padding: 48px;
text-align: center; text-align: center;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.error { .admin-form {
color: var(--color-status-error); background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px;
} }
.admin-form h2 { margin: 0 0 16px; font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 4px; font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); }
.form-group input { width: 100%; padding: 8px 12px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: var(--font-size-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
.form-actions { display: flex; gap: 8px; }
.btn-primary { padding: 8px 16px; border: none; border-radius: var(--radius-sm); font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); cursor: pointer; background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text, #fff); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { padding: 8px 16px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: var(--font-size-sm); cursor: pointer; background: transparent; color: var(--color-text-primary); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; }
.skeleton-row { display: flex; gap: 0.75rem; padding: 0.6rem 0; }
.skeleton-cell { height: 14px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: skeleton-pulse 1.2s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
`] `]
}) })
export class TenantsListComponent implements OnInit, OnDestroy { export class TenantsListComponent implements OnInit, OnDestroy {
private readonly api = inject(ConsoleAdminApiService); private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly pageAction = inject(PageActionService); private readonly pageAction = inject(PageActionService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly i18n = inject(I18nService);
tenants: Tenant[] = []; tenants: Tenant[] = [];
loading = true; loading = true;
error: string | null = null; error: string | null = null;
canWrite = false; // TODO: Check authority:tenants.write scope canWrite = false; // TODO: Check authority:tenants.write scope
isCreating = false;
isSaving = false;
formData = { id: '', displayName: '' };
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Add Tenant', action: () => this.createTenant() }); this.pageAction.set({ label: 'Add Tenant', action: () => this.showCreateForm() });
this.loadTenants(); this.loadTenants();
setTimeout(() => {
if (this.loading) {
this.error = this.i18n.t('common.error.timeout') + ' ' + this.i18n.t('ui.error.service_unavailable');
this.loading = false;
this.cdr.markForCheck();
}
}, 15_000);
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.pageAction.clear(); this.pageAction.clear();
} }
private loadTenants(): void { loadTenants(): void {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
this.api.listTenants().subscribe({ this.api.listTenants().pipe(
next: (response) => { timeout(15_000),
this.tenants = response.tenants; catchError((err) => {
const reason = err.name === 'TimeoutError'
? this.i18n.t('common.error.timeout')
: err.status ? this.i18n.t('ui.error.http_status', { status: err.status })
: (err.message || this.i18n.t('common.error.generic'));
this.error = this.i18n.t('ui.error.load_failed', { resource: 'tenants', reason });
this.loading = false; this.loading = false;
}, this.cdr.markForCheck();
error: (err) => { return of(null);
this.error = 'Failed to load tenants: ' + (err.message || 'Unknown error'); })
this.loading = false; ).subscribe((response: any) => {
} if (response === null) return;
this.tenants = Array.isArray(response) ? response : (response?.tenants ?? []);
this.loading = false;
this.cdr.markForCheck();
}); });
} }
createTenant(): void { showCreateForm(): void {
// Placeholder: would open create tenant dialog this.isCreating = true;
console.log('Create tenant dialog - implementation pending'); this.formData = { id: '', displayName: '' };
this.cdr.markForCheck();
}
cancelCreate(): void {
this.isCreating = false;
this.cdr.markForCheck();
}
async submitCreateTenant(): Promise<void> {
const freshAuthOk = await this.freshAuth.requireFreshAuth('Create tenant requires fresh authentication');
if (!freshAuthOk) return;
this.isSaving = true;
this.error = null;
this.cdr.markForCheck();
this.api.createTenant({ id: this.formData.id.trim(), displayName: this.formData.displayName.trim() }).subscribe({
next: () => {
this.isCreating = false;
this.isSaving = false;
this.loadTenants();
this.cdr.markForCheck();
},
error: (err) => {
this.error = this.i18n.t('ui.error.load_failed', { resource: 'create tenant', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false;
this.cdr.markForCheck();
}
});
} }
async suspendTenant(tenantId: string): Promise<void> { async suspendTenant(tenantId: string): Promise<void> {
@@ -189,7 +318,7 @@ export class TenantsListComponent implements OnInit, OnDestroy {
this.loadTenants(); this.loadTenants();
}, },
error: (err) => { error: (err) => {
this.error = 'Failed to suspend tenant: ' + (err.message || 'Unknown error'); this.error = this.i18n.t('ui.error.load_failed', { resource: 'suspend tenant', reason: err.message || this.i18n.t('common.error.generic') });
} }
}); });
} }
@@ -206,7 +335,7 @@ export class TenantsListComponent implements OnInit, OnDestroy {
this.loadTenants(); this.loadTenants();
}, },
error: (err) => { error: (err) => {
this.error = 'Failed to resume tenant: ' + (err.message || 'Unknown error'); this.error = this.i18n.t('ui.error.load_failed', { resource: 'resume tenant', reason: err.message || this.i18n.t('common.error.generic') });
} }
}); });
} }

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject, ChangeDetectorRef } from '@angular/core';
import { of } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, Token } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, Token } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
@@ -7,6 +8,7 @@ import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes'; import { StellaOpsScopes } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { TruncatePipe } from '../../../shared/pipes/truncate.pipe'; import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
import { I18nService } from '../../../core/i18n';
@Component({ @Component({
selector: 'app-tokens-list', selector: 'app-tokens-list',
@@ -33,11 +35,29 @@ import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
</header> </header>
@if (error) { @if (error) {
<div class="alert alert-error">{{ error }}</div> <div class="error-banner">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{{ error }}</span>
<button type="button" class="error-banner__retry" (click)="loadTokens()">Retry</button>
</div>
} }
@if (isLoading) { @if (isLoading) {
<div class="loading">Loading tokens...</div> <div class="skeleton-list">
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.2"></div>
<div class="skeleton-cell" style="flex:0.8"></div>
<div class="skeleton-cell" style="flex:1.2"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:0.7"></div>
<div class="skeleton-cell" style="flex:0.5"></div>
</div>
}
</div>
} @else if (tokens.length === 0) { } @else if (tokens.length === 0) {
<div class="empty-state">No tokens found</div> <div class="empty-state">No tokens found</div>
} @else { } @else {
@@ -358,13 +378,31 @@ import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
color: white; color: white;
} }
.loading,
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 48px; padding: 48px;
color: var(--theme-text-secondary); color: var(--theme-text-secondary);
} }
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; }
.skeleton-row { display: flex; gap: 0.75rem; padding: 0.6rem 0; }
.skeleton-cell { height: 14px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: skeleton-pulse 1.2s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.error-banner {
display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem;
background: var(--color-status-error-bg); border: 1px solid var(--color-status-error);
border-radius: var(--radius-md); color: var(--color-status-error-text); font-size: 0.8125rem;
margin-bottom: 16px;
}
.error-banner span { flex: 1; }
.error-banner__retry {
padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 600;
border: 1px solid var(--color-status-error); border-radius: var(--radius-sm);
background: transparent; color: var(--color-status-error-text); cursor: pointer;
}
.error-banner__retry:hover { background: var(--color-status-error); color: #fff; }
code { code {
font-family: 'Monaco', 'Courier New', monospace; font-family: 'Monaco', 'Courier New', monospace;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
@@ -375,6 +413,8 @@ export class TokensListComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService); private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly cdr = inject(ChangeDetectorRef);
private readonly i18n = inject(I18nService);
tokens: Token[] = []; tokens: Token[] = [];
isLoading = false; isLoading = false;
@@ -389,21 +429,31 @@ export class TokensListComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.loadTokens(); this.loadTokens();
setTimeout(() => { if (this.isLoading) { this.error = this.i18n.t('common.error.timeout') + ' ' + this.i18n.t('ui.error.service_unavailable'); this.isLoading = false; this.cdr.markForCheck(); } }, 15_000);
} }
loadTokens(): void { loadTokens(): void {
this.isLoading = true; this.isLoading = true;
this.error = null; this.error = null;
this.api.listTokens().subscribe({ this.api.listTokens().pipe(
next: (response) => { timeout(15_000),
this.tokens = this.applyFilters(response.tokens); catchError((err) => {
const reason = err.name === 'TimeoutError'
? this.i18n.t('common.error.timeout')
: err.status ? this.i18n.t('ui.error.http_status', { status: err.status })
: (err.message || this.i18n.t('common.error.generic'));
this.error = this.i18n.t('ui.error.load_failed', { resource: 'tokens', reason });
this.isLoading = false; this.isLoading = false;
}, this.cdr.markForCheck();
error: (err) => { return of(null);
this.error = 'Failed to load tokens: ' + (err.error?.message || err.message); })
this.isLoading = false; ).subscribe((response: any) => {
} if (response === null) return;
const raw = Array.isArray(response) ? response : (response?.tokens ?? []);
this.tokens = this.applyFilters(raw);
this.isLoading = false;
this.cdr.markForCheck();
}); });
} }
@@ -448,9 +498,11 @@ export class TokensListComponent implements OnInit {
if (token) { if (token) {
token.status = 'revoked'; token.status = 'revoked';
} }
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke token: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'revoke token', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }
@@ -473,9 +525,11 @@ export class TokensListComponent implements OnInit {
if (response.revokedCount < tokenIds.length) { if (response.revokedCount < tokenIds.length) {
this.error = `Revoked ${response.revokedCount} of ${tokenIds.length} tokens`; this.error = `Revoked ${response.revokedCount} of ${tokenIds.length} tokens`;
} }
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke tokens: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'revoke tokens', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }
@@ -501,9 +555,11 @@ export class TokensListComponent implements OnInit {
this.api.revokeTokens({ tokenIds }).subscribe({ this.api.revokeTokens({ tokenIds }).subscribe({
next: () => { next: () => {
matchingTokens.forEach(t => t.status = 'revoked'); matchingTokens.forEach(t => t.status = 'revoked');
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'revoke tokens', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }
@@ -529,9 +585,11 @@ export class TokensListComponent implements OnInit {
this.api.revokeTokens({ tokenIds }).subscribe({ this.api.revokeTokens({ tokenIds }).subscribe({
next: () => { next: () => {
matchingTokens.forEach(t => t.status = 'revoked'); matchingTokens.forEach(t => t.status = 'revoked');
this.cdr.markForCheck();
}, },
error: (err: { error?: { message?: string }; message?: string }) => { error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'revoke tokens', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, inject, ChangeDetectorRef } from '@angular/core';
import { of } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, User } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, User } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
@@ -7,6 +8,7 @@ import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes'; import { StellaOpsScopes } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { PageActionService } from '../../../core/services/page-action.service'; import { PageActionService } from '../../../core/services/page-action.service';
import { I18nService } from '../../../core/i18n';
@Component({ @Component({
selector: 'app-users-list', selector: 'app-users-list',
@@ -18,7 +20,11 @@ import { PageActionService } from '../../../core/services/page-action.service';
</header> </header>
@if (error) { @if (error) {
<div class="alert alert-error">{{ error }}</div> <div class="error-banner">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{{ error }}</span>
<button type="button" class="error-banner__retry" (click)="loadUsers()">Retry</button>
</div>
} }
@if (isCreating || editingUser) { @if (isCreating || editingUser) {
@@ -51,11 +57,25 @@ import { PageActionService } from '../../../core/services/page-action.service';
required> required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Roles (comma-separated)</label> <label>Roles</label>
<input <div class="chip-input">
type="text" <div class="chip-input__chips">
[(ngModel)]="formData.rolesInput" @for (role of parsedRoles; track role) {
placeholder="role/scanner-viewer,role/policy-operator"> <span class="chip">
<code class="chip__text">{{ role }}</code>
<button type="button" class="chip__remove" (click)="removeRole(role)" title="Remove">&times;</button>
</span>
}
<input
type="text"
class="chip-input__field"
[(ngModel)]="newRoleInput"
(keydown.enter)="addRole($event)"
(keydown.comma)="addRole($event)"
placeholder="Type role and press Enter">
</div>
</div>
<span class="form-hint">e.g. role/scanner-viewer, role/policy-operator</span>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button <button
@@ -75,7 +95,18 @@ import { PageActionService } from '../../../core/services/page-action.service';
} }
@if (isLoading) { @if (isLoading) {
<div class="loading">Loading users...</div> <div class="skeleton-list">
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:2"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:0.7"></div>
<div class="skeleton-cell" style="flex:0.5"></div>
</div>
}
</div>
} @else if (users.length === 0 && !isCreating) { } @else if (users.length === 0 && !isCreating) {
<div class="empty-state">No users found</div> <div class="empty-state">No users found</div>
} @else { } @else {
@@ -178,25 +209,6 @@ import { PageActionService } from '../../../core/services/page-action.service';
margin-bottom: 16px; margin-bottom: 16px;
} }
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: var(--font-weight-medium);
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--theme-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
}
.form-group input:disabled {
background: var(--theme-bg-tertiary);
cursor: not-allowed;
}
.form-actions { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -339,13 +351,31 @@ import { PageActionService } from '../../../core/services/page-action.service';
color: white; color: white;
} }
.loading,
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 48px; padding: 48px;
color: var(--theme-text-secondary); color: var(--theme-text-secondary);
} }
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; }
.skeleton-row { display: flex; gap: 0.75rem; padding: 0.6rem 0; }
.skeleton-cell { height: 14px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: skeleton-pulse 1.2s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.error-banner {
display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem;
background: var(--color-status-error-bg); border: 1px solid var(--color-status-error);
border-radius: var(--radius-md); color: var(--color-status-error-text); font-size: 0.8125rem;
margin-bottom: 16px;
}
.error-banner span { flex: 1; }
.error-banner__retry {
padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 600;
border: 1px solid var(--color-status-error); border-radius: var(--radius-sm);
background: transparent; color: var(--color-status-error-text); cursor: pointer;
}
.error-banner__retry:hover { background: var(--color-status-error); color: #fff; }
code { code {
font-family: 'Monaco', 'Courier New', monospace; font-family: 'Monaco', 'Courier New', monospace;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
@@ -353,6 +383,17 @@ import { PageActionService } from '../../../core/services/page-action.service';
padding: 2px 4px; padding: 2px 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.chip-input { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-tertiary); padding: 0.35rem 0.5rem; min-height: 34px; cursor: text; transition: border-color 0.12s, box-shadow 0.12s; }
.chip-input:focus-within { border-color: var(--color-brand-primary); box-shadow: 0 0 0 1px var(--color-brand-primary-20); background: var(--color-surface-primary); }
.chip-input__chips { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
.chip-input__field { border: none; background: transparent; outline: none; flex: 1; min-width: 120px; height: 24px; font-size: 0.8125rem; color: var(--color-text-primary); padding: 0; }
.chip-input__field::placeholder { color: var(--color-text-muted); font-size: 0.75rem; }
.chip { display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.1rem 0.15rem 0.1rem 0.4rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 0.6875rem; }
.chip__text { font-family: var(--font-family-mono, monospace); color: var(--color-text-primary); }
.chip__remove { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; font-size: 0.875rem; border-radius: 2px; padding: 0; }
.chip__remove:hover { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.form-hint { font-size: 0.75rem; color: var(--color-text-muted); margin-top: 0.25rem; display: block; }
`] `]
}) })
export class UsersListComponent implements OnInit, OnDestroy { export class UsersListComponent implements OnInit, OnDestroy {
@@ -360,6 +401,8 @@ export class UsersListComponent implements OnInit, OnDestroy {
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly pageAction = inject(PageActionService); private readonly pageAction = inject(PageActionService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly i18n = inject(I18nService);
users: User[] = []; users: User[] = [];
isLoading = false; isLoading = false;
@@ -376,6 +419,31 @@ export class UsersListComponent implements OnInit, OnDestroy {
rolesInput: '' rolesInput: ''
}; };
newRoleInput = '';
get parsedRoles(): string[] {
return this.formData.rolesInput
.split(',')
.map(r => r.trim())
.filter(r => r.length > 0);
}
addRole(event: Event): void {
event.preventDefault();
const role = this.newRoleInput.trim().replace(/,$/, '');
if (role && !this.parsedRoles.includes(role)) {
const roles = this.parsedRoles;
roles.push(role);
this.formData.rolesInput = roles.join(',');
}
this.newRoleInput = '';
}
removeRole(role: string): void {
const roles = this.parsedRoles.filter(r => r !== role);
this.formData.rolesInput = roles.join(',');
}
get canWrite(): boolean { get canWrite(): boolean {
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_USERS_WRITE); return this.auth.hasScope(StellaOpsScopes.AUTHORITY_USERS_WRITE);
} }
@@ -383,6 +451,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Add User', action: () => this.showCreateForm() }); this.pageAction.set({ label: 'Add User', action: () => this.showCreateForm() });
this.loadUsers(); this.loadUsers();
setTimeout(() => { if (this.isLoading) { this.error = this.i18n.t('common.error.timeout') + ' ' + this.i18n.t('ui.error.service_unavailable'); this.isLoading = false; this.cdr.markForCheck(); } }, 15_000);
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -393,15 +462,23 @@ export class UsersListComponent implements OnInit, OnDestroy {
this.isLoading = true; this.isLoading = true;
this.error = null; this.error = null;
this.api.listUsers().subscribe({ this.api.listUsers().pipe(
next: (response) => { timeout(15_000),
this.users = response.users; catchError((err) => {
const reason = err.name === 'TimeoutError'
? this.i18n.t('common.error.timeout')
: err.status ? this.i18n.t('ui.error.http_status', { status: err.status })
: (err.message || this.i18n.t('common.error.generic'));
this.error = this.i18n.t('ui.error.load_failed', { resource: 'users', reason });
this.isLoading = false; this.isLoading = false;
}, this.cdr.markForCheck();
error: (err) => { return of(null);
this.error = 'Failed to load users: ' + (err.error?.message || err.message); })
this.isLoading = false; ).subscribe((response: any) => {
} if (response === null) return;
this.users = Array.isArray(response) ? response : (response?.users ?? []);
this.isLoading = false;
this.cdr.markForCheck();
}); });
} }
@@ -414,6 +491,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
tenantId: '', tenantId: '',
rolesInput: '' rolesInput: ''
}; };
this.cdr.markForCheck();
} }
editUser(user: User): void { editUser(user: User): void {
@@ -425,6 +503,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
tenantId: user.tenantId ?? '', tenantId: user.tenantId ?? '',
rolesInput: user.roles.join(',') rolesInput: user.roles.join(',')
}; };
this.cdr.markForCheck();
} }
cancelForm(): void { cancelForm(): void {
@@ -436,6 +515,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
tenantId: '', tenantId: '',
rolesInput: '' rolesInput: ''
}; };
this.cdr.markForCheck();
} }
async createUser(): Promise<void> { async createUser(): Promise<void> {
@@ -461,10 +541,12 @@ export class UsersListComponent implements OnInit, OnDestroy {
this.loadUsers(); this.loadUsers();
this.cancelForm(); this.cancelForm();
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
}, },
error: (err) => { error: (err) => {
this.error = 'Failed to create user: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'create user', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
} }
}); });
} }
@@ -492,10 +574,12 @@ export class UsersListComponent implements OnInit, OnDestroy {
this.loadUsers(); this.loadUsers();
this.cancelForm(); this.cancelForm();
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
}, },
error: (err) => { error: (err) => {
this.error = 'Failed to update user: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'update user', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.isSaving = false; this.isSaving = false;
this.cdr.markForCheck();
} }
}); });
} }
@@ -510,9 +594,11 @@ export class UsersListComponent implements OnInit, OnDestroy {
if (user) { if (user) {
user.status = 'disabled'; user.status = 'disabled';
} }
this.cdr.markForCheck();
}, },
error: (err) => { error: (err) => {
this.error = 'Failed to disable user: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'disable user', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }
@@ -527,9 +613,11 @@ export class UsersListComponent implements OnInit, OnDestroy {
if (user) { if (user) {
user.status = 'active'; user.status = 'active';
} }
this.cdr.markForCheck();
}, },
error: (err) => { error: (err) => {
this.error = 'Failed to enable user: ' + (err.error?.message || err.message); this.error = this.i18n.t('ui.error.load_failed', { resource: 'enable user', reason: err.error?.message || err.message || this.i18n.t('common.error.generic') });
this.cdr.markForCheck();
} }
}); });
} }

View File

@@ -569,14 +569,6 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
gap: 0.5rem; gap: 0.5rem;
} }
.filter-group label { font-size: 0.875rem; color: var(--color-text-secondary); }
.filter-group select, .filter-group input {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
}
/* Table */ /* Table */
.data-table { width: 100%; border-collapse: collapse; } .data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { .data-table th, .data-table td {

View File

@@ -339,19 +339,6 @@ import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operation
gap: 0.25rem; gap: 0.25rem;
} }
.filter-group label {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.filter-group select,
.filter-group input {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
}
.filter-actions { .filter-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -70,6 +70,7 @@ interface DeploymentArtifact {
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setTab($event)" (tabChange)="setTab($event)"
> >
@switch (activeTab()) { @switch (activeTab()) {

View File

@@ -38,6 +38,15 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
</div> </div>
} }
@if (remediation.runbookUrl) {
<a class="article-link" [href]="resolveArticleUrl(remediation.runbookUrl)" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
Read full troubleshooting article
</a>
}
<div class="fix-steps"> <div class="fix-steps">
@for (step of remediation.steps; track step.order) { @for (step of remediation.steps; track step.order) {
<div class="step"> <div class="step">
@@ -147,6 +156,25 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
} }
} }
.article-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-sm);
color: var(--color-brand-primary);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
transition: background 150ms ease, color 150ms ease;
}
.article-link:hover {
background: var(--color-brand-primary);
color: var(--color-surface-primary);
}
.safety-note { .safety-note {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -321,4 +349,10 @@ export class RemediationPanelComponent {
runFixLabel(): string { runFixLabel(): string {
return this.ranFix() ? 'Fix Commands Copied' : 'Run Fix'; return this.ranFix() ? 'Fix Commands Copied' : 'Run Fix';
} }
resolveArticleUrl(path: string): string {
// Relative doc paths are served at the docs viewer base
if (path.startsWith('http')) return path;
return '/docs/' + path.replace(/\.md$/, '');
}
} }

View File

@@ -455,6 +455,18 @@
border-top: 1px solid var(--color-border-primary); border-top: 1px solid var(--color-border-primary);
} }
&__check-list--boot {
display: flex;
flex-direction: column;
gap: 0;
background: var(--color-surface-primary);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-primary);
padding: 0.35rem 0;
font-family: var(--font-family-mono, ui-monospace, monospace);
font-size: 0.72rem;
}
&__check-id { &__check-id {
display: inline-block; display: inline-block;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
@@ -473,6 +485,98 @@
} }
} }
/* ─── Boot screen lines ─── */
.boot-entry {
&--expanded {
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
}
}
.boot-detail {
padding: 0.5rem 0.6rem 0.75rem 1.8rem;
border-top: 1px solid var(--color-border-primary);
}
.boot-line {
display: flex;
align-items: center;
padding: 0.18rem 0.6rem;
gap: 0.5rem;
transition: background 120ms ease;
&--clickable {
cursor: pointer;
}
&:hover {
background: var(--color-surface-secondary);
}
&__chevron {
flex-shrink: 0;
color: var(--color-text-muted);
transition: transform 150ms ease;
&--open { transform: rotate(90deg); }
}
&__id {
flex-shrink: 0;
color: var(--color-text-secondary);
white-space: nowrap;
}
&__dots {
flex: 1;
min-width: 1rem;
border-bottom: 1px dotted var(--color-border-primary);
height: 0;
margin-bottom: 0.2em;
}
&__tag {
flex-shrink: 0;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.05rem 0.35rem;
border-radius: var(--radius-sm);
font-size: 0.65rem;
text-align: center;
min-width: 3rem;
&--ok {
color: var(--color-status-success-text, #166534);
background: var(--color-status-success-bg, rgba(22, 163, 74, 0.1));
}
&--fail {
color: var(--color-status-error-text, #991b1b);
background: var(--color-status-error-bg, rgba(220, 38, 38, 0.1));
}
&--warn {
color: var(--color-status-warning-text, #92400e);
background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.1));
}
&--info {
color: var(--color-status-info-text, #1e40af);
background: var(--color-status-info-bg, rgba(59, 130, 246, 0.1));
}
&--skip {
color: var(--color-text-muted);
background: var(--color-surface-secondary);
}
&--idle {
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
}
}
}
// ── Filter Bar ── // ── Filter Bar ──
.filter-bar { .filter-bar {
display: flex; display: flex;
@@ -535,10 +639,11 @@
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: var(--font-weight-medium);
cursor: pointer; cursor: pointer;
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary); background: var(--color-surface-primary);
transition: background 0.15s ease, border-color 0.15s ease; transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
input { display: none; } input { display: none; }
@@ -546,19 +651,67 @@
border-color: var(--color-border-secondary); border-color: var(--color-border-secondary);
} }
&--active { // Before a run: plain text colored by severity
border-color: var(--color-brand-primary);
background: var(--color-brand-primary);
span {
color: var(--color-text-inverse, #fff) !important;
}
}
&.severity-fail span { color: var(--color-status-error); } &.severity-fail span { color: var(--color-status-error); }
&.severity-warn span { color: var(--color-status-warning); } &.severity-warn span { color: var(--color-status-warning); }
&.severity-pass span { color: var(--color-status-success); } &.severity-pass span { color: var(--color-status-success); }
&.severity-info span { color: var(--color-status-info); } &.severity-info span { color: var(--color-status-info); }
// After a run: severity-colored border + background tint
&--has-count {
&.severity-fail { border-color: var(--color-status-error); background: var(--color-status-error-bg); }
&.severity-warn { border-color: var(--color-status-warning); background: var(--color-status-warning-bg); }
&.severity-pass { border-color: var(--color-status-success); background: var(--color-status-success-bg); }
&.severity-info { border-color: var(--color-status-info, #3b82f6); background: var(--color-status-info-bg, rgba(59,130,246,0.08)); }
}
// Zero count: dimmed
&--zero {
opacity: 0.5;
border-color: var(--color-border-primary) !important;
background: var(--color-surface-primary) !important;
}
// Active filter: solid fill
&--active {
&.severity-fail { background: var(--color-status-error); border-color: var(--color-status-error); span, .severity-chip__count { color: #fff !important; } }
&.severity-warn { background: var(--color-status-warning); border-color: var(--color-status-warning); span, .severity-chip__count { color: #fff !important; } }
&.severity-pass { background: var(--color-status-success); border-color: var(--color-status-success); span, .severity-chip__count { color: #fff !important; } }
&.severity-info { background: var(--color-status-info, #3b82f6); border-color: var(--color-status-info, #3b82f6); span, .severity-chip__count { color: #fff !important; } }
opacity: 1;
}
&__count {
font-weight: var(--font-weight-bold);
font-size: 0.8rem;
min-width: 1ch;
}
&.severity-fail .severity-chip__count { color: var(--color-status-error); }
&.severity-warn .severity-chip__count { color: var(--color-status-warning); }
&.severity-pass .severity-chip__count { color: var(--color-status-success); }
&.severity-info .severity-chip__count { color: var(--color-status-info, #3b82f6); }
}
// Inline status indicator (compact, in the filter bar)
.filter-bar__status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-left: auto;
flex-shrink: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.filter-bar__status-icon {
flex-shrink: 0;
&--ok { color: var(--color-status-success); }
&--issue { color: var(--color-status-error); }
}
.filter-bar__status-text {
&--ok { color: var(--color-status-success); }
&--issue { color: var(--color-status-error); }
} }
.filter-bar__clear { .filter-bar__clear {

View File

@@ -4,13 +4,13 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DoctorStore } from './services/doctor.store'; import { DoctorStore } from './services/doctor.store';
import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from './models/doctor.models'; import { CheckResult, CheckMetadata, DoctorCategory, DoctorPluginGroup, DoctorSeverity, RunDoctorRequest } from './models/doctor.models';
import { SummaryStripComponent } from './components/summary-strip/summary-strip.component';
import { CheckResultComponent } from './components/check-result/check-result.component'; import { CheckResultComponent } from './components/check-result/check-result.component';
import { ExportDialogComponent } from './components/export-dialog/export-dialog.component'; import { ExportDialogComponent } from './components/export-dialog/export-dialog.component';
import { AppConfigService } from '../../core/config/app-config.service'; import { AppConfigService } from '../../core/config/app-config.service';
import { PageActionService } from '../../core/services/page-action.service'; import { PageActionService } from '../../core/services/page-action.service';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [ const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [
{ id: 'all', label: 'All', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, { id: 'all', label: 'All', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' },
@@ -26,10 +26,10 @@ const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [
selector: 'st-doctor-dashboard', selector: 'st-doctor-dashboard',
imports: [ imports: [
FormsModule, FormsModule,
SummaryStripComponent,
CheckResultComponent, CheckResultComponent,
ExportDialogComponent, ExportDialogComponent,
StellaPageTabsComponent, StellaPageTabsComponent,
PageActionOutletComponent,
], ],
templateUrl: './doctor-dashboard.component.html', templateUrl: './doctor-dashboard.component.html',
styleUrl: './doctor-dashboard.component.scss' styleUrl: './doctor-dashboard.component.scss'
@@ -45,7 +45,7 @@ export class DoctorDashboardComponent implements OnInit, OnDestroy {
readonly showExportDialog = signal(false); readonly showExportDialog = signal(false);
readonly selectedResult = signal<CheckResult | null>(null); readonly selectedResult = signal<CheckResult | null>(null);
readonly activeTab = signal<DoctorCategory | 'all'>('all'); readonly activeTab = signal<DoctorCategory | 'all'>('all');
readonly activePackTab = signal<string>(''); readonly activePackTab = signal<string>('all');
readonly doctorCategoryTabs = DOCTOR_CATEGORY_TABS; readonly doctorCategoryTabs = DOCTOR_CATEGORY_TABS;
readonly categories: { value: DoctorCategory | null; label: string }[] = [ readonly categories: { value: DoctorCategory | null; label: string }[] = [
@@ -75,16 +75,116 @@ export class DoctorDashboardComponent implements OnInit, OnDestroy {
{ value: 'info', label: 'Info', class: 'severity-info' }, { value: 'info', label: 'Info', class: 'severity-info' },
]; ];
/** Per-severity counts from the latest report (null = no report yet) */
readonly severityCounts = computed<Record<DoctorSeverity, number> | null>(() => {
const summary = this.store.summary();
if (!summary) return null;
return {
fail: summary.failed,
warn: summary.warnings,
pass: summary.passed,
info: summary.info,
skip: summary.skipped,
};
});
/** Get the count for a severity chip (empty string before a run) */
severityCount(sev: DoctorSeverity): number | null {
const counts = this.severityCounts();
if (!counts) return null;
return counts[sev];
}
constructor() { constructor() {
// Auto-select first pack tab when pack groups load // Auto-select 'all' pack tab when pack groups load
effect(() => { effect(() => {
const groups = this.store.packGroups(); const groups = this.store.packGroups();
if (groups.length > 0 && !this.activePackTab()) { if (groups.length > 0 && !this.activePackTab()) {
this.activePackTab.set(groups[0].category); this.activePackTab.set('all');
} }
}); });
} }
private readonly packIconMap: Record<string, string> = Object.fromEntries(
DOCTOR_CATEGORY_TABS.map(t => [t.id, t.icon])
);
private readonly defaultPackIcon = 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8';
/** Tabs for the pack section — severity-aware badges */
readonly packTabs = computed<StellaPageTab[]>(() => {
const groups = this.store.packGroups();
const statusMap = this.checkStatusMap();
const buildTab = (id: string, label: string, checks: { checkId: string }[]): StellaPageTab => {
const icon = this.packIconMap[id] ?? this.defaultPackIcon;
if (statusMap.size === 0) {
// No report yet — badge = total check count
return { id, label, icon, badge: checks.length };
}
// Count severities for this tab's checks
let fails = 0, warns = 0, infos = 0, passes = 0;
for (const c of checks) {
const sev = statusMap.get(c.checkId);
if (sev === 'fail') fails++;
else if (sev === 'warn') warns++;
else if (sev === 'info') infos++;
else if (sev === 'pass') passes++;
}
let badge: number | undefined;
let status: 'ok' | 'warn' | 'error' | undefined;
if (fails > 0) { badge = fails + warns; status = 'error'; }
else if (warns > 0) { badge = warns; status = 'warn'; }
else if (infos > 0) { badge = infos; status = undefined; }
else if (passes > 0) { badge = passes; status = 'ok'; }
return { id, label, icon, badge, status };
};
const allChecks = groups.flatMap(g => g.plugins.flatMap(p => p.checks));
const tabs: StellaPageTab[] = [buildTab('all', 'All', allChecks)];
for (const g of groups) {
const catChecks = g.plugins.flatMap(p => p.checks);
tabs.push(buildTab(g.category, g.label, catChecks));
}
return tabs;
});
/** Plugins visible for the active pack tab, filtered by search + severity */
readonly visiblePackPlugins = computed<DoctorPluginGroup[]>(() => {
const tab = this.activePackTab();
const groups = this.store.packGroups();
const query = this.store.searchQuery().toLowerCase().trim();
const sevFilter = this.store.severityFilter();
const statusMap = this.checkStatusMap();
let plugins: DoctorPluginGroup[];
if (tab === 'all' || !tab) {
plugins = groups.flatMap(g => g.plugins);
} else {
plugins = groups.find(g => g.category === tab)?.plugins ?? [];
}
// Text search filter
if (query) {
plugins = plugins.filter(p =>
p.displayName.toLowerCase().includes(query) ||
p.pluginId.toLowerCase().includes(query) ||
p.checks.some(c => c.checkId.toLowerCase().includes(query))
);
}
// Severity filter — hide plugins with 0 matching checks
if (sevFilter.length > 0 && statusMap.size > 0) {
plugins = plugins.filter(p =>
p.checks.some(c => {
const sev = statusMap.get(c.checkId);
return sev ? sevFilter.includes(sev) : false;
})
);
}
return plugins;
});
readonly doctorTabsWithStatus = computed<readonly StellaPageTab[]>(() => { readonly doctorTabsWithStatus = computed<readonly StellaPageTab[]>(() => {
return DOCTOR_CATEGORY_TABS.map(tab => { return DOCTOR_CATEGORY_TABS.map(tab => {
const catStatus = this.getCategoryStatus(tab.id as DoctorCategory | 'all'); const catStatus = this.getCategoryStatus(tab.id as DoctorCategory | 'all');
@@ -212,6 +312,68 @@ export class DoctorDashboardComponent implements OnInit, OnDestroy {
this.activeTab.set('all'); this.activeTab.set('all');
} }
/** Map of checkId → severity from the latest report (empty before a run) */
readonly checkStatusMap = computed<Map<string, DoctorSeverity>>(() => {
const report = this.store.report();
if (!report) return new Map();
const map = new Map<string, DoctorSeverity>();
for (const r of report.results) {
map.set(r.checkId, r.severity);
}
return map;
});
/** Get the severity label for a check from the latest report */
checkStatus(checkId: string): DoctorSeverity | null {
return this.checkStatusMap().get(checkId) ?? null;
}
/** Map of checkId → full CheckResult from the latest report */
readonly checkResultMap = computed<Map<string, CheckResult>>(() => {
const report = this.store.report();
if (!report) return new Map();
const map = new Map<string, CheckResult>();
for (const r of report.results) {
map.set(r.checkId, r);
}
return map;
});
/** Get the full CheckResult for a check (null if not yet run) */
checkResult(checkId: string): CheckResult | null {
return this.checkResultMap().get(checkId) ?? null;
}
/** Track which boot-line check is expanded */
readonly expandedCheckId = signal<string | null>(null);
toggleCheck(checkId: string): void {
this.expandedCheckId.set(this.expandedCheckId() === checkId ? null : checkId);
}
/** Filter checks within a plugin by search query AND severity filter */
filteredChecks(plugin: DoctorPluginGroup): CheckMetadata[] {
const query = this.store.searchQuery().toLowerCase().trim();
const sevFilter = this.store.severityFilter();
const statusMap = this.checkStatusMap();
let checks = plugin.checks;
if (query) {
checks = checks.filter(c => c.checkId.toLowerCase().includes(query));
}
// Apply severity filter only if a report exists and filter is active
if (sevFilter.length > 0 && statusMap.size > 0) {
checks = checks.filter(c => {
const sev = statusMap.get(c.checkId);
return sev ? sevFilter.includes(sev) : false;
});
}
return checks;
}
trackResult(_index: number, result: CheckResult): string { trackResult(_index: number, result: CheckResult): string {
return result.checkId; return result.checkId;
} }

View File

@@ -94,6 +94,7 @@ export interface Evidence {
export interface Remediation { export interface Remediation {
requiresBackup: boolean; requiresBackup: boolean;
safetyNote?: string; safetyNote?: string;
runbookUrl?: string;
steps: RemediationStep[]; steps: RemediationStep[];
} }

View File

@@ -57,6 +57,7 @@ interface GateSummary {
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setTab($any($event))" (tabChange)="setTab($any($event))"
> >
@switch (activeTab()) { @switch (activeTab()) {

View File

@@ -58,6 +58,7 @@ const EXPORT_CENTER_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="exportCenterTabs" [tabs]="exportCenterTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Export center tabs" ariaLabel="Export center tabs"
/> />

View File

@@ -199,9 +199,6 @@ interface EvidencePacket {
.filter-bar__input { .filter-bar__input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem; padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
} }
.filter-bar__select { .filter-bar__select {
padding: 0.5rem 2rem 0.5rem 0.75rem; padding: 0.5rem 2rem 0.5rem 0.75rem;

View File

@@ -83,6 +83,7 @@ const PACKET_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="PACKET_TABS" [tabs]="PACKET_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setTab($any($event))" (tabChange)="setTab($any($event))"
ariaLabel="Evidence packet tabs" ariaLabel="Evidence packet tabs"
> >

View File

@@ -282,32 +282,8 @@
gap: var(--space-1-5); gap: var(--space-1-5);
} }
.field-input {
padding: var(--space-2-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: 2px solid var(--color-brand-primary);
outline-offset: -1px;
}
}
.field-select { .field-select {
padding: var(--space-2-5) var(--space-3); cursor: pointer;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: 2px solid var(--color-brand-primary);
outline-offset: -1px;
}
} }
.severity-options { .severity-options {

View File

@@ -24,6 +24,7 @@ import { FeedVersionLockComponent } from './feed-version-lock.component';
import { SyncStatusIndicatorComponent } from './sync-status-indicator.component'; import { SyncStatusIndicatorComponent } from './sync-status-indicator.component';
import { FreshnessWarningsComponent } from './freshness-warnings.component'; import { FreshnessWarningsComponent } from './freshness-warnings.component';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
type TabMode = 'mirrors' | 'airgap' | 'version-locks'; type TabMode = 'mirrors' | 'airgap' | 'version-locks';
@@ -54,6 +55,7 @@ const FEED_MIRROR_TABS: readonly StellaPageTab[] = [
SyncStatusIndicatorComponent, SyncStatusIndicatorComponent,
FreshnessWarningsComponent, FreshnessWarningsComponent,
StellaPageTabsComponent, StellaPageTabsComponent,
PageActionOutletComponent,
], ],
template: ` template: `
<section class="feed-mirror-dashboard"> <section class="feed-mirror-dashboard">
@@ -85,9 +87,11 @@ const FEED_MIRROR_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="FEED_MIRROR_TABS" [tabs]="FEED_MIRROR_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setActiveTab($any($event))" (tabChange)="setActiveTab($any($event))"
ariaLabel="Feed mirror sections" ariaLabel="Feed mirror sections"
> >
<app-page-action-outlet tabBarAction />
<!-- Summary Stats --> <!-- Summary Stats -->
@if (activeTab() === 'mirrors' && !loading()) { @if (activeTab() === 'mirrors' && !loading()) {

View File

@@ -154,6 +154,7 @@ const SIDE_PANEL_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="SIDE_PANEL_TABS" [tabs]="SIDE_PANEL_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="setActiveTab($any($event))" (tabChange)="setActiveTab($any($event))"
ariaLabel="Graph side panels" ariaLabel="Graph side panels"
> >

View File

@@ -191,11 +191,6 @@ export type ActivityEventType =
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.filter-group label {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-transform: uppercase;
}
.filter-select, .filter-input { .filter-select, .filter-input {
padding: 0.5rem; padding: 0.5rem;

View File

@@ -76,6 +76,7 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="HUB_DETAIL_TABS" [tabs]="HUB_DETAIL_TABS"
[activeTab]="activeTab" [activeTab]="activeTab"
urlParam="tab"
(tabChange)="activeTab = $any($event)" (tabChange)="activeTab = $any($event)"
ariaLabel="Integration detail tabs" ariaLabel="Integration detail tabs"
/> />

View File

@@ -37,6 +37,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
ariaLabel="Integration tabs" ariaLabel="Integration tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >

View File

@@ -197,27 +197,6 @@
} }
} }
input,
textarea,
select {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xs);
font-size: var(--font-size-base);
&:focus {
outline: none;
border-color: var(--color-brand-primary);
}
}
textarea {
resize: vertical;
font-family: inherit;
}
.field-hint { .field-hint {
display: block; display: block;
margin-top: var(--space-1); margin-top: var(--space-1);

View File

@@ -106,13 +106,6 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.form-group label {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 0.375rem;
}
.form-input { .form-input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;

View File

@@ -153,13 +153,6 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.form-group label {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 0.375rem;
}
.form-input { .form-input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;

View File

@@ -76,6 +76,7 @@ export interface MobileSection {
<stella-page-tabs <stella-page-tabs
[tabs]="MOBILE_COMPARE_TABS" [tabs]="MOBILE_COMPARE_TABS"
[activeTab]="currentView()" [activeTab]="currentView()"
urlParam="tab"
(tabChange)="setView($any($event))" (tabChange)="setView($any($event))"
ariaLabel="Mobile compare views" ariaLabel="Mobile compare views"
/> />

View File

@@ -92,6 +92,7 @@ const VEX_DIFF_TABS: StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="VEX_DIFF_TABS" [tabs]="VEX_DIFF_TABS"
[activeTab]="activeGroup()" [activeTab]="activeGroup()"
urlParam="tab"
(tabChange)="activeGroup.set($any($event))" (tabChange)="activeGroup.set($any($event))"
ariaLabel="VEX diff groups" ariaLabel="VEX diff groups"
/> />

View File

@@ -48,9 +48,11 @@
<stella-page-tabs <stella-page-tabs
[tabs]="notifyTabs" [tabs]="notifyTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Notification operations tabs" ariaLabel="Notification operations tabs"
> >
<app-page-action-outlet tabBarAction />
@switch (activeTab()) { @switch (activeTab()) {
<!-- ═══ Channels Tab ═══ --> <!-- ═══ Channels Tab ═══ -->
@case ('channels') { @case ('channels') {

View File

@@ -35,6 +35,7 @@ import {
StellaPageTabsComponent, StellaPageTabsComponent,
StellaPageTab, StellaPageTab,
} from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
type NotifyTab = 'channels' | 'rules' | 'deliveries'; type NotifyTab = 'channels' | 'rules' | 'deliveries';
@@ -67,7 +68,7 @@ type DeliveryFilter =
@Component({ @Component({
selector: 'app-notify-panel', selector: 'app-notify-panel',
imports: [CommonModule, ReactiveFormsModule, RouterLink, StellaPageTabsComponent], imports: [CommonModule, ReactiveFormsModule, RouterLink, StellaPageTabsComponent, PageActionOutletComponent],
templateUrl: './notify-panel.component.html', templateUrl: './notify-panel.component.html',
styleUrls: ['./notify-panel.component.scss'], styleUrls: ['./notify-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -513,13 +513,6 @@ interface TrustAnchor {
gap: 1rem; gap: 1rem;
} }
.form-group label {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.token-input { .token-input {
width: 100%; width: 100%;
min-height: 120px; min-height: 120px;

View File

@@ -54,6 +54,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
ariaLabel="Offline kit tabs" ariaLabel="Offline kit tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >

View File

@@ -140,7 +140,7 @@ interface DlqItem {
.buckets button.active { .buckets button.active {
border-color: var(--color-brand-primary); border-color: var(--color-brand-primary);
background: rgba(245, 166, 35, 0.08); background: var(--color-brand-soft);
} }
.table { .table {

View File

@@ -23,6 +23,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
ariaLabel="Feeds & Offline sections" ariaLabel="Feeds & Offline sections"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >

View File

@@ -42,6 +42,7 @@ type FeedsAirgapAction = 'import' | 'export' | null;
<stella-page-tabs <stella-page-tabs
[tabs]="feedsTabs" [tabs]="feedsTabs"
[activeTab]="tab()" [activeTab]="tab()"
urlParam="tab"
(tabChange)="tab.set($any($event))" (tabChange)="tab.set($any($event))"
ariaLabel="Feeds and airgap sections" ariaLabel="Feeds and airgap sections"
/> />

View File

@@ -47,6 +47,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeSubview()" [activeTab]="activeSubview()"
urlParam="tab"
ariaLabel="Audit tabs" ariaLabel="Audit tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >

View File

@@ -60,6 +60,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs" [tabs]="pageTabs"
[activeTab]="activeSubview()" [activeTab]="activeSubview()"
urlParam="tab"
ariaLabel="VEX tabs" ariaLabel="VEX tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >

View File

@@ -64,6 +64,7 @@ const PACK_DETAIL_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="pageTabs()" [tabs]="pageTabs()"
[activeTab]="activeSubview()" [activeTab]="activeSubview()"
urlParam="tab"
ariaLabel="Pack tabs" ariaLabel="Pack tabs"
(tabChange)="onTabChange($event)" (tabChange)="onTabChange($event)"
> >

View File

@@ -38,6 +38,7 @@ const GOVERNANCE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="GOVERNANCE_TABS" [tabs]="GOVERNANCE_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="onTabChange($any($event))" (tabChange)="onTabChange($any($event))"
ariaLabel="Policy governance sections" ariaLabel="Policy governance sections"
> >

View File

@@ -563,22 +563,6 @@ import {
gap: 0.25rem; gap: 0.25rem;
} }
.filter-field span {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-field select {
padding: 0.5rem 0.75rem;
background: var(--color-surface-inverse);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
min-width: 160px;
}
.conflict-list { .conflict-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -237,23 +237,6 @@ import {
gap: 0.25rem; gap: 0.25rem;
} }
.filter-field span {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-field select,
.filter-field input {
padding: 0.5rem 0.75rem;
background: var(--color-surface-inverse);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
min-width: 180px;
}
.effective-policy__list { .effective-policy__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -267,22 +267,6 @@ import {
gap: 0.25rem; gap: 0.25rem;
} }
.filter-field span {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-field select {
padding: 0.5rem 0.75rem;
background: var(--color-surface-inverse);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
min-width: 160px;
}
.audit-timeline { .audit-timeline {
position: relative; position: relative;
} }

View File

@@ -397,12 +397,6 @@ import {
gap: 0.5rem; gap: 0.5rem;
} }
.filter-group label {
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: var(--font-weight-medium);
}
.filter-btn { .filter-btn {
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
background: transparent; background: transparent;

View File

@@ -63,6 +63,7 @@ const POLICY_SIM_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="POLICY_SIM_TABS" [tabs]="POLICY_SIM_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Policy simulation studio sections" ariaLabel="Policy simulation studio sections"
> >

View File

@@ -300,22 +300,6 @@ import { ShadowModeStateService } from './shadow-mode-state.service';
gap: 0.25rem; gap: 0.25rem;
} }
.filter-field span {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-field select {
padding: 0.5rem 0.75rem;
background: var(--color-text-heading);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
min-width: 150px;
}
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: linear-gradient(135deg, var(--color-status-excepted), var(--color-status-info)); background: linear-gradient(135deg, var(--color-status-excepted), var(--color-status-info));

View File

@@ -168,6 +168,7 @@ const SIMULATION_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="SIMULATION_TABS" [tabs]="SIMULATION_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="onTabChange($any($event))" (tabChange)="onTabChange($any($event))"
ariaLabel="Policy simulation sections" ariaLabel="Policy simulation sections"
> >

View File

@@ -464,22 +464,6 @@ import {
gap: 0.25rem; gap: 0.25rem;
} }
.filter-field span {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-field select {
padding: 0.5rem 0.75rem;
background: var(--color-surface-inverse);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
min-width: 160px;
}
.filter-field--checkbox { .filter-field--checkbox {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;

View File

@@ -56,6 +56,7 @@ const POLICY_STUDIO_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="policyTabs" [tabs]="policyTabs"
[activeTab]="viewMode()" [activeTab]="viewMode()"
urlParam="tab"
(tabChange)="viewMode.set($any($event))" (tabChange)="viewMode.set($any($event))"
ariaLabel="Policy studio views" ariaLabel="Policy studio views"
> >
@@ -677,27 +678,6 @@ const POLICY_STUDIO_TABS: readonly StellaPageTab[] = [
} }
} }
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(212, 201, 168, 0.3);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--color-text-primary);
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px var(--color-brand-primary-10);
}
&--sm {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
width: auto;
}
}
.policy-studio__filters { .policy-studio__filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -327,20 +327,6 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
gap: 0.25rem; gap: 0.25rem;
} }
.form-field label {
font-size: 0.82rem;
font-weight: 600;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.45rem 0.65rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
}
.form-hint { .form-hint {
margin: 0; margin: 0;
font-size: 0.78rem; font-size: 0.78rem;

View File

@@ -97,6 +97,7 @@ const PROMOTION_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="tabs" [tabs]="tabs"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))" (tabChange)="activeTab.set($any($event))"
ariaLabel="Promotion detail tabs" ariaLabel="Promotion detail tabs"
> >

View File

@@ -282,24 +282,6 @@ interface PromotionRow {
color: var(--color-text-secondary, #666); color: var(--color-text-secondary, #666);
} }
.filter-field input,
.filter-field select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
background: var(--color-surface-primary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.filter-field input:focus,
.filter-field select:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.state-block { .state-block {
padding: 1rem; padding: 1rem;
background: linear-gradient(90deg, var(--color-surface-alt, #f9fafb) 25%, var(--color-surface-primary, #fff) 50%, var(--color-surface-alt, #f9fafb) 75%); background: linear-gradient(90deg, var(--color-surface-alt, #f9fafb) 25%, var(--color-surface-primary, #fff) 50%, var(--color-surface-alt, #f9fafb) 75%);

View File

@@ -41,6 +41,7 @@ const SCORE_COMPARISON_TABS: readonly StellaPageTab[] = [
<stella-page-tabs <stella-page-tabs
[tabs]="scoreTabs" [tabs]="scoreTabs"
[activeTab]="viewMode()" [activeTab]="viewMode()"
urlParam="tab"
(tabChange)="viewMode.set($any($event))" (tabChange)="viewMode.set($any($event))"
ariaLabel="Score comparison view mode" ariaLabel="Score comparison view mode"
> >

View File

@@ -278,19 +278,6 @@ import { quotasPath } from '../platform/ops/operations-paths';
gap: 0.5rem; gap: 0.5rem;
} }
.filter-group label {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.filter-group select,
.filter-group input {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
/* Summary Cards */ /* Summary Cards */
.summary-cards { .summary-cards {
display: flex; display: flex;

View File

@@ -256,18 +256,6 @@ import { quotasPath } from '../platform/ops/operations-paths';
gap: 0.5rem; gap: 0.5rem;
} }
.filter-group label {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.filter-group select {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -398,19 +398,6 @@ import { quotaTenantPath, quotasPath } from '../platform/ops/operations-paths';
gap: 0.5rem; gap: 0.5rem;
} }
.filter-group label {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.filter-group select,
.filter-group input {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.summary-stats { .summary-stats {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;

View File

@@ -53,6 +53,7 @@ type TabType = 'plans' | 'audit';
<stella-page-tabs <stella-page-tabs
[tabs]="REGISTRY_ADMIN_TABS" [tabs]="REGISTRY_ADMIN_TABS"
[activeTab]="activeTab()" [activeTab]="activeTab()"
urlParam="tab"
(tabChange)="onTabChange($any($event))" (tabChange)="onTabChange($any($event))"
ariaLabel="Registry admin sections" ariaLabel="Registry admin sections"
> >

View File

@@ -734,30 +734,10 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
margin-bottom: 16px; margin-bottom: 16px;
} }
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: var(--font-weight-medium);
}
.required { .required {
color: var(--color-status-error); color: var(--color-status-error);
} }
.form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
resize: vertical;
}
.form-group textarea:focus {
outline: none;
border-color: var(--color-status-info);
}
.form-actions { .form-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

Some files were not shown because too many files have changed in this diff Show More