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:
@@ -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 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)
|
||||
|
||||
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
|
||||
- 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)
|
||||
|
||||
Three filter component types:
|
||||
|
||||
87
src/Web/StellaOps.Web/e2e/helpers/seed-releases.ts
Normal file
87
src/Web/StellaOps.Web/e2e/helpers/seed-releases.ts
Normal 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-%';
|
||||
`);
|
||||
}
|
||||
1155
src/Web/StellaOps.Web/e2e/workflows/release-pages.e2e.spec.ts
Normal file
1155
src/Web/StellaOps.Web/e2e/workflows/release-pages.e2e.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,12 @@
|
||||
<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) -->
|
||||
@if (legacyRouteInfo(); as legacy) {
|
||||
<app-legacy-url-banner
|
||||
|
||||
@@ -17,6 +17,41 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -99,8 +99,10 @@ export class AppComponent {
|
||||
.subscribe(() => removeSplash());
|
||||
|
||||
// Defensive fallback: if first navigation never settles (e.g. test/misconfigured
|
||||
// backend), remove splash so the shell remains interactive.
|
||||
setTimeout(() => removeSplash(), 5000);
|
||||
// backend), remove splash so the shell remains interactive. Use 10s to match
|
||||
// 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
|
||||
this.brandingService.fetchBranding().subscribe();
|
||||
@@ -151,6 +153,7 @@ export class AppComponent {
|
||||
}
|
||||
|
||||
readonly isAuthenticated = this.sessionStore.isAuthenticated;
|
||||
readonly authStatus = this.sessionStore.status;
|
||||
readonly activeTenant = this.consoleStore.selectedTenantId;
|
||||
|
||||
readonly freshAuthSummary = computed(() => {
|
||||
|
||||
567
src/Web/StellaOps.Web/src/app/core/api/scripts.client.ts
Normal file
567
src/Web/StellaOps.Web/src/app/core/api/scripts.client.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
175
src/Web/StellaOps.Web/src/app/core/api/scripts.models.ts
Normal file
175
src/Web/StellaOps.Web/src/app/core/api/scripts.models.ts
Normal 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)';
|
||||
}
|
||||
@@ -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 { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
import { Observable, firstValueFrom, from, throwError, timer } from 'rxjs';
|
||||
import { catchError, filter, switchMap, take } from 'rxjs/operators';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
@@ -14,6 +14,7 @@ const RETRY_HEADER = StellaOpsHeaders.DpopRetry;
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
private excludedPrefixes: string[] = [];
|
||||
private authorityResolved = false;
|
||||
private logoutInFlight = false;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthorityAuthService,
|
||||
@@ -63,6 +64,22 @@ export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
error: HttpErrorResponse,
|
||||
next: HttpHandler
|
||||
): 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) {
|
||||
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(
|
||||
request: HttpRequest<unknown>,
|
||||
nonce: string,
|
||||
@@ -103,7 +131,39 @@ export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
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 {
|
||||
|
||||
@@ -42,7 +42,8 @@ export type AuthStatus =
|
||||
| 'unauthenticated'
|
||||
| 'authenticated'
|
||||
| 'refreshing'
|
||||
| 'loading';
|
||||
| 'loading'
|
||||
| 'expired';
|
||||
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('AuthSessionStore', () => {
|
||||
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);
|
||||
sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(expired));
|
||||
sessionStorage.setItem(
|
||||
@@ -96,6 +96,7 @@ describe('AuthSessionStore', () => {
|
||||
|
||||
const rehydrated = createStore();
|
||||
expect(rehydrated.isAuthenticated()).toBeFalse();
|
||||
expect(rehydrated.status()).toBe('expired');
|
||||
expect(rehydrated.session()).toBeNull();
|
||||
expect(rehydrated.subjectHint()).toBe('user-123');
|
||||
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull();
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private restoredSessionExpired = false;
|
||||
private readonly restoredSession = this.readPersistedSession();
|
||||
private readonly sessionSignal = signal<AuthSession | null>(
|
||||
this.restoredSession
|
||||
);
|
||||
private readonly statusSignal = signal<AuthStatus>(
|
||||
this.restoredSession ? 'authenticated' : 'unauthenticated'
|
||||
this.restoredSession ? 'authenticated'
|
||||
: this.restoredSessionExpired ? 'expired'
|
||||
: 'unauthenticated'
|
||||
);
|
||||
private readonly persistedSignal = signal<PersistedSessionMetadata | null>(
|
||||
this.readPersistedMetadata(this.restoredSession)
|
||||
@@ -201,6 +204,7 @@ export class AuthSessionStore {
|
||||
|
||||
if (parsed.tokens.expiresAtEpochMs <= Date.now()) {
|
||||
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
|
||||
this.restoredSessionExpired = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export class AuthorityAuthService {
|
||||
private refreshInFlight: Promise<void> | null = null;
|
||||
private silentRefreshInFlight: Promise<boolean> | null = null;
|
||||
private lastError: AuthErrorReason | null = null;
|
||||
private loggingOut = false;
|
||||
|
||||
constructor(
|
||||
httpBackend: HttpBackend,
|
||||
@@ -142,8 +143,9 @@ export class AuthorityAuthService {
|
||||
return this.silentRefreshInFlight;
|
||||
}
|
||||
|
||||
const statusBeforeRefresh = this.sessionStore.status();
|
||||
this.sessionStore.setStatus('loading');
|
||||
this.silentRefreshInFlight = this.executeSilentRefresh()
|
||||
this.silentRefreshInFlight = this.executeSilentRefresh(statusBeforeRefresh)
|
||||
.finally(() => {
|
||||
this.silentRefreshInFlight = null;
|
||||
});
|
||||
@@ -151,7 +153,9 @@ export class AuthorityAuthService {
|
||||
return this.silentRefreshInFlight;
|
||||
}
|
||||
|
||||
private async executeSilentRefresh(): Promise<boolean> {
|
||||
private async executeSilentRefresh(
|
||||
statusBeforeRefresh: string
|
||||
): Promise<boolean> {
|
||||
const authority = this.config.authority;
|
||||
const silentRedirectUri = this.resolveSilentRefreshRedirectUri(authority);
|
||||
const pkce = await createPkcePair();
|
||||
@@ -204,14 +208,18 @@ export class AuthorityAuthService {
|
||||
resolve(true);
|
||||
} else if (data.type === 'silent-refresh-error') {
|
||||
cleanup();
|
||||
this.sessionStore.setStatus('unauthenticated');
|
||||
this.sessionStore.setStatus(
|
||||
statusBeforeRefresh === 'expired' ? 'expired' : 'unauthenticated'
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
this.sessionStore.setStatus('unauthenticated');
|
||||
this.sessionStore.setStatus(
|
||||
statusBeforeRefresh === 'expired' ? 'expired' : 'unauthenticated'
|
||||
);
|
||||
resolve(false);
|
||||
}, AuthorityAuthService.SILENT_REFRESH_TIMEOUT_MS);
|
||||
|
||||
@@ -316,6 +324,7 @@ export class AuthorityAuthService {
|
||||
.catch((error) => {
|
||||
this.lastError = 'refresh_failed';
|
||||
this.sessionStore.clear();
|
||||
this.sessionStore.setStatus('expired');
|
||||
this.getConsoleSession().clear();
|
||||
throw error;
|
||||
})
|
||||
@@ -327,6 +336,8 @@ export class AuthorityAuthService {
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (this.loggingOut) return;
|
||||
this.loggingOut = true;
|
||||
const session = this.sessionStore.session();
|
||||
this.cancelRefreshTimer();
|
||||
this.sessionStore.clear();
|
||||
|
||||
@@ -165,6 +165,31 @@ export class BrandingService {
|
||||
?? 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
|
||||
*/
|
||||
@@ -200,6 +225,39 @@ export class BrandingService {
|
||||
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
|
||||
*/
|
||||
@@ -219,13 +277,45 @@ export class BrandingService {
|
||||
Object.entries(tokens).forEach(([key, value]) => {
|
||||
// Only apply whitelisted tokens
|
||||
if (allowedPrefixes.some(prefix => key.startsWith(prefix))) {
|
||||
// Sanitize value to prevent CSS injection
|
||||
const sanitizedValue = this.sanitizeCssValue(value);
|
||||
if (sanitizedValue) {
|
||||
// Set the --theme-* token (used by branding preview)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,6 +120,7 @@ const NOTIFY_ADMIN_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="NOTIFY_ADMIN_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Notification admin tabs"
|
||||
/>
|
||||
|
||||
@@ -382,13 +382,6 @@ interface ChannelTypeOption {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
@@ -588,30 +581,6 @@ interface ChannelTypeOption {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -369,19 +369,6 @@ import {
|
||||
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 {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -377,10 +377,6 @@ import {
|
||||
.section-desc { margin: 0 0 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); }
|
||||
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
NotifierDeliveryStats,
|
||||
} from '../../../core/api/notifier.models';
|
||||
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';
|
||||
|
||||
@@ -81,7 +82,7 @@ interface ConfigSubTab {
|
||||
|
||||
@Component({
|
||||
selector: 'app-notification-dashboard',
|
||||
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
|
||||
imports: [CommonModule, RouterModule, StellaPageTabsComponent, PageActionOutletComponent],
|
||||
template: `
|
||||
<div class="nd">
|
||||
<header class="nd__header">
|
||||
@@ -188,9 +189,11 @@ interface ConfigSubTab {
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Notification administration tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
<app-page-action-outlet tabBarAction />
|
||||
<!-- Config Sub-Navigation -->
|
||||
@if (activeTab() === 'config') {
|
||||
<nav class="nd__sub-nav" role="tablist">
|
||||
|
||||
@@ -303,14 +303,6 @@ import {
|
||||
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="number"],
|
||||
.form-group select,
|
||||
@@ -323,19 +315,6 @@ import {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -212,14 +212,6 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
||||
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 {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -378,10 +378,6 @@ import {
|
||||
.form-section h4 { margin: 0 0 1rem; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); }
|
||||
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -371,8 +371,6 @@ import {
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -469,10 +467,6 @@ import {
|
||||
.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 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); }
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -276,34 +276,6 @@ import {
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
@@ -268,30 +268,6 @@ import {
|
||||
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 {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
@@ -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-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.three-col { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
@@ -138,6 +138,7 @@ interface ActionFeedback {
|
||||
<stella-page-tabs
|
||||
[tabs]="tabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Agent detail tabs"
|
||||
>
|
||||
|
||||
@@ -300,16 +300,6 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
|
||||
.modal__input {
|
||||
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 {
|
||||
|
||||
@@ -579,19 +579,6 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
gap: 0.35rem;
|
||||
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 {
|
||||
padding: 0.45rem 0.9rem;
|
||||
|
||||
@@ -238,6 +238,7 @@ interface HistoryEvent {
|
||||
<stella-page-tabs
|
||||
[tabs]="approvalDetailTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setActiveTab($any($event))"
|
||||
ariaLabel="Approval detail tabs"
|
||||
/>
|
||||
@@ -870,7 +871,7 @@ interface HistoryEvent {
|
||||
.decision-form textarea:focus {
|
||||
outline: none;
|
||||
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 {
|
||||
|
||||
@@ -29,6 +29,7 @@ const AUTHORITY_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="authorityTabs"
|
||||
[activeTab]="tab"
|
||||
urlParam="tab"
|
||||
(tabChange)="switchTab($any($event))"
|
||||
ariaLabel="Authority audit tabs"
|
||||
/>
|
||||
|
||||
@@ -7,54 +7,21 @@ export const auditLogRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./audit-log-dashboard.component').then((m) => m.AuditLogDashboardComponent),
|
||||
},
|
||||
{
|
||||
path: 'events',
|
||||
loadComponent: () =>
|
||||
import('./audit-log-table.component').then((m) => m.AuditLogTableComponent),
|
||||
},
|
||||
// Event detail (deep link)
|
||||
{
|
||||
path: 'events/:eventId',
|
||||
loadComponent: () =>
|
||||
import('./audit-event-detail.component').then((m) => m.AuditEventDetailComponent),
|
||||
},
|
||||
{
|
||||
path: 'timeline',
|
||||
loadComponent: () =>
|
||||
import('./audit-timeline-search.component').then((m) => m.AuditTimelineSearchComponent),
|
||||
},
|
||||
{
|
||||
path: 'correlations',
|
||||
loadComponent: () =>
|
||||
import('./audit-correlations.component').then((m) => m.AuditCorrelationsComponent),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
// Backward-compatible redirects for old child-route URLs → dashboard with ?tab=
|
||||
{ path: 'events', redirectTo: '?tab=all-events', pathMatch: 'full' },
|
||||
{ path: 'policy', redirectTo: '?tab=policy', pathMatch: 'full' },
|
||||
{ path: 'authority', redirectTo: '?tab=authority', pathMatch: 'full' },
|
||||
{ path: 'vex', redirectTo: '?tab=vex', pathMatch: 'full' },
|
||||
{ path: 'integrations', redirectTo: '?tab=integrations', pathMatch: 'full' },
|
||||
{ path: 'trust', redirectTo: '?tab=trust', pathMatch: 'full' },
|
||||
{ path: 'timeline', redirectTo: '?tab=timeline', pathMatch: 'full' },
|
||||
{ path: 'correlations', redirectTo: '?tab=correlations', pathMatch: 'full' },
|
||||
{ path: 'anomalies', redirectTo: '?tab=overview', pathMatch: 'full' },
|
||||
{ path: 'export', redirectTo: '/evidence/exports', pathMatch: 'full' },
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -114,7 +114,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
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%),
|
||||
#060a14;
|
||||
}
|
||||
@@ -152,9 +152,9 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
background: rgba(8, 14, 26, 0.5);
|
||||
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:
|
||||
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),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
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 */
|
||||
.orbit--1 {
|
||||
stroke: rgba(245, 184, 74, 0.2);
|
||||
stroke: var(--color-brand-primary-20);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 80 196;
|
||||
animation: orbit-spin-cw 3s linear infinite;
|
||||
@@ -203,7 +203,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
/* Middle orbit — medium CCW */
|
||||
.orbit--2 {
|
||||
stroke: rgba(245, 184, 74, 0.3);
|
||||
stroke: var(--color-brand-primary-30);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 55 159;
|
||||
animation: orbit-spin-ccw 2.2s linear infinite;
|
||||
@@ -211,7 +211,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
/* Inner orbit — fast CW */
|
||||
.orbit--3 {
|
||||
stroke: rgba(245, 184, 74, 0.45);
|
||||
stroke: var(--color-border-emphasis);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 35 116;
|
||||
animation: orbit-spin-cw 1.6s linear infinite;
|
||||
@@ -224,7 +224,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(245, 184, 74, 0.7);
|
||||
color: var(--color-brand-primary);
|
||||
animation: icon-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -307,10 +307,10 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
align-items: center;
|
||||
gap: 0.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;
|
||||
background: rgba(245, 184, 74, 0.06);
|
||||
color: rgba(245, 184, 74, 0.8);
|
||||
background: var(--color-sidebar-hover);
|
||||
color: var(--color-brand-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
@@ -326,15 +326,15 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: rgba(245, 184, 74, 0.12);
|
||||
border-color: rgba(245, 184, 74, 0.35);
|
||||
color: rgba(245, 184, 74, 1);
|
||||
background: var(--color-brand-primary-10);
|
||||
border-color: var(--color-brand-primary-30);
|
||||
color: var(--color-brand-primary);
|
||||
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 {
|
||||
outline: 2px solid rgba(245, 184, 74, 0.4);
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ const BINARY_INDEX_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="BINARY_INDEX_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setTab($any($event))"
|
||||
ariaLabel="BinaryIndex operations"
|
||||
>
|
||||
|
||||
@@ -294,19 +294,6 @@ interface ComponentDraft {
|
||||
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 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
|
||||
@@ -74,6 +74,7 @@ const BUNDLE_DETAIL_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="BUNDLE_DETAIL_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Bundle details"
|
||||
>
|
||||
|
||||
@@ -90,6 +90,7 @@ const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="BUNDLE_VERSION_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Bundle version details"
|
||||
>
|
||||
|
||||
@@ -261,20 +261,6 @@ import {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
|
||||
@@ -61,6 +61,7 @@ const CONFIG_DETAIL_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="configDetailTabs"
|
||||
[activeTab]="activeTab"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab = $any($event)"
|
||||
ariaLabel="Integration detail tabs"
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// 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 { of } from 'rxjs';
|
||||
import { catchError, timeout } from 'rxjs/operators';
|
||||
import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component';
|
||||
import { I18nService } from '../../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-log',
|
||||
@@ -51,7 +54,19 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/
|
||||
</div>
|
||||
|
||||
@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) {
|
||||
<div class="empty-state">No audit events found</div>
|
||||
} @else {
|
||||
@@ -458,20 +473,10 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
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; }
|
||||
}
|
||||
.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, var(--theme-bg-tertiary)); animation: skeleton-pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -493,6 +498,8 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/
|
||||
export class AuditLogComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
events: AuditEvent[] = [];
|
||||
filteredEvents: AuditEvent[] = [];
|
||||
@@ -548,25 +555,32 @@ export class AuditLogComponent implements OnInit {
|
||||
}
|
||||
|
||||
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 {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listAuditEvents().subscribe({
|
||||
next: (response: { events: AuditEvent[] }) => {
|
||||
this.events = response.events;
|
||||
this.applyFilters();
|
||||
this.api.listAuditEvents().pipe(
|
||||
timeout(15_000),
|
||||
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: 'audit log', reason });
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err: { error?: { message?: string }; message?: string }) => {
|
||||
this.error = 'Failed to load audit log: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((response: any) => {
|
||||
if (response === null) return;
|
||||
this.events = Array.isArray(response) ? response : (response?.events ?? []);
|
||||
this.applyFilters();
|
||||
this.isLoading = false;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { BrandingService } from '../../../core/branding/branding.service';
|
||||
@@ -58,7 +58,7 @@ const DARK_DEFAULTS = {
|
||||
<div class="branding-settings">
|
||||
<header class="page-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -568,12 +568,24 @@ const DARK_DEFAULTS = {
|
||||
{{ formData.title || 'Stella Ops' }}
|
||||
</span>
|
||||
<div class="preview-topbar-pill"
|
||||
[style.background]="resolvedBrandColor()"
|
||||
[style.background]="resolvedBrandSecondary()"
|
||||
[style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'">
|
||||
v3.2
|
||||
</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 -->
|
||||
<div class="preview-cards">
|
||||
<div class="preview-stat-card"
|
||||
@@ -624,12 +636,20 @@ const DARK_DEFAULTS = {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action button -->
|
||||
<button class="preview-action-btn"
|
||||
[style.background]="resolvedBrandColor()"
|
||||
[style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'">
|
||||
Create Release
|
||||
</button>
|
||||
<!-- Action buttons -->
|
||||
<div class="preview-actions">
|
||||
<button class="preview-action-btn preview-action-btn--primary"
|
||||
[style.background]="resolvedBrandColor()"
|
||||
[style.color]="getTokenValue('--theme-text-inverse') || '#1A0F00'">
|
||||
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>
|
||||
@@ -916,14 +936,6 @@ const DARK_DEFAULTS = {
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
@@ -1003,7 +1015,7 @@ const DARK_DEFAULTS = {
|
||||
|
||||
.upload-zone:hover {
|
||||
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 {
|
||||
@@ -1305,7 +1317,7 @@ const DARK_DEFAULTS = {
|
||||
}
|
||||
|
||||
.preview-nav-item.active {
|
||||
background: rgba(245, 184, 74, 0.12);
|
||||
background: var(--color-brand-primary-10);
|
||||
}
|
||||
|
||||
.preview-nav-item span {
|
||||
@@ -1406,8 +1418,36 @@ const DARK_DEFAULTS = {
|
||||
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;
|
||||
}
|
||||
|
||||
.preview-action-btn {
|
||||
flex: 1;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
@@ -1416,11 +1456,16 @@ const DARK_DEFAULTS = {
|
||||
cursor: default;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-action-btn--secondary {
|
||||
border: 1px solid;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class BrandingEditorComponent implements OnInit {
|
||||
private readonly brandingService = inject(BrandingService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
|
||||
readonly isLoading = signal(false);
|
||||
@@ -1491,45 +1536,53 @@ export class BrandingEditorComponent implements OnInit {
|
||||
// Resolved preview values (check token first, then fallback)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
resolvedBrandColor = computed(() =>
|
||||
this.getTokenValue('--theme-brand-primary') || LIGHT_DEFAULTS['--color-brand-primary']
|
||||
);
|
||||
resolvedBrandColor(): string {
|
||||
return this.getTokenValue('--theme-brand-primary') || LIGHT_DEFAULTS['--color-brand-primary'];
|
||||
}
|
||||
|
||||
resolvedSurfacePrimary = computed(() =>
|
||||
this.getTokenValue('--theme-bg-primary') || LIGHT_DEFAULTS['--color-surface-primary']
|
||||
);
|
||||
resolvedBrandSecondary(): string {
|
||||
return this.getTokenValue('--theme-brand-secondary') || LIGHT_DEFAULTS['--color-brand-secondary'];
|
||||
}
|
||||
|
||||
resolvedSurfaceSecondary = computed(() =>
|
||||
this.getTokenValue('--theme-bg-secondary') || LIGHT_DEFAULTS['--color-surface-secondary']
|
||||
);
|
||||
resolvedSurfacePrimary(): string {
|
||||
return this.getTokenValue('--theme-bg-primary') || LIGHT_DEFAULTS['--color-surface-primary'];
|
||||
}
|
||||
|
||||
resolvedTextPrimary = computed(() =>
|
||||
this.getTokenValue('--theme-text-primary') || LIGHT_DEFAULTS['--color-text-primary']
|
||||
);
|
||||
resolvedSurfaceSecondary(): string {
|
||||
return this.getTokenValue('--theme-bg-secondary') || LIGHT_DEFAULTS['--color-surface-secondary'];
|
||||
}
|
||||
|
||||
resolvedTextMuted = computed(() =>
|
||||
this.getTokenValue('--theme-text-muted') || LIGHT_DEFAULTS['--color-text-muted']
|
||||
);
|
||||
resolvedSurfaceTertiary(): string {
|
||||
return this.getTokenValue('--theme-bg-tertiary') || LIGHT_DEFAULTS['--color-surface-tertiary'];
|
||||
}
|
||||
|
||||
resolvedBorderColor = computed(() =>
|
||||
this.getTokenValue('--theme-border-primary') || LIGHT_DEFAULTS['--color-border-primary']
|
||||
);
|
||||
resolvedTextPrimary(): string {
|
||||
return this.getTokenValue('--theme-text-primary') || LIGHT_DEFAULTS['--color-text-primary'];
|
||||
}
|
||||
|
||||
resolvedStatusSuccess = computed(() =>
|
||||
this.getTokenValue('--theme-status-success') || LIGHT_DEFAULTS['--color-status-success']
|
||||
);
|
||||
resolvedTextMuted(): string {
|
||||
return this.getTokenValue('--theme-text-muted') || LIGHT_DEFAULTS['--color-text-muted'];
|
||||
}
|
||||
|
||||
resolvedStatusWarning = computed(() =>
|
||||
this.getTokenValue('--theme-status-warning') || LIGHT_DEFAULTS['--color-status-warning']
|
||||
);
|
||||
resolvedBorderColor(): string {
|
||||
return this.getTokenValue('--theme-border-primary') || LIGHT_DEFAULTS['--color-border-primary'];
|
||||
}
|
||||
|
||||
resolvedStatusError = computed(() =>
|
||||
this.getTokenValue('--theme-status-error') || LIGHT_DEFAULTS['--color-status-error']
|
||||
);
|
||||
resolvedStatusSuccess(): string {
|
||||
return this.getTokenValue('--theme-status-success') || LIGHT_DEFAULTS['--color-status-success'];
|
||||
}
|
||||
|
||||
resolvedStatusInfo = computed(() =>
|
||||
this.getTokenValue('--theme-status-info') || LIGHT_DEFAULTS['--color-status-info']
|
||||
);
|
||||
resolvedStatusWarning(): string {
|
||||
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 {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_BRANDING_WRITE);
|
||||
@@ -1612,6 +1665,7 @@ export class BrandingEditorComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
this.markAsChanged();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
markAsChanged(): void {
|
||||
@@ -1622,16 +1676,24 @@ export class BrandingEditorComponent implements OnInit {
|
||||
this.success.set(null);
|
||||
}
|
||||
|
||||
resetAllToDefaults(): void {
|
||||
async resetAllToDefaults(): Promise<void> {
|
||||
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.formData.themeTokens = {};
|
||||
this.formData.title = '';
|
||||
this.formData.logoUrl = '';
|
||||
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> {
|
||||
|
||||
@@ -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 { ConsoleAdminApiService, Client } from '../services/console-admin-api.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 { CopyToClipboardComponent } from '../../../shared/ui/copy-to-clipboard/copy-to-clipboard.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({
|
||||
selector: 'app-clients-list',
|
||||
@@ -15,16 +18,14 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
<h1>OAuth2 Clients</h1>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="showCreateForm()"
|
||||
[disabled]="!canWrite || isCreating">
|
||||
Create Client
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@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) {
|
||||
@@ -113,7 +114,19 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
}
|
||||
|
||||
@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) {
|
||||
<div class="empty-state">No OAuth2 clients configured</div>
|
||||
} @else {
|
||||
@@ -156,7 +169,9 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (canWrite) {
|
||||
@if (isBuiltInClient(client)) {
|
||||
<span class="badge badge-system">System</span>
|
||||
} @else if (canWrite) {
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="editClient(client)"
|
||||
@@ -230,25 +245,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
@@ -342,6 +338,15 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
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 {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
@@ -432,13 +437,31 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
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 {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
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 freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
clients: Client[] = [];
|
||||
isLoading = false;
|
||||
@@ -476,25 +502,44 @@ export class ClientsListComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Create Client', action: () => this.showCreateForm() });
|
||||
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 {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listClients().subscribe({
|
||||
next: (response) => {
|
||||
this.clients = response.clients;
|
||||
this.api.listClients().pipe(
|
||||
timeout(15_000),
|
||||
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;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load OAuth2 clients: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
return of(null);
|
||||
})
|
||||
).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 {
|
||||
this.isCreating = true;
|
||||
this.editingClient = null;
|
||||
@@ -507,9 +552,11 @@ export class ClientsListComponent implements OnInit {
|
||||
redirectUrisInput: '',
|
||||
scopesInput: ''
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
editClient(client: Client): void {
|
||||
if (this.isBuiltInClient(client)) return;
|
||||
this.isCreating = false;
|
||||
this.editingClient = client;
|
||||
this.newClientSecret = null;
|
||||
@@ -521,6 +568,7 @@ export class ClientsListComponent implements OnInit {
|
||||
redirectUrisInput: (client.redirectUris ?? []).join(','),
|
||||
scopesInput: client.scopes.join(',')
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
cancelForm(): void {
|
||||
@@ -535,6 +583,7 @@ export class ClientsListComponent implements OnInit {
|
||||
redirectUrisInput: '',
|
||||
scopesInput: ''
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async createClient(): Promise<void> {
|
||||
@@ -562,10 +611,12 @@ export class ClientsListComponent implements OnInit {
|
||||
}
|
||||
this.newClientSecret = response.clientSecret ?? null;
|
||||
this.isSaving = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -596,10 +647,12 @@ export class ClientsListComponent implements OnInit {
|
||||
}
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -617,9 +670,11 @@ export class ClientsListComponent implements OnInit {
|
||||
this.newClientSecret = response.newSecret;
|
||||
this.isCreating = false;
|
||||
this.editingClient = null;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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) {
|
||||
client.status = 'disabled';
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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) {
|
||||
client.status = 'active';
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/ro
|
||||
import { filter } from 'rxjs';
|
||||
|
||||
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';
|
||||
|
||||
@@ -26,7 +27,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
@Component({
|
||||
selector: 'app-console-admin-layout',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, StellaPageTabsComponent],
|
||||
imports: [RouterOutlet, StellaPageTabsComponent, PageActionOutletComponent],
|
||||
template: `
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
@@ -34,6 +35,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
ariaLabel="Console admin tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
<app-page-action-outlet tabBarAction />
|
||||
<router-outlet />
|
||||
</stella-page-tabs>
|
||||
`,
|
||||
|
||||
@@ -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 { ConsoleAdminApiService, Role } from '../services/console-admin-api.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 { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
import { I18nService } from '../../../core/i18n';
|
||||
|
||||
interface RoleBundle {
|
||||
module: string;
|
||||
@@ -41,7 +43,11 @@ interface RoleBundle {
|
||||
</div>
|
||||
|
||||
@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') {
|
||||
@@ -111,19 +117,43 @@ interface RoleBundle {
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scopes</label>
|
||||
<div class="scope-selector">
|
||||
@for (scope of availableScopes; track scope) {
|
||||
<label class="scope-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="formData.selectedScopes.includes(scope)"
|
||||
(change)="toggleScope(scope)">
|
||||
<span class="scope-label">{{ getScopeLabel(scope) }}</span>
|
||||
<app-inline-code [code]="scope"></app-inline-code>
|
||||
</label>
|
||||
}
|
||||
<label>Scopes ({{ formData.selectedScopes.length }} selected)</label>
|
||||
<div class="scope-tag-input" (click)="scopeInputEl.focus()">
|
||||
<div class="scope-tag-input__tags">
|
||||
@for (scope of formData.selectedScopes; track scope) {
|
||||
<span class="scope-tag">
|
||||
<code>{{ scope }}</code>
|
||||
<button type="button" class="scope-tag__remove" (click)="toggleScope(scope); $event.stopPropagation()" title="Remove">×</button>
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
#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>
|
||||
@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 class="form-actions">
|
||||
<button
|
||||
@@ -143,7 +173,16 @@ interface RoleBundle {
|
||||
}
|
||||
|
||||
@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) {
|
||||
<div class="empty-state">No custom roles defined</div>
|
||||
} @else {
|
||||
@@ -239,6 +278,20 @@ interface RoleBundle {
|
||||
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 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@@ -281,25 +334,22 @@ interface RoleBundle {
|
||||
}
|
||||
|
||||
.bundle-card {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 2px solid var(--theme-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.bundle-card.tier-viewer {
|
||||
border-left-color: var(--theme-status-info);
|
||||
border-left-width: 4px;
|
||||
border-left: 3px solid var(--color-status-info);
|
||||
}
|
||||
|
||||
.bundle-card.tier-operator {
|
||||
border-left-color: var(--theme-status-warning);
|
||||
border-left-width: 4px;
|
||||
border-left: 3px solid var(--color-status-warning);
|
||||
}
|
||||
|
||||
.bundle-card.tier-admin {
|
||||
border-left-color: var(--theme-status-error);
|
||||
border-left-width: 4px;
|
||||
border-left: 3px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.bundle-header {
|
||||
@@ -310,48 +360,58 @@ interface RoleBundle {
|
||||
}
|
||||
|
||||
.bundle-name {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.bundle-tier {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: var(--theme-bg-tertiary);
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.bundle-description {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--theme-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bundle-scopes {
|
||||
border-top: 1px solid var(--theme-border-primary);
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scopes-header {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.scope-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 2px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: 999px;
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
@@ -372,12 +432,6 @@ interface RoleBundle {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
@@ -386,41 +440,73 @@ interface RoleBundle {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.scope-selector {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
.scope-tag-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;
|
||||
}
|
||||
|
||||
.scope-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
.scope-tag-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);
|
||||
}
|
||||
|
||||
.scope-checkbox:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
.scope-tag-input__tags { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
|
||||
.scope-tag-input__field {
|
||||
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-checkbox input[type="checkbox"] {
|
||||
margin-right: 12px;
|
||||
.scope-tag-input__field::placeholder { color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
.scope-tag {
|
||||
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-label {
|
||||
flex: 1;
|
||||
font-weight: var(--font-weight-medium);
|
||||
.scope-tag code { font-family: var(--font-family-mono, monospace); color: var(--color-text-primary); white-space: nowrap; }
|
||||
.scope-tag__remove {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
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 {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
.scope-dropdown {
|
||||
position: relative; margin-top: 0.25rem;
|
||||
max-height: 300px; overflow-y: auto;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-elevated);
|
||||
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 {
|
||||
@@ -538,13 +624,31 @@ interface RoleBundle {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
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 {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
@@ -556,6 +660,9 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly zone = inject(NgZone);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
activeTab: 'catalog' | 'custom' = 'catalog';
|
||||
catalogFilter = '';
|
||||
@@ -644,6 +751,84 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
|
||||
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 {
|
||||
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() });
|
||||
if (this.activeTab === 'custom') {
|
||||
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.error = null;
|
||||
|
||||
this.api.listRoles().subscribe({
|
||||
next: (response) => {
|
||||
this.customRoles = response.roles;
|
||||
this.api.listRoles().pipe(
|
||||
timeout(15_000),
|
||||
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;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load custom roles: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((response: any) => {
|
||||
if (response === null) return;
|
||||
this.customRoles = Array.isArray(response) ? response : (response?.roles ?? []);
|
||||
this.isLoading = false;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
showCreateForm(): void {
|
||||
this.isCreating = true;
|
||||
this.editingRole = null;
|
||||
this.formData = {
|
||||
roleId: '',
|
||||
displayName: '',
|
||||
selectedScopes: []
|
||||
};
|
||||
this.zone.run(() => {
|
||||
this.activeTab = 'custom';
|
||||
this.isCreating = true;
|
||||
this.editingRole = null;
|
||||
this.formData = {
|
||||
roleId: '',
|
||||
displayName: '',
|
||||
selectedScopes: []
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
editRole(role: Role): void {
|
||||
@@ -707,6 +905,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
displayName: role.displayName,
|
||||
selectedScopes: [...role.scopes]
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
cancelForm(): void {
|
||||
@@ -717,6 +916,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
displayName: '',
|
||||
selectedScopes: []
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
toggleScope(scope: string): void {
|
||||
@@ -745,10 +945,12 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
this.loadCustomRoles();
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -771,10 +973,12 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
this.loadCustomRoles();
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -788,6 +992,7 @@ export class RolesListComponent implements OnInit, OnDestroy {
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||
import { catchError, timeout } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { ConsoleAdminApiService, Tenant } from '../services/console-admin-api.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
import { I18nService } from '../../../core/i18n';
|
||||
|
||||
/**
|
||||
* Tenants List Component
|
||||
@@ -12,18 +16,52 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-tenants-list',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
<h1>Tenants</h1>
|
||||
</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">
|
||||
@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) {
|
||||
<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) {
|
||||
<div class="empty-state">No tenants configured.</div>
|
||||
} @else {
|
||||
@@ -124,57 +162,148 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.error-banner {
|
||||
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 {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-status-error);
|
||||
.admin-form {
|
||||
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 {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
tenants: Tenant[] = [];
|
||||
loading = true;
|
||||
error: string | null = null;
|
||||
canWrite = false; // TODO: Check authority:tenants.write scope
|
||||
|
||||
isCreating = false;
|
||||
isSaving = false;
|
||||
formData = { id: '', displayName: '' };
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Add Tenant', action: () => this.createTenant() });
|
||||
this.pageAction.set({ label: 'Add Tenant', action: () => this.showCreateForm() });
|
||||
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 {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
private loadTenants(): void {
|
||||
loadTenants(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listTenants().subscribe({
|
||||
next: (response) => {
|
||||
this.tenants = response.tenants;
|
||||
this.api.listTenants().pipe(
|
||||
timeout(15_000),
|
||||
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;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load tenants: ' + (err.message || 'Unknown error');
|
||||
this.loading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((response: any) => {
|
||||
if (response === null) return;
|
||||
this.tenants = Array.isArray(response) ? response : (response?.tenants ?? []);
|
||||
this.loading = false;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
createTenant(): void {
|
||||
// Placeholder: would open create tenant dialog
|
||||
console.log('Create tenant dialog - implementation pending');
|
||||
showCreateForm(): void {
|
||||
this.isCreating = true;
|
||||
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> {
|
||||
@@ -189,7 +318,7 @@ export class TenantsListComponent implements OnInit, OnDestroy {
|
||||
this.loadTenants();
|
||||
},
|
||||
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();
|
||||
},
|
||||
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') });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { ConsoleAdminApiService, Token } from '../services/console-admin-api.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 { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
|
||||
import { I18nService } from '../../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens-list',
|
||||
@@ -33,11 +35,29 @@ import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
|
||||
</header>
|
||||
|
||||
@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) {
|
||||
<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) {
|
||||
<div class="empty-state">No tokens found</div>
|
||||
} @else {
|
||||
@@ -358,13 +378,31 @@ import { TruncatePipe } from '../../../shared/pipes/truncate.pipe';
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
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 {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
@@ -375,6 +413,8 @@ export class TokensListComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
tokens: Token[] = [];
|
||||
isLoading = false;
|
||||
@@ -389,21 +429,31 @@ export class TokensListComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
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 {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listTokens().subscribe({
|
||||
next: (response) => {
|
||||
this.tokens = this.applyFilters(response.tokens);
|
||||
this.api.listTokens().pipe(
|
||||
timeout(15_000),
|
||||
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;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load tokens: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
return of(null);
|
||||
})
|
||||
).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) {
|
||||
token.status = 'revoked';
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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) {
|
||||
this.error = `Revoked ${response.revokedCount} of ${tokenIds.length} tokens`;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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({
|
||||
next: () => {
|
||||
matchingTokens.forEach(t => t.status = 'revoked');
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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({
|
||||
next: () => {
|
||||
matchingTokens.forEach(t => t.status = 'revoked');
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { ConsoleAdminApiService, User } from '../services/console-admin-api.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 { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
import { I18nService } from '../../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-list',
|
||||
@@ -18,7 +20,11 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
</header>
|
||||
|
||||
@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) {
|
||||
@@ -51,11 +57,25 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Roles (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="formData.rolesInput"
|
||||
placeholder="role/scanner-viewer,role/policy-operator">
|
||||
<label>Roles</label>
|
||||
<div class="chip-input">
|
||||
<div class="chip-input__chips">
|
||||
@for (role of parsedRoles; track role) {
|
||||
<span class="chip">
|
||||
<code class="chip__text">{{ role }}</code>
|
||||
<button type="button" class="chip__remove" (click)="removeRole(role)" title="Remove">×</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 class="form-actions">
|
||||
<button
|
||||
@@ -75,7 +95,18 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
}
|
||||
|
||||
@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) {
|
||||
<div class="empty-state">No users found</div>
|
||||
} @else {
|
||||
@@ -178,25 +209,6 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -339,13 +351,31 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
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 {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
@@ -353,6 +383,17 @@ import { PageActionService } from '../../../core/services/page-action.service';
|
||||
padding: 2px 4px;
|
||||
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 {
|
||||
@@ -360,6 +401,8 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
users: User[] = [];
|
||||
isLoading = false;
|
||||
@@ -376,6 +419,31 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_USERS_WRITE);
|
||||
}
|
||||
@@ -383,6 +451,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Add User', action: () => this.showCreateForm() });
|
||||
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 {
|
||||
@@ -393,15 +462,23 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listUsers().subscribe({
|
||||
next: (response) => {
|
||||
this.users = response.users;
|
||||
this.api.listUsers().pipe(
|
||||
timeout(15_000),
|
||||
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;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load users: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
return of(null);
|
||||
})
|
||||
).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: '',
|
||||
rolesInput: ''
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
editUser(user: User): void {
|
||||
@@ -425,6 +503,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
tenantId: user.tenantId ?? '',
|
||||
rolesInput: user.roles.join(',')
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
cancelForm(): void {
|
||||
@@ -436,6 +515,7 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
tenantId: '',
|
||||
rolesInput: ''
|
||||
};
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
async createUser(): Promise<void> {
|
||||
@@ -461,10 +541,12 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
this.loadUsers();
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -492,10 +574,12 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
this.loadUsers();
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -510,9 +594,11 @@ export class UsersListComponent implements OnInit, OnDestroy {
|
||||
if (user) {
|
||||
user.status = 'disabled';
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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) {
|
||||
user.status = 'active';
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -569,14 +569,6 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
|
||||
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 */
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td {
|
||||
|
||||
@@ -339,19 +339,6 @@ import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operation
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -70,6 +70,7 @@ interface DeploymentArtifact {
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setTab($event)"
|
||||
>
|
||||
@switch (activeTab()) {
|
||||
|
||||
@@ -38,6 +38,15 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
|
||||
</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">
|
||||
@for (step of remediation.steps; track step.order) {
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -321,4 +349,10 @@ export class RemediationPanelComponent {
|
||||
runFixLabel(): string {
|
||||
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$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +455,18 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -535,10 +639,11 @@
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border-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; }
|
||||
|
||||
@@ -546,19 +651,67 @@
|
||||
border-color: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-brand-primary);
|
||||
|
||||
span {
|
||||
color: var(--color-text-inverse, #fff) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Before a run: plain text colored by severity
|
||||
&.severity-fail span { color: var(--color-status-error); }
|
||||
&.severity-warn span { color: var(--color-status-warning); }
|
||||
&.severity-pass span { color: var(--color-status-success); }
|
||||
&.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 {
|
||||
|
||||
@@ -4,13 +4,13 @@ import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { DoctorStore } from './services/doctor.store';
|
||||
import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from './models/doctor.models';
|
||||
import { SummaryStripComponent } from './components/summary-strip/summary-strip.component';
|
||||
import { CheckResult, CheckMetadata, DoctorCategory, DoctorPluginGroup, DoctorSeverity, RunDoctorRequest } from './models/doctor.models';
|
||||
import { CheckResultComponent } from './components/check-result/check-result.component';
|
||||
import { ExportDialogComponent } from './components/export-dialog/export-dialog.component';
|
||||
import { AppConfigService } from '../../core/config/app-config.service';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
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[] = [
|
||||
{ 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',
|
||||
imports: [
|
||||
FormsModule,
|
||||
SummaryStripComponent,
|
||||
CheckResultComponent,
|
||||
ExportDialogComponent,
|
||||
StellaPageTabsComponent,
|
||||
PageActionOutletComponent,
|
||||
],
|
||||
templateUrl: './doctor-dashboard.component.html',
|
||||
styleUrl: './doctor-dashboard.component.scss'
|
||||
@@ -45,7 +45,7 @@ export class DoctorDashboardComponent implements OnInit, OnDestroy {
|
||||
readonly showExportDialog = signal(false);
|
||||
readonly selectedResult = signal<CheckResult | null>(null);
|
||||
readonly activeTab = signal<DoctorCategory | 'all'>('all');
|
||||
readonly activePackTab = signal<string>('');
|
||||
readonly activePackTab = signal<string>('all');
|
||||
readonly doctorCategoryTabs = DOCTOR_CATEGORY_TABS;
|
||||
|
||||
readonly categories: { value: DoctorCategory | null; label: string }[] = [
|
||||
@@ -75,16 +75,116 @@ export class DoctorDashboardComponent implements OnInit, OnDestroy {
|
||||
{ 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() {
|
||||
// Auto-select first pack tab when pack groups load
|
||||
// Auto-select 'all' pack tab when pack groups load
|
||||
effect(() => {
|
||||
const groups = this.store.packGroups();
|
||||
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[]>(() => {
|
||||
return DOCTOR_CATEGORY_TABS.map(tab => {
|
||||
const catStatus = this.getCategoryStatus(tab.id as DoctorCategory | 'all');
|
||||
@@ -212,6 +312,68 @@ export class DoctorDashboardComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
return result.checkId;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface Evidence {
|
||||
export interface Remediation {
|
||||
requiresBackup: boolean;
|
||||
safetyNote?: string;
|
||||
runbookUrl?: string;
|
||||
steps: RemediationStep[];
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ interface GateSummary {
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setTab($any($event))"
|
||||
>
|
||||
@switch (activeTab()) {
|
||||
|
||||
@@ -58,6 +58,7 @@ const EXPORT_CENTER_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="exportCenterTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Export center tabs"
|
||||
/>
|
||||
|
||||
@@ -199,9 +199,6 @@ interface EvidencePacket {
|
||||
.filter-bar__input {
|
||||
width: 100%;
|
||||
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 {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
|
||||
@@ -83,6 +83,7 @@ const PACKET_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="PACKET_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setTab($any($event))"
|
||||
ariaLabel="Evidence packet tabs"
|
||||
>
|
||||
|
||||
@@ -282,32 +282,8 @@
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.severity-options {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FeedVersionLockComponent } from './feed-version-lock.component';
|
||||
import { SyncStatusIndicatorComponent } from './sync-status-indicator.component';
|
||||
import { FreshnessWarningsComponent } from './freshness-warnings.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';
|
||||
|
||||
@@ -54,6 +55,7 @@ const FEED_MIRROR_TABS: readonly StellaPageTab[] = [
|
||||
SyncStatusIndicatorComponent,
|
||||
FreshnessWarningsComponent,
|
||||
StellaPageTabsComponent,
|
||||
PageActionOutletComponent,
|
||||
],
|
||||
template: `
|
||||
<section class="feed-mirror-dashboard">
|
||||
@@ -85,9 +87,11 @@ const FEED_MIRROR_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="FEED_MIRROR_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setActiveTab($any($event))"
|
||||
ariaLabel="Feed mirror sections"
|
||||
>
|
||||
<app-page-action-outlet tabBarAction />
|
||||
|
||||
<!-- Summary Stats -->
|
||||
@if (activeTab() === 'mirrors' && !loading()) {
|
||||
|
||||
@@ -154,6 +154,7 @@ const SIDE_PANEL_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="SIDE_PANEL_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setActiveTab($any($event))"
|
||||
ariaLabel="Graph side panels"
|
||||
>
|
||||
|
||||
@@ -191,11 +191,6 @@ export type ActivityEventType =
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.filter-group label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-select, .filter-input {
|
||||
padding: 0.5rem;
|
||||
|
||||
@@ -76,6 +76,7 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="HUB_DETAIL_TABS"
|
||||
[activeTab]="activeTab"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab = $any($event)"
|
||||
ariaLabel="Integration detail tabs"
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Integration tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
|
||||
@@ -106,13 +106,6 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@@ -153,13 +153,6 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface MobileSection {
|
||||
<stella-page-tabs
|
||||
[tabs]="MOBILE_COMPARE_TABS"
|
||||
[activeTab]="currentView()"
|
||||
urlParam="tab"
|
||||
(tabChange)="setView($any($event))"
|
||||
ariaLabel="Mobile compare views"
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,7 @@ const VEX_DIFF_TABS: StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="VEX_DIFF_TABS"
|
||||
[activeTab]="activeGroup()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeGroup.set($any($event))"
|
||||
ariaLabel="VEX diff groups"
|
||||
/>
|
||||
|
||||
@@ -48,9 +48,11 @@
|
||||
<stella-page-tabs
|
||||
[tabs]="notifyTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Notification operations tabs"
|
||||
>
|
||||
<app-page-action-outlet tabBarAction />
|
||||
@switch (activeTab()) {
|
||||
<!-- ═══ Channels Tab ═══ -->
|
||||
@case ('channels') {
|
||||
|
||||
@@ -35,6 +35,7 @@ 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 NotifyTab = 'channels' | 'rules' | 'deliveries';
|
||||
|
||||
@@ -67,7 +68,7 @@ type DeliveryFilter =
|
||||
|
||||
@Component({
|
||||
selector: 'app-notify-panel',
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink, StellaPageTabsComponent],
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink, StellaPageTabsComponent, PageActionOutletComponent],
|
||||
templateUrl: './notify-panel.component.html',
|
||||
styleUrls: ['./notify-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -513,13 +513,6 @@ interface TrustAnchor {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
|
||||
@@ -54,6 +54,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Offline kit tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
@@ -140,7 +140,7 @@ interface DlqItem {
|
||||
|
||||
.buckets button.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: rgba(245, 166, 35, 0.08);
|
||||
background: var(--color-brand-soft);
|
||||
}
|
||||
|
||||
.table {
|
||||
|
||||
@@ -23,6 +23,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Feeds & Offline sections"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
@@ -42,6 +42,7 @@ type FeedsAirgapAction = 'import' | 'export' | null;
|
||||
<stella-page-tabs
|
||||
[tabs]="feedsTabs"
|
||||
[activeTab]="tab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="tab.set($any($event))"
|
||||
ariaLabel="Feeds and airgap sections"
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeSubview()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Audit tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
@@ -60,6 +60,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeSubview()"
|
||||
urlParam="tab"
|
||||
ariaLabel="VEX tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
@@ -64,6 +64,7 @@ const PACK_DETAIL_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs()"
|
||||
[activeTab]="activeSubview()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Pack tabs"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
@@ -38,6 +38,7 @@ const GOVERNANCE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="GOVERNANCE_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="onTabChange($any($event))"
|
||||
ariaLabel="Policy governance sections"
|
||||
>
|
||||
|
||||
@@ -563,22 +563,6 @@ import {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -237,23 +237,6 @@ import {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -267,22 +267,6 @@ import {
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -397,12 +397,6 @@ import {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
|
||||
@@ -63,6 +63,7 @@ const POLICY_SIM_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="POLICY_SIM_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Policy simulation studio sections"
|
||||
>
|
||||
|
||||
@@ -300,22 +300,6 @@ import { ShadowModeStateService } from './shadow-mode-state.service';
|
||||
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 {
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, var(--color-status-excepted), var(--color-status-info));
|
||||
|
||||
@@ -168,6 +168,7 @@ const SIMULATION_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="SIMULATION_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="onTabChange($any($event))"
|
||||
ariaLabel="Policy simulation sections"
|
||||
>
|
||||
|
||||
@@ -464,22 +464,6 @@ import {
|
||||
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 {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -56,6 +56,7 @@ const POLICY_STUDIO_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="policyTabs"
|
||||
[activeTab]="viewMode()"
|
||||
urlParam="tab"
|
||||
(tabChange)="viewMode.set($any($event))"
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -327,20 +327,6 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
@@ -97,6 +97,7 @@ const PROMOTION_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="tabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeTab.set($any($event))"
|
||||
ariaLabel="Promotion detail tabs"
|
||||
>
|
||||
|
||||
@@ -282,24 +282,6 @@ interface PromotionRow {
|
||||
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 {
|
||||
padding: 1rem;
|
||||
background: linear-gradient(90deg, var(--color-surface-alt, #f9fafb) 25%, var(--color-surface-primary, #fff) 50%, var(--color-surface-alt, #f9fafb) 75%);
|
||||
|
||||
@@ -41,6 +41,7 @@ const SCORE_COMPARISON_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="scoreTabs"
|
||||
[activeTab]="viewMode()"
|
||||
urlParam="tab"
|
||||
(tabChange)="viewMode.set($any($event))"
|
||||
ariaLabel="Score comparison view mode"
|
||||
>
|
||||
|
||||
@@ -278,19 +278,6 @@ import { quotasPath } from '../platform/ops/operations-paths';
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -256,18 +256,6 @@ import { quotasPath } from '../platform/ops/operations-paths';
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -398,19 +398,6 @@ import { quotaTenantPath, quotasPath } from '../platform/ops/operations-paths';
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
@@ -53,6 +53,7 @@ type TabType = 'plans' | 'audit';
|
||||
<stella-page-tabs
|
||||
[tabs]="REGISTRY_ADMIN_TABS"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
(tabChange)="onTabChange($any($event))"
|
||||
ariaLabel="Registry admin sections"
|
||||
>
|
||||
|
||||
@@ -734,30 +734,10 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.required {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user