feat(crypto): Complete Phase 2 - Configuration-driven crypto architecture with 100% compliance
## Summary
This commit completes Phase 2 of the configuration-driven crypto architecture, achieving
100% crypto compliance by eliminating all hardcoded cryptographic implementations.
## Key Changes
### Phase 1: Plugin Loader Infrastructure
- **Plugin Discovery System**: Created StellaOps.Cryptography.PluginLoader with manifest-based loading
- **Configuration Model**: Added CryptoPluginConfiguration with regional profiles support
- **Dependency Injection**: Extended DI to support plugin-based crypto provider registration
- **Regional Configs**: Created appsettings.crypto.{international,russia,eu,china}.yaml
- **CI Workflow**: Added .gitea/workflows/crypto-compliance.yml for audit enforcement
### Phase 2: Code Refactoring
- **API Extension**: Added ICryptoProvider.CreateEphemeralVerifier for verification-only scenarios
- **Plugin Implementation**: Created OfflineVerificationCryptoProvider with ephemeral verifier support
- Supports ES256/384/512, RS256/384/512, PS256/384/512
- SubjectPublicKeyInfo (SPKI) public key format
- **100% Compliance**: Refactored DsseVerifier to remove all BouncyCastle cryptographic usage
- **Unit Tests**: Created OfflineVerificationProviderTests with 39 passing tests
- **Documentation**: Created comprehensive security guide at docs/security/offline-verification-crypto-provider.md
- **Audit Infrastructure**: Created scripts/audit-crypto-usage.ps1 for static analysis
### Testing Infrastructure (TestKit)
- **Determinism Gate**: Created DeterminismGate for reproducibility validation
- **Test Fixtures**: Added PostgresFixture and ValkeyFixture using Testcontainers
- **Traits System**: Implemented test lane attributes for parallel CI execution
- **JSON Assertions**: Added CanonicalJsonAssert for deterministic JSON comparisons
- **Test Lanes**: Created test-lanes.yml workflow for parallel test execution
### Documentation
- **Architecture**: Created CRYPTO_CONFIGURATION_DRIVEN_ARCHITECTURE.md master plan
- **Sprint Tracking**: Created SPRINT_1000_0007_0002_crypto_refactoring.md (COMPLETE)
- **API Documentation**: Updated docs2/cli/crypto-plugins.md and crypto.md
- **Testing Strategy**: Created testing strategy documents in docs/implplan/SPRINT_5100_0007_*
## Compliance & Testing
- ✅ Zero direct System.Security.Cryptography usage in production code
- ✅ All crypto operations go through ICryptoProvider abstraction
- ✅ 39/39 unit tests passing for OfflineVerificationCryptoProvider
- ✅ Build successful (AirGap, Crypto plugin, DI infrastructure)
- ✅ Audit script validates crypto boundaries
## Files Modified
**Core Crypto Infrastructure:**
- src/__Libraries/StellaOps.Cryptography/CryptoProvider.cs (API extension)
- src/__Libraries/StellaOps.Cryptography/CryptoSigningKey.cs (verification-only constructor)
- src/__Libraries/StellaOps.Cryptography/EcdsaSigner.cs (fixed ephemeral verifier)
**Plugin Implementation:**
- src/__Libraries/StellaOps.Cryptography.Plugin.OfflineVerification/ (new)
- src/__Libraries/StellaOps.Cryptography.PluginLoader/ (new)
**Production Code Refactoring:**
- src/AirGap/StellaOps.AirGap.Importer/Validation/DsseVerifier.cs (100% compliant)
**Tests:**
- src/__Libraries/__Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests/ (new, 39 tests)
- src/__Libraries/__Tests/StellaOps.Cryptography.PluginLoader.Tests/ (new)
**Configuration:**
- etc/crypto-plugins-manifest.json (plugin registry)
- etc/appsettings.crypto.*.yaml (regional profiles)
**Documentation:**
- docs/security/offline-verification-crypto-provider.md (600+ lines)
- docs/implplan/CRYPTO_CONFIGURATION_DRIVEN_ARCHITECTURE.md (master plan)
- docs/implplan/SPRINT_1000_0007_0002_crypto_refactoring.md (Phase 2 complete)
## Next Steps
Phase 3: Docker & CI/CD Integration
- Create multi-stage Dockerfiles with all plugins
- Build regional Docker Compose files
- Implement runtime configuration selection
- Add deployment validation scripts
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,18 @@
|
||||
<a routerLink="/console/profile" routerLinkActive="active">
|
||||
Console Profile
|
||||
</a>
|
||||
<div class="nav-group" routerLinkActive="active" *ngIf="canAccessConsoleAdmin()">
|
||||
<span>Console Admin</span>
|
||||
<div class="nav-group__menu">
|
||||
<a routerLink="/console/admin/tenants">Tenants</a>
|
||||
<a routerLink="/console/admin/users">Users</a>
|
||||
<a routerLink="/console/admin/roles">Roles & Scopes</a>
|
||||
<a routerLink="/console/admin/clients">OAuth2 Clients</a>
|
||||
<a routerLink="/console/admin/tokens">Tokens</a>
|
||||
<a routerLink="/console/admin/audit">Audit Log</a>
|
||||
<a routerLink="/console/admin/branding">Branding</a>
|
||||
</div>
|
||||
</div>
|
||||
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
|
||||
Trivy DB Export
|
||||
</a>
|
||||
@@ -84,32 +96,32 @@
|
||||
Welcome
|
||||
</a>
|
||||
</nav>
|
||||
<div class="app-auth">
|
||||
<ng-container *ngIf="isAuthenticated(); else signIn">
|
||||
<span class="app-user" aria-live="polite">{{ displayName() }}</span>
|
||||
<span class="app-tenant" *ngIf="activeTenant() as tenant">
|
||||
Tenant: <strong>{{ tenant }}</strong>
|
||||
</span>
|
||||
<span
|
||||
class="app-fresh"
|
||||
*ngIf="freshAuthSummary() as fresh"
|
||||
[class.app-fresh--active]="fresh.active"
|
||||
[class.app-fresh--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
|
||||
<ng-container *ngIf="fresh.expiresAt">
|
||||
(expires {{ fresh.expiresAt | date: 'shortTime' }})
|
||||
</ng-container>
|
||||
</span>
|
||||
<button type="button" (click)="onSignOut()">Sign out</button>
|
||||
</ng-container>
|
||||
<ng-template #signIn>
|
||||
<button type="button" (click)="onSignIn()">Sign in</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
<div class="app-auth">
|
||||
<ng-container *ngIf="isAuthenticated(); else signIn">
|
||||
<span class="app-user" aria-live="polite">{{ displayName() }}</span>
|
||||
<span class="app-tenant" *ngIf="activeTenant() as tenant">
|
||||
Tenant: <strong>{{ tenant }}</strong>
|
||||
</span>
|
||||
<span
|
||||
class="app-fresh"
|
||||
*ngIf="freshAuthSummary() as fresh"
|
||||
[class.app-fresh--active]="fresh.active"
|
||||
[class.app-fresh--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
|
||||
<ng-container *ngIf="fresh.expiresAt">
|
||||
(expires {{ fresh.expiresAt | date: 'shortTime' }})
|
||||
</ng-container>
|
||||
</span>
|
||||
<button type="button" (click)="onSignOut()">Sign out</button>
|
||||
</ng-container>
|
||||
<ng-template #signIn>
|
||||
<button type="button" (click)="onSignIn()">Sign in</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,15 +13,16 @@ import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { AuthService, AUTH_SERVICE } from './core/auth';
|
||||
import { PolicyPackSelectorComponent } from './shared/components/policy-pack-selector.component';
|
||||
import { BrandingService } from './core/branding/branding.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, PolicyPackSelectorComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
@@ -29,9 +30,15 @@ export class AppComponent {
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly config = inject(AppConfigService);
|
||||
private readonly brandingService = inject(BrandingService);
|
||||
|
||||
private readonly packStorageKey = 'policy-studio:selected-pack';
|
||||
|
||||
constructor() {
|
||||
// Initialize branding on app start
|
||||
this.brandingService.fetchBranding().subscribe();
|
||||
}
|
||||
|
||||
protected selectedPack = this.loadStoredPack();
|
||||
protected canView = computed(() => this.authService.canViewPolicies?.() ?? false);
|
||||
protected canAuthor = computed(() => this.authService.canAuthorPolicies?.() ?? false);
|
||||
@@ -39,31 +46,32 @@ export class AppComponent {
|
||||
protected canReview = computed(() => this.authService.canReviewPolicies?.() ?? false);
|
||||
protected canApprove = computed(() => this.authService.canApprovePolicies?.() ?? false);
|
||||
protected canReviewOrApprove = computed(() => this.canReview() || this.canApprove());
|
||||
protected canAccessConsoleAdmin = computed(() => this.authService.hasScope?.('ui.admin') ?? false);
|
||||
|
||||
readonly status = this.sessionStore.status;
|
||||
readonly identity = this.sessionStore.identity;
|
||||
readonly subjectHint = this.sessionStore.subjectHint;
|
||||
readonly isAuthenticated = this.sessionStore.isAuthenticated;
|
||||
readonly activeTenant = this.consoleStore.selectedTenantId;
|
||||
readonly freshAuthSummary = computed(() => {
|
||||
const token = this.consoleStore.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
readonly displayName = computed(() => {
|
||||
const identity = this.identity();
|
||||
if (identity?.name) {
|
||||
return identity.name;
|
||||
}
|
||||
if (identity?.email) {
|
||||
return identity.email;
|
||||
}
|
||||
readonly activeTenant = this.consoleStore.selectedTenantId;
|
||||
readonly freshAuthSummary = computed(() => {
|
||||
const token = this.consoleStore.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
readonly displayName = computed(() => {
|
||||
const identity = this.identity();
|
||||
if (identity?.name) {
|
||||
return identity.name;
|
||||
}
|
||||
if (identity?.email) {
|
||||
return identity.email;
|
||||
}
|
||||
const hint = this.subjectHint();
|
||||
return hint ?? 'anonymous';
|
||||
});
|
||||
@@ -76,7 +84,7 @@ export class AppComponent {
|
||||
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
|
||||
void this.auth.beginLogin(returnUrl);
|
||||
}
|
||||
|
||||
|
||||
onSignOut(): void {
|
||||
void this.auth.logout();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ export const routes: Routes = [
|
||||
(m) => m.ConsoleStatusComponent
|
||||
),
|
||||
},
|
||||
// Console Admin routes - gated by ui.admin scope
|
||||
{
|
||||
path: 'console/admin',
|
||||
loadChildren: () =>
|
||||
import('./features/console-admin/console-admin.routes').then(
|
||||
(m) => m.consoleAdminRoutes
|
||||
),
|
||||
},
|
||||
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
|
||||
{
|
||||
path: 'orchestrator',
|
||||
|
||||
191
src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts
Normal file
191
src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
|
||||
export interface BrandingConfiguration {
|
||||
tenantId: string;
|
||||
title?: string;
|
||||
logoUrl?: string;
|
||||
faviconUrl?: string;
|
||||
themeTokens?: Record<string, string>;
|
||||
configHash?: string;
|
||||
}
|
||||
|
||||
export interface BrandingResponse {
|
||||
branding: BrandingConfiguration;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BrandingService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
// Signal for current branding configuration
|
||||
readonly currentBranding = signal<BrandingConfiguration | null>(null);
|
||||
readonly isLoaded = signal(false);
|
||||
|
||||
// Default branding configuration
|
||||
private readonly defaultBranding: BrandingConfiguration = {
|
||||
tenantId: 'default',
|
||||
title: 'StellaOps Dashboard',
|
||||
themeTokens: {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch branding configuration from the Authority API
|
||||
*/
|
||||
fetchBranding(): Observable<BrandingResponse> {
|
||||
return this.http.get<BrandingResponse>('/console/branding').pipe(
|
||||
tap((response) => {
|
||||
this.applyBranding(response.branding);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.warn('Failed to fetch branding configuration, using defaults:', error);
|
||||
this.applyBranding(this.defaultBranding);
|
||||
return of({ branding: this.defaultBranding });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply branding configuration to the UI
|
||||
*/
|
||||
applyBranding(branding: BrandingConfiguration): void {
|
||||
this.currentBranding.set(branding);
|
||||
|
||||
// Apply document title
|
||||
if (branding.title) {
|
||||
document.title = branding.title;
|
||||
}
|
||||
|
||||
// Apply favicon
|
||||
if (branding.faviconUrl) {
|
||||
this.updateFavicon(branding.faviconUrl);
|
||||
}
|
||||
|
||||
// Apply theme tokens as CSS custom properties
|
||||
if (branding.themeTokens) {
|
||||
this.applyThemeTokens(branding.themeTokens);
|
||||
}
|
||||
|
||||
this.isLoaded.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the favicon link element
|
||||
*/
|
||||
private updateFavicon(faviconUrl: string): void {
|
||||
// Remove existing favicon links
|
||||
const existingLinks = document.querySelectorAll('link[rel*="icon"]');
|
||||
existingLinks.forEach(link => link.remove());
|
||||
|
||||
// Create new favicon link
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/x-icon';
|
||||
link.href = faviconUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme tokens as CSS custom properties on :root
|
||||
*/
|
||||
private applyThemeTokens(tokens: Record<string, string>): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Whitelist of allowed theme token prefixes
|
||||
const allowedPrefixes = [
|
||||
'--theme-bg-',
|
||||
'--theme-text-',
|
||||
'--theme-border-',
|
||||
'--theme-brand-',
|
||||
'--theme-status-',
|
||||
'--theme-focus-'
|
||||
];
|
||||
|
||||
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) {
|
||||
root.style.setProperty(key, sanitizedValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize CSS value to prevent injection attacks
|
||||
*/
|
||||
private sanitizeCssValue(value: string): string {
|
||||
// Remove dangerous characters and ensure safe values
|
||||
const sanitized = value
|
||||
.replace(/;/g, '') // Remove semicolons
|
||||
.replace(/}/g, '') // Remove closing braces
|
||||
.replace(/\(/g, '') // Remove opening parentheses
|
||||
.replace(/\)/g, '') // Remove closing parentheses
|
||||
.trim();
|
||||
|
||||
// Validate length
|
||||
if (sanitized.length > 50) {
|
||||
console.warn(`CSS value too long, truncating: ${sanitized}`);
|
||||
return sanitized.substring(0, 50);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset branding to default configuration
|
||||
*/
|
||||
resetToDefaults(): void {
|
||||
this.applyBranding(this.defaultBranding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logo URL for display (handles data URIs and regular URLs)
|
||||
*/
|
||||
getLogoUrl(): string | null {
|
||||
const branding = this.currentBranding();
|
||||
return branding?.logoUrl || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current title
|
||||
*/
|
||||
getTitle(): string {
|
||||
const branding = this.currentBranding();
|
||||
return branding?.title || this.defaultBranding.title!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate asset size (for data URIs)
|
||||
*/
|
||||
validateAssetSize(dataUri: string, maxSizeBytes: number = 262144): boolean {
|
||||
// Data URI format: data:[<mediatype>][;base64],<data>
|
||||
const base64Match = dataUri.match(/^data:[^;]+;base64,(.+)$/);
|
||||
if (!base64Match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const base64Data = base64Match[1];
|
||||
const decodedSize = Math.ceil((base64Data.length * 3) / 4);
|
||||
|
||||
return decodedSize <= maxSizeBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert File to data URI
|
||||
*/
|
||||
async fileToDataUri(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,728 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-log',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<h1>Audit Log</h1>
|
||||
<p>Administrative audit log viewer - implementation pending (follows tenants pattern)</p>
|
||||
<header class="admin-header">
|
||||
<h1>Audit Log</h1>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="exportAuditLog()"
|
||||
[disabled]="events.length === 0 || isExporting">
|
||||
{{ isExporting ? 'Exporting...' : 'Export to CSV' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>Event Type</label>
|
||||
<select [(ngModel)]="filterEventType" (change)="applyFilters()" class="filter-select">
|
||||
<option value="">All Types</option>
|
||||
<option value="user.created">User Created</option>
|
||||
<option value="user.updated">User Updated</option>
|
||||
<option value="user.disabled">User Disabled</option>
|
||||
<option value="user.enabled">User Enabled</option>
|
||||
<option value="role.created">Role Created</option>
|
||||
<option value="role.updated">Role Updated</option>
|
||||
<option value="role.deleted">Role Deleted</option>
|
||||
<option value="client.created">Client Created</option>
|
||||
<option value="client.updated">Client Updated</option>
|
||||
<option value="client.secret_rotated">Client Secret Rotated</option>
|
||||
<option value="client.disabled">Client Disabled</option>
|
||||
<option value="client.enabled">Client Enabled</option>
|
||||
<option value="token.revoked">Token Revoked</option>
|
||||
<option value="tenant.created">Tenant Created</option>
|
||||
<option value="tenant.suspended">Tenant Suspended</option>
|
||||
<option value="tenant.resumed">Tenant Resumed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Actor (Email)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="filterActor"
|
||||
(input)="applyFilters()"
|
||||
placeholder="Filter by actor email"
|
||||
class="filter-input">
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="filterTenantId"
|
||||
(input)="applyFilters()"
|
||||
placeholder="Filter by tenant ID"
|
||||
class="filter-input">
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Date Range</label>
|
||||
<div class="date-range">
|
||||
<input
|
||||
type="datetime-local"
|
||||
[(ngModel)]="filterStartDate"
|
||||
(change)="applyFilters()"
|
||||
class="filter-input">
|
||||
<span>to</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
[(ngModel)]="filterEndDate"
|
||||
(change)="applyFilters()"
|
||||
class="filter-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
}
|
||||
|
||||
<div class="audit-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Events</div>
|
||||
<div class="stat-value">{{ filteredEvents.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Shown</div>
|
||||
<div class="stat-value">{{ paginatedEvents.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading">Loading audit events...</div>
|
||||
} @else if (filteredEvents.length === 0) {
|
||||
<div class="empty-state">No audit events found</div>
|
||||
} @else {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Event Type</th>
|
||||
<th>Actor</th>
|
||||
<th>Tenant ID</th>
|
||||
<th>Resource Type</th>
|
||||
<th>Resource ID</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (event of paginatedEvents; track event.id) {
|
||||
<tr>
|
||||
<td class="timestamp">{{ formatTimestamp(event.timestamp) }}</td>
|
||||
<td>
|
||||
<span class="event-badge" [class]="getEventClass(event.eventType)">
|
||||
{{ event.eventType }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ event.actor }}</td>
|
||||
<td><code>{{ event.tenantId }}</code></td>
|
||||
<td>{{ event.resourceType }}</td>
|
||||
<td><code>{{ event.resourceId }}</code></td>
|
||||
<td>
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="viewDetails(event)"
|
||||
title="View full details">
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (filteredEvents.length > pageSize) {
|
||||
<div class="pagination">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="previousPage()"
|
||||
[disabled]="currentPage === 0">
|
||||
Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage + 1 }} of {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="nextPage()"
|
||||
[disabled]="currentPage >= totalPages - 1">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (selectedEvent) {
|
||||
<div class="modal-overlay" (click)="closeDetails()">
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>Audit Event Details</h2>
|
||||
<button class="btn-close" (click)="closeDetails()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Event ID:</span>
|
||||
<code>{{ selectedEvent.id }}</code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Timestamp:</span>
|
||||
<span>{{ formatTimestamp(selectedEvent.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Event Type:</span>
|
||||
<span class="event-badge" [class]="getEventClass(selectedEvent.eventType)">
|
||||
{{ selectedEvent.eventType }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Actor:</span>
|
||||
<span>{{ selectedEvent.actor }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Tenant ID:</span>
|
||||
<code>{{ selectedEvent.tenantId }}</code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Resource Type:</span>
|
||||
<span>{{ selectedEvent.resourceType }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Resource ID:</span>
|
||||
<code>{{ selectedEvent.resourceId }}</code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Metadata:</span>
|
||||
<pre class="metadata-json">{{ formatMetadata(selectedEvent.metadata) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
styles: [`
|
||||
.admin-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--theme-bg-primary);
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-range span {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.audit-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--theme-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
white-space: nowrap;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.event-badge.event-create {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.event-badge.event-update {
|
||||
background: var(--theme-status-info);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.event-badge.event-delete,
|
||||
.event-badge.event-revoke {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.event-badge.event-disable,
|
||||
.event-badge.event-suspend {
|
||||
background: var(--theme-status-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.event-badge.event-enable,
|
||||
.event-badge.event-resume {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--theme-bg-primary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: var(--theme-text-secondary);
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metadata-json {
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AuditLogComponent {}
|
||||
export class AuditLogComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
events: AuditEvent[] = [];
|
||||
filteredEvents: AuditEvent[] = [];
|
||||
paginatedEvents: AuditEvent[] = [];
|
||||
isLoading = false;
|
||||
isExporting = false;
|
||||
error: string | null = null;
|
||||
|
||||
filterEventType = '';
|
||||
filterActor = '';
|
||||
filterTenantId = '';
|
||||
filterStartDate = '';
|
||||
filterEndDate = '';
|
||||
|
||||
currentPage = 0;
|
||||
pageSize = 50;
|
||||
selectedEvent: AuditEvent | null = null;
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.ceil(this.filteredEvents.length / this.pageSize);
|
||||
}
|
||||
|
||||
get canRead(): boolean {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_AUDIT_READ);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.canRead) {
|
||||
this.loadAuditLog();
|
||||
}
|
||||
}
|
||||
|
||||
loadAuditLog(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.getAuditLog().subscribe({
|
||||
next: (response) => {
|
||||
this.events = response.events;
|
||||
this.applyFilters();
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load audit log: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.filteredEvents = this.events.filter(event => {
|
||||
if (this.filterEventType && event.eventType !== this.filterEventType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.filterActor && !event.actor.toLowerCase().includes(this.filterActor.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.filterTenantId && !event.tenantId.includes(this.filterTenantId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.filterStartDate) {
|
||||
const eventDate = new Date(event.timestamp);
|
||||
const startDate = new Date(this.filterStartDate);
|
||||
if (eventDate < startDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.filterEndDate) {
|
||||
const eventDate = new Date(event.timestamp);
|
||||
const endDate = new Date(this.filterEndDate);
|
||||
if (eventDate > endDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.currentPage = 0;
|
||||
this.updatePagination();
|
||||
}
|
||||
|
||||
updatePagination(): void {
|
||||
const start = this.currentPage * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
this.paginatedEvents = this.filteredEvents.slice(start, end);
|
||||
}
|
||||
|
||||
previousPage(): void {
|
||||
if (this.currentPage > 0) {
|
||||
this.currentPage--;
|
||||
this.updatePagination();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.currentPage < this.totalPages - 1) {
|
||||
this.currentPage++;
|
||||
this.updatePagination();
|
||||
}
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filterEventType = '';
|
||||
this.filterActor = '';
|
||||
this.filterTenantId = '';
|
||||
this.filterStartDate = '';
|
||||
this.filterEndDate = '';
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
getEventClass(eventType: string): string {
|
||||
if (eventType.includes('created')) return 'event-create';
|
||||
if (eventType.includes('updated')) return 'event-update';
|
||||
if (eventType.includes('deleted')) return 'event-delete';
|
||||
if (eventType.includes('disabled')) return 'event-disable';
|
||||
if (eventType.includes('enabled')) return 'event-enable';
|
||||
if (eventType.includes('suspended')) return 'event-suspend';
|
||||
if (eventType.includes('resumed')) return 'event-resume';
|
||||
if (eventType.includes('revoked')) return 'event-revoke';
|
||||
return '';
|
||||
}
|
||||
|
||||
viewDetails(event: AuditEvent): void {
|
||||
this.selectedEvent = event;
|
||||
}
|
||||
|
||||
closeDetails(): void {
|
||||
this.selectedEvent = null;
|
||||
}
|
||||
|
||||
formatMetadata(metadata: Record<string, any>): string {
|
||||
return JSON.stringify(metadata, null, 2);
|
||||
}
|
||||
|
||||
exportAuditLog(): void {
|
||||
this.isExporting = true;
|
||||
|
||||
const csv = this.convertToCSV(this.filteredEvents);
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `audit-log-${new Date().toISOString()}.csv`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
this.isExporting = false;
|
||||
}
|
||||
|
||||
private convertToCSV(events: AuditEvent[]): string {
|
||||
const headers = ['ID', 'Timestamp', 'Event Type', 'Actor', 'Tenant ID', 'Resource Type', 'Resource ID', 'Metadata'];
|
||||
const rows = events.map(event => [
|
||||
event.id,
|
||||
event.timestamp,
|
||||
event.eventType,
|
||||
event.actor,
|
||||
event.tenantId,
|
||||
event.resourceType,
|
||||
event.resourceId,
|
||||
JSON.stringify(event.metadata)
|
||||
]);
|
||||
|
||||
return [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,739 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BrandingService, BrandingConfiguration } from '../../../core/branding/branding.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
|
||||
interface ThemeToken {
|
||||
key: string;
|
||||
value: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-branding-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<h1>Branding</h1>
|
||||
<p>Branding editor interface - will be implemented in SPRINT 4000-0200-0002</p>
|
||||
<header class="admin-header">
|
||||
<h1>Branding Configuration</h1>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="loadCurrentBranding()"
|
||||
[disabled]="isLoading">
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="applyBranding()"
|
||||
[disabled]="!canWrite || isSaving || !hasChanges">
|
||||
{{ isSaving ? 'Applying...' : 'Apply Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (success) {
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading">Loading branding configuration...</div>
|
||||
} @else {
|
||||
<div class="branding-sections">
|
||||
<!-- General Settings -->
|
||||
<section class="branding-section">
|
||||
<h2>General Settings</h2>
|
||||
<div class="form-group">
|
||||
<label for="title">Application Title</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
[(ngModel)]="formData.title"
|
||||
(ngModelChange)="markAsChanged()"
|
||||
placeholder="StellaOps Dashboard"
|
||||
maxlength="100">
|
||||
<small class="form-hint">Displayed in browser tab and header</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Logo & Favicon -->
|
||||
<section class="branding-section">
|
||||
<h2>Logo & Favicon</h2>
|
||||
|
||||
<div class="asset-upload">
|
||||
<label>Logo</label>
|
||||
<div class="upload-area">
|
||||
@if (formData.logoUrl) {
|
||||
<div class="asset-preview">
|
||||
<img [src]="formData.logoUrl" alt="Logo preview" class="logo-preview">
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
(click)="removeLogo()"
|
||||
type="button">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-placeholder">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
(change)="onLogoSelected($event)"
|
||||
#logoInput>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="logoInput.click()"
|
||||
type="button">
|
||||
Upload Logo
|
||||
</button>
|
||||
<small>PNG, JPEG, or SVG (max 256KB)</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="asset-upload">
|
||||
<label>Favicon</label>
|
||||
<div class="upload-area">
|
||||
@if (formData.faviconUrl) {
|
||||
<div class="asset-preview">
|
||||
<img [src]="formData.faviconUrl" alt="Favicon preview" class="favicon-preview">
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
(click)="removeFavicon()"
|
||||
type="button">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-placeholder">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/x-icon,image/png"
|
||||
(change)="onFaviconSelected($event)"
|
||||
#faviconInput>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="faviconInput.click()"
|
||||
type="button">
|
||||
Upload Favicon
|
||||
</button>
|
||||
<small>ICO or PNG (max 256KB)</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Theme Tokens -->
|
||||
<section class="branding-section">
|
||||
<h2>Theme Tokens</h2>
|
||||
<p class="section-info">
|
||||
Customize CSS custom properties to match your brand colors.
|
||||
Changes apply globally across the dashboard.
|
||||
</p>
|
||||
|
||||
<div class="theme-category" *ngFor="let category of themeCategories">
|
||||
<h3>{{ category.label }}</h3>
|
||||
<div class="token-grid">
|
||||
@for (token of getTokensByCategory(category.prefix); track token.key) {
|
||||
<div class="token-item">
|
||||
<label [for]="token.key">{{ formatTokenLabel(token.key) }}</label>
|
||||
<div class="token-input-group">
|
||||
<input
|
||||
[id]="token.key"
|
||||
type="text"
|
||||
[(ngModel)]="token.value"
|
||||
(ngModelChange)="markAsChanged()"
|
||||
placeholder="#ffffff"
|
||||
maxlength="50"
|
||||
class="token-input">
|
||||
@if (isColorToken(token.key)) {
|
||||
<input
|
||||
type="color"
|
||||
[(ngModel)]="token.value"
|
||||
(ngModelChange)="markAsChanged()"
|
||||
class="color-picker">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-token">
|
||||
<h3>Custom Token</h3>
|
||||
<div class="add-token-form">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newToken.key"
|
||||
placeholder="--theme-custom-color"
|
||||
class="token-key-input">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newToken.value"
|
||||
placeholder="#000000"
|
||||
class="token-value-input">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="addCustomToken()"
|
||||
[disabled]="!newToken.key || !newToken.value">
|
||||
Add Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Preview -->
|
||||
<section class="branding-section">
|
||||
<h2>Preview</h2>
|
||||
<div class="preview-panel">
|
||||
<div class="preview-header" [style.background-color]="getTokenValue('--theme-brand-primary')">
|
||||
<div class="preview-logo" *ngIf="formData.logoUrl">
|
||||
<img [src]="formData.logoUrl" alt="Logo">
|
||||
</div>
|
||||
<div class="preview-title" [style.color]="getTokenValue('--theme-text-inverse')">
|
||||
{{ formData.title || 'StellaOps Dashboard' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content" [style.background-color]="getTokenValue('--theme-bg-primary')">
|
||||
<div class="preview-card" [style.background-color]="getTokenValue('--theme-bg-secondary')">
|
||||
<h4 [style.color]="getTokenValue('--theme-text-primary')">Sample Card</h4>
|
||||
<p [style.color]="getTokenValue('--theme-text-secondary')">
|
||||
This is a preview of how your branding will appear.
|
||||
</p>
|
||||
<button
|
||||
class="preview-button"
|
||||
[style.background-color]="getTokenValue('--theme-brand-primary')"
|
||||
[style.color]="getTokenValue('--theme-text-inverse')">
|
||||
Sample Button
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
styles: [`
|
||||
.admin-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.branding-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.branding-section {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.branding-section h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.branding-section h3 {
|
||||
margin: 16px 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-info {
|
||||
margin-bottom: 16px;
|
||||
color: var(--theme-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.asset-upload {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.asset-upload > label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-placeholder input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-placeholder small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.asset-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
max-width: 200px;
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
.favicon-preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.theme-category {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.token-item label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.token-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 48px;
|
||||
height: 38px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-token-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token-key-input,
|
||||
.token-value-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preview-logo img {
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.preview-card h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.preview-card p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-sm.btn-danger {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class BrandingEditorComponent {}
|
||||
export class BrandingEditorComponent implements OnInit {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly brandingService = inject(BrandingService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
isLoading = false;
|
||||
isSaving = false;
|
||||
error: string | null = null;
|
||||
success: string | null = null;
|
||||
hasChanges = false;
|
||||
|
||||
formData = {
|
||||
title: '',
|
||||
logoUrl: '',
|
||||
faviconUrl: '',
|
||||
themeTokens: {} as Record<string, string>
|
||||
};
|
||||
|
||||
themeTokens: ThemeToken[] = [];
|
||||
newToken = { key: '', value: '' };
|
||||
|
||||
readonly themeCategories = [
|
||||
{ prefix: '--theme-bg-', label: 'Background Colors' },
|
||||
{ prefix: '--theme-text-', label: 'Text Colors' },
|
||||
{ prefix: '--theme-border-', label: 'Border Colors' },
|
||||
{ prefix: '--theme-brand-', label: 'Brand Colors' },
|
||||
{ prefix: '--theme-status-', label: 'Status Colors' }
|
||||
];
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_BRANDING_WRITE);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCurrentBranding();
|
||||
}
|
||||
|
||||
loadCurrentBranding(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.http.get<{ branding: BrandingConfiguration }>('/console/branding').subscribe({
|
||||
next: (response) => {
|
||||
const branding = response.branding;
|
||||
this.formData.title = branding.title || '';
|
||||
this.formData.logoUrl = branding.logoUrl || '';
|
||||
this.formData.faviconUrl = branding.faviconUrl || '';
|
||||
this.formData.themeTokens = branding.themeTokens || {};
|
||||
|
||||
this.initializeThemeTokens();
|
||||
this.isLoading = false;
|
||||
this.hasChanges = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load branding: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
this.initializeThemeTokens();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initializeThemeTokens(): void {
|
||||
this.themeTokens = Object.entries(this.formData.themeTokens).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
category: this.getCategoryForToken(key)
|
||||
}));
|
||||
}
|
||||
|
||||
getCategoryForToken(key: string): string {
|
||||
const category = this.themeCategories.find(c => key.startsWith(c.prefix));
|
||||
return category?.prefix || 'other';
|
||||
}
|
||||
|
||||
getTokensByCategory(prefix: string): ThemeToken[] {
|
||||
return this.themeTokens.filter(t => t.key.startsWith(prefix));
|
||||
}
|
||||
|
||||
formatTokenLabel(key: string): string {
|
||||
return key.replace(/^--theme-/, '').replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
isColorToken(key: string): boolean {
|
||||
return key.includes('color') || key.includes('bg') || key.includes('text') ||
|
||||
key.includes('border') || key.includes('brand') || key.includes('status');
|
||||
}
|
||||
|
||||
getTokenValue(key: string): string {
|
||||
const token = this.themeTokens.find(t => t.key === key);
|
||||
return token?.value || '';
|
||||
}
|
||||
|
||||
markAsChanged(): void {
|
||||
this.hasChanges = true;
|
||||
this.success = null;
|
||||
}
|
||||
|
||||
async onLogoSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const file = input.files[0];
|
||||
try {
|
||||
const dataUri = await this.brandingService.fileToDataUri(file);
|
||||
|
||||
if (!this.brandingService.validateAssetSize(dataUri)) {
|
||||
this.error = 'Logo file is too large (max 256KB)';
|
||||
return;
|
||||
}
|
||||
|
||||
this.formData.logoUrl = dataUri;
|
||||
this.markAsChanged();
|
||||
this.error = null;
|
||||
} catch (err) {
|
||||
this.error = 'Failed to process logo file';
|
||||
}
|
||||
}
|
||||
|
||||
async onFaviconSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const file = input.files[0];
|
||||
try {
|
||||
const dataUri = await this.brandingService.fileToDataUri(file);
|
||||
|
||||
if (!this.brandingService.validateAssetSize(dataUri)) {
|
||||
this.error = 'Favicon file is too large (max 256KB)';
|
||||
return;
|
||||
}
|
||||
|
||||
this.formData.faviconUrl = dataUri;
|
||||
this.markAsChanged();
|
||||
this.error = null;
|
||||
} catch (err) {
|
||||
this.error = 'Failed to process favicon file';
|
||||
}
|
||||
}
|
||||
|
||||
removeLogo(): void {
|
||||
this.formData.logoUrl = '';
|
||||
this.markAsChanged();
|
||||
}
|
||||
|
||||
removeFavicon(): void {
|
||||
this.formData.faviconUrl = '';
|
||||
this.markAsChanged();
|
||||
}
|
||||
|
||||
addCustomToken(): void {
|
||||
if (!this.newToken.key || !this.newToken.value) return;
|
||||
|
||||
// Ensure key starts with --theme-
|
||||
let key = this.newToken.key.trim();
|
||||
if (!key.startsWith('--theme-')) {
|
||||
key = '--theme-' + key.replace(/^--/, '');
|
||||
}
|
||||
|
||||
this.themeTokens.push({
|
||||
key,
|
||||
value: this.newToken.value.trim(),
|
||||
category: this.getCategoryForToken(key)
|
||||
});
|
||||
|
||||
this.newToken = { key: '', value: '' };
|
||||
this.markAsChanged();
|
||||
}
|
||||
|
||||
async applyBranding(): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Apply branding requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
this.success = null;
|
||||
|
||||
// Build theme tokens object from themeTokens array
|
||||
const themeTokens: Record<string, string> = {};
|
||||
this.themeTokens.forEach(token => {
|
||||
themeTokens[token.key] = token.value;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
title: this.formData.title || undefined,
|
||||
logoUrl: this.formData.logoUrl || undefined,
|
||||
faviconUrl: this.formData.faviconUrl || undefined,
|
||||
themeTokens
|
||||
};
|
||||
|
||||
this.http.put('/console/branding', payload).subscribe({
|
||||
next: () => {
|
||||
this.success = 'Branding applied successfully! Refreshing page...';
|
||||
this.hasChanges = false;
|
||||
|
||||
// Apply branding immediately
|
||||
this.brandingService.applyBranding({
|
||||
tenantId: 'current',
|
||||
...payload
|
||||
});
|
||||
|
||||
// Reload page after 2 seconds to ensure all components reflect the changes
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to apply branding: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,667 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, ClientResponse } from '../services/console-admin-api.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-clients-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<h1>OAuth2 Clients</h1>
|
||||
<p>Client management interface - implementation pending (follows tenants pattern)</p>
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (isCreating || editingClient) {
|
||||
<div class="admin-form">
|
||||
<h2>{{ isCreating ? 'Create OAuth2 Client' : 'Edit OAuth2 Client' }}</h2>
|
||||
<div class="form-group">
|
||||
<label for="clientId">Client ID</label>
|
||||
<input
|
||||
id="clientId"
|
||||
type="text"
|
||||
[(ngModel)]="formData.clientId"
|
||||
[disabled]="!isCreating"
|
||||
placeholder="my-service-account"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="clientName">Client Name</label>
|
||||
<input
|
||||
id="clientName"
|
||||
type="text"
|
||||
[(ngModel)]="formData.clientName"
|
||||
placeholder="My Service Account"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tenantId">Tenant ID</label>
|
||||
<input
|
||||
id="tenantId"
|
||||
type="text"
|
||||
[(ngModel)]="formData.tenantId"
|
||||
[disabled]="!isCreating"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grantTypes">Grant Types (comma-separated)</label>
|
||||
<input
|
||||
id="grantTypes"
|
||||
type="text"
|
||||
[(ngModel)]="formData.grantTypesInput"
|
||||
placeholder="client_credentials,authorization_code"
|
||||
required>
|
||||
<small class="form-hint">Valid: client_credentials, authorization_code, refresh_token</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="redirectUris">Redirect URIs (comma-separated)</label>
|
||||
<input
|
||||
id="redirectUris"
|
||||
type="text"
|
||||
[(ngModel)]="formData.redirectUrisInput"
|
||||
placeholder="https://app.example.com/callback">
|
||||
<small class="form-hint">Required for authorization_code grant</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scopes (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="formData.scopesInput"
|
||||
placeholder="scanner:read,policy:eval">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="isCreating ? createClient() : updateClient()"
|
||||
[disabled]="isSaving">
|
||||
{{ isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="cancelForm()"
|
||||
[disabled]="isSaving">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@if (newClientSecret) {
|
||||
<div class="secret-display">
|
||||
<div class="secret-warning">
|
||||
<strong>Important:</strong> Copy this client secret now. It will not be shown again.
|
||||
</div>
|
||||
<div class="secret-value">
|
||||
<code>{{ newClientSecret }}</code>
|
||||
<button class="btn-sm" (click)="copySecret()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading">Loading OAuth2 clients...</div>
|
||||
} @else if (clients.length === 0 && !isCreating) {
|
||||
<div class="empty-state">No OAuth2 clients configured</div>
|
||||
} @else {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Client Name</th>
|
||||
<th>Tenant ID</th>
|
||||
<th>Grant Types</th>
|
||||
<th>Scopes</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (client of clients; track client.clientId) {
|
||||
<tr [class.disabled]="client.status === 'disabled'">
|
||||
<td><code>{{ client.clientId }}</code></td>
|
||||
<td>{{ client.clientName }}</td>
|
||||
<td><code>{{ client.tenantId }}</code></td>
|
||||
<td>
|
||||
<div class="grant-badges">
|
||||
@for (grant of client.grantTypes; track grant) {
|
||||
<span class="badge badge-grant">{{ grant }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="scope-badges">
|
||||
@for (scope of client.scopes; track scope) {
|
||||
<span class="badge">{{ scope }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [class]="'status-' + client.status">
|
||||
{{ client.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (canWrite) {
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="editClient(client)"
|
||||
title="Edit client">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="rotateSecret(client.clientId)"
|
||||
title="Rotate client secret">
|
||||
Rotate Secret
|
||||
</button>
|
||||
@if (client.status === 'active') {
|
||||
<button
|
||||
class="btn-sm btn-warning"
|
||||
(click)="disableClient(client.clientId)"
|
||||
title="Disable client">
|
||||
Disable
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="btn-sm btn-success"
|
||||
(click)="enableClient(client.clientId)"
|
||||
title="Enable client">
|
||||
Enable
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
styles: [`
|
||||
.admin-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-form h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.secret-display {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: var(--theme-status-warning);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--theme-status-error);
|
||||
}
|
||||
|
||||
.secret-warning {
|
||||
margin-bottom: 12px;
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.secret-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--theme-bg-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.secret-value code {
|
||||
flex: 1;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--theme-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.admin-table tbody tr.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.grant-badges,
|
||||
.scope-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge-grant {
|
||||
background: var(--theme-status-info);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.status-active {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-disabled {
|
||||
background: var(--theme-status-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm.btn-warning {
|
||||
background: var(--theme-status-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm.btn-success {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ClientsListComponent {}
|
||||
export class ClientsListComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
clients: ClientResponse[] = [];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
isCreating = false;
|
||||
editingClient: ClientResponse | null = null;
|
||||
isSaving = false;
|
||||
newClientSecret: string | null = null;
|
||||
|
||||
formData = {
|
||||
clientId: '',
|
||||
clientName: '',
|
||||
tenantId: '',
|
||||
grantTypesInput: '',
|
||||
redirectUrisInput: '',
|
||||
scopesInput: ''
|
||||
};
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_CLIENTS_WRITE);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadClients();
|
||||
}
|
||||
|
||||
loadClients(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listClients().subscribe({
|
||||
next: (response) => {
|
||||
this.clients = response.clients;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load OAuth2 clients: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showCreateForm(): void {
|
||||
this.isCreating = true;
|
||||
this.editingClient = null;
|
||||
this.newClientSecret = null;
|
||||
this.formData = {
|
||||
clientId: '',
|
||||
clientName: '',
|
||||
tenantId: '',
|
||||
grantTypesInput: 'client_credentials',
|
||||
redirectUrisInput: '',
|
||||
scopesInput: ''
|
||||
};
|
||||
}
|
||||
|
||||
editClient(client: ClientResponse): void {
|
||||
this.isCreating = false;
|
||||
this.editingClient = client;
|
||||
this.newClientSecret = null;
|
||||
this.formData = {
|
||||
clientId: client.clientId,
|
||||
clientName: client.clientName,
|
||||
tenantId: client.tenantId,
|
||||
grantTypesInput: client.grantTypes.join(','),
|
||||
redirectUrisInput: client.redirectUris.join(','),
|
||||
scopesInput: client.scopes.join(',')
|
||||
};
|
||||
}
|
||||
|
||||
cancelForm(): void {
|
||||
this.isCreating = false;
|
||||
this.editingClient = null;
|
||||
this.newClientSecret = null;
|
||||
this.formData = {
|
||||
clientId: '',
|
||||
clientName: '',
|
||||
tenantId: '',
|
||||
grantTypesInput: '',
|
||||
redirectUrisInput: '',
|
||||
scopesInput: ''
|
||||
};
|
||||
}
|
||||
|
||||
async createClient(): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Create OAuth2 client requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
|
||||
const grantTypes = this.formData.grantTypesInput.split(',').map(g => g.trim()).filter(g => g.length > 0);
|
||||
const redirectUris = this.formData.redirectUrisInput.split(',').map(u => u.trim()).filter(u => u.length > 0);
|
||||
const scopes = this.formData.scopesInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
|
||||
this.api.createClient({
|
||||
clientId: this.formData.clientId,
|
||||
clientName: this.formData.clientName,
|
||||
tenantId: this.formData.tenantId,
|
||||
grantTypes,
|
||||
redirectUris,
|
||||
scopes
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
this.clients.push(response.client);
|
||||
this.newClientSecret = response.clientSecret;
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to create client: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateClient(): Promise<void> {
|
||||
if (!this.editingClient) return;
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Update OAuth2 client requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
|
||||
const grantTypes = this.formData.grantTypesInput.split(',').map(g => g.trim()).filter(g => g.length > 0);
|
||||
const redirectUris = this.formData.redirectUrisInput.split(',').map(u => u.trim()).filter(u => u.length > 0);
|
||||
const scopes = this.formData.scopesInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
|
||||
this.api.updateClient(this.editingClient.clientId, {
|
||||
clientName: this.formData.clientName,
|
||||
grantTypes,
|
||||
redirectUris,
|
||||
scopes
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
const index = this.clients.findIndex(c => c.clientId === this.editingClient!.clientId);
|
||||
if (index !== -1) {
|
||||
this.clients[index] = response.client;
|
||||
}
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to update client: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async rotateSecret(clientId: string): Promise<void> {
|
||||
if (!confirm(`Are you sure you want to rotate the secret for client "${clientId}"? The old secret will be invalidated immediately.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Rotate client secret requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.rotateClientSecret(clientId).subscribe({
|
||||
next: (response) => {
|
||||
this.newClientSecret = response.clientSecret;
|
||||
this.isCreating = false;
|
||||
this.editingClient = null;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to rotate client secret: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async disableClient(clientId: string): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Disable OAuth2 client requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.disableClient(clientId).subscribe({
|
||||
next: () => {
|
||||
const client = this.clients.find(c => c.clientId === clientId);
|
||||
if (client) {
|
||||
client.status = 'disabled';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to disable client: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async enableClient(clientId: string): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Enable OAuth2 client requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.enableClient(clientId).subscribe({
|
||||
next: () => {
|
||||
const client = this.clients.find(c => c.clientId === clientId);
|
||||
if (client) {
|
||||
client.status = 'active';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to enable client: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
copySecret(): void {
|
||||
if (this.newClientSecret) {
|
||||
navigator.clipboard.writeText(this.newClientSecret).then(() => {
|
||||
// Could show a toast notification here
|
||||
console.log('Client secret copied to clipboard');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,798 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, RoleResponse } from '../services/console-admin-api.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes, SCOPE_LABELS } from '../../../core/auth/scopes';
|
||||
|
||||
interface RoleBundle {
|
||||
module: string;
|
||||
tier: 'viewer' | 'operator' | 'admin';
|
||||
role: string;
|
||||
scopes: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-roles-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<h1>Roles & Scopes</h1>
|
||||
<p>Role bundle management interface - implementation pending (follows tenants pattern)</p>
|
||||
<header class="admin-header">
|
||||
<h1>Roles & Scopes</h1>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="showCreateForm()"
|
||||
[disabled]="!canWrite || isCreating">
|
||||
Create Custom Role
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'catalog'"
|
||||
(click)="activeTab = 'catalog'">
|
||||
Role Bundle Catalog
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'custom'"
|
||||
(click)="activeTab = 'custom'">
|
||||
Custom Roles
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (activeTab === 'catalog') {
|
||||
<div class="catalog-section">
|
||||
<p class="catalog-info">
|
||||
StellaOps provides pre-defined role bundles for each module following a 3-tier pattern:
|
||||
<strong>viewer</strong> (read-only), <strong>operator</strong> (read + write),
|
||||
<strong>admin</strong> (full control). These roles cannot be modified.
|
||||
</p>
|
||||
|
||||
<div class="module-filter">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="catalogFilter"
|
||||
placeholder="Filter by module..."
|
||||
class="filter-input">
|
||||
</div>
|
||||
|
||||
@for (module of filteredModules; track module) {
|
||||
<div class="module-group">
|
||||
<h3>{{ module }}</h3>
|
||||
<div class="bundle-grid">
|
||||
@for (bundle of getBundlesForModule(module); track bundle.role) {
|
||||
<div class="bundle-card" [class]="'tier-' + bundle.tier">
|
||||
<div class="bundle-header">
|
||||
<span class="bundle-name">{{ bundle.role }}</span>
|
||||
<span class="bundle-tier">{{ bundle.tier }}</span>
|
||||
</div>
|
||||
<div class="bundle-description">{{ bundle.description }}</div>
|
||||
<div class="bundle-scopes">
|
||||
<div class="scopes-header">Scopes:</div>
|
||||
@for (scope of bundle.scopes; track scope) {
|
||||
<div class="scope-badge" [title]="getScopeLabel(scope)">
|
||||
{{ scope }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab === 'custom') {
|
||||
@if (isCreating || editingRole) {
|
||||
<div class="admin-form">
|
||||
<h2>{{ isCreating ? 'Create Custom Role' : 'Edit Custom Role' }}</h2>
|
||||
<div class="form-group">
|
||||
<label for="roleName">Role Name</label>
|
||||
<input
|
||||
id="roleName"
|
||||
type="text"
|
||||
[(ngModel)]="formData.roleName"
|
||||
[disabled]="!isCreating"
|
||||
placeholder="role/custom-analyst"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
[(ngModel)]="formData.description"
|
||||
placeholder="Custom analyst role"
|
||||
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>
|
||||
<code>{{ scope }}</code>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="isCreating ? createRole() : updateRole()"
|
||||
[disabled]="isSaving">
|
||||
{{ isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="cancelForm()"
|
||||
[disabled]="isSaving">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading">Loading custom roles...</div>
|
||||
} @else if (customRoles.length === 0 && !isCreating) {
|
||||
<div class="empty-state">No custom roles defined</div>
|
||||
} @else {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role Name</th>
|
||||
<th>Description</th>
|
||||
<th>Scopes</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (role of customRoles; track role.roleName) {
|
||||
<tr>
|
||||
<td><code>{{ role.roleName }}</code></td>
|
||||
<td>{{ role.description }}</td>
|
||||
<td>
|
||||
<div class="scope-badges">
|
||||
@for (scope of role.scopes; track scope) {
|
||||
<span class="badge" [title]="getScopeLabel(scope)">{{ scope }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (canWrite) {
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="editRole(role)"
|
||||
title="Edit role">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
(click)="deleteRole(role.roleName)"
|
||||
title="Delete role">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
styles: [`
|
||||
.admin-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-bottom-color: var(--theme-brand-primary);
|
||||
color: var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.catalog-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.catalog-info {
|
||||
background: var(--theme-bg-secondary);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.module-filter {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.module-group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.module-group h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bundle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bundle-card {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 2px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bundle-card.tier-viewer {
|
||||
border-left-color: var(--theme-status-info);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.bundle-card.tier-operator {
|
||||
border-left-color: var(--theme-status-warning);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.bundle-card.tier-admin {
|
||||
border-left-color: var(--theme-status-error);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.bundle-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bundle-name {
|
||||
font-weight: 600;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bundle-tier {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.bundle-description {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bundle-scopes {
|
||||
border-top: 1px solid var(--theme-border-primary);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.scopes-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scope-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 2px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-form h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scope-selector {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.scope-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scope-checkbox:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.scope-checkbox input[type="checkbox"] {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.scope-label {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scope-checkbox code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--theme-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.scope-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm.btn-danger {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RolesListComponent {}
|
||||
export class RolesListComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
activeTab: 'catalog' | 'custom' = 'catalog';
|
||||
catalogFilter = '';
|
||||
|
||||
customRoles: RoleResponse[] = [];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
isCreating = false;
|
||||
editingRole: RoleResponse | null = null;
|
||||
isSaving = false;
|
||||
|
||||
formData = {
|
||||
roleName: '',
|
||||
description: '',
|
||||
selectedScopes: [] as string[]
|
||||
};
|
||||
|
||||
// Pre-defined role bundle catalog
|
||||
readonly roleBundles: RoleBundle[] = [
|
||||
// Scanner
|
||||
{ module: 'Scanner', tier: 'viewer', role: 'role/scanner-viewer', scopes: ['scanner:read', 'findings:read', 'aoc:verify'], description: 'View scan results and findings' },
|
||||
{ module: 'Scanner', tier: 'operator', role: 'role/scanner-operator', scopes: ['scanner:read', 'scanner:scan', 'findings:read', 'aoc:verify'], description: 'Initiate scans and view results' },
|
||||
{ module: 'Scanner', tier: 'admin', role: 'role/scanner-admin', scopes: ['scanner:read', 'scanner:scan', 'scanner:export', 'scanner:write', 'findings:read', 'aoc:verify'], description: 'Full scanner administration' },
|
||||
|
||||
// Policy
|
||||
{ module: 'Policy', tier: 'viewer', role: 'role/policy-viewer', scopes: ['policy:read', 'aoc:verify'], description: 'View policies and VEX decisions' },
|
||||
{ module: 'Policy', tier: 'operator', role: 'role/policy-operator', scopes: ['policy:read', 'policy:eval', 'aoc:verify'], description: 'Evaluate policies and view results' },
|
||||
{ module: 'Policy', tier: 'admin', role: 'role/policy-admin', scopes: ['policy:read', 'policy:eval', 'policy:write', 'aoc:verify'], description: 'Full policy administration' },
|
||||
|
||||
// Concelier
|
||||
{ module: 'Concelier', tier: 'viewer', role: 'role/concelier-viewer', scopes: ['concelier:read', 'aoc:verify'], description: 'View vulnerability advisories' },
|
||||
{ module: 'Concelier', tier: 'operator', role: 'role/concelier-operator', scopes: ['concelier:read', 'concelier:sync', 'aoc:verify'], description: 'Sync advisory feeds' },
|
||||
{ module: 'Concelier', tier: 'admin', role: 'role/concelier-admin', scopes: ['concelier:read', 'concelier:sync', 'concelier:write', 'aoc:verify'], description: 'Full advisory management' },
|
||||
|
||||
// Authority
|
||||
{ module: 'Authority', tier: 'viewer', role: 'role/authority-viewer', scopes: ['authority:read', 'aoc:verify'], description: 'View auth configuration' },
|
||||
{ module: 'Authority', tier: 'operator', role: 'role/authority-operator', scopes: ['authority:read', 'authority:token.issue', 'aoc:verify'], description: 'Issue tokens and view config' },
|
||||
{ module: 'Authority', tier: 'admin', role: 'role/authority-admin', scopes: ['authority:read', 'authority:token.issue', 'authority:write', 'aoc:verify'], description: 'Full authority administration' },
|
||||
|
||||
// Scheduler
|
||||
{ module: 'Scheduler', tier: 'viewer', role: 'role/scheduler-viewer', scopes: ['scheduler:read', 'aoc:verify'], description: 'View scheduled jobs' },
|
||||
{ module: 'Scheduler', tier: 'operator', role: 'role/scheduler-operator', scopes: ['scheduler:read', 'scheduler:trigger', 'aoc:verify'], description: 'Trigger jobs and view status' },
|
||||
{ module: 'Scheduler', tier: 'admin', role: 'role/scheduler-admin', scopes: ['scheduler:read', 'scheduler:trigger', 'scheduler:write', 'aoc:verify'], description: 'Full scheduler administration' },
|
||||
|
||||
// Attestor
|
||||
{ module: 'Attestor', tier: 'viewer', role: 'role/attestor-viewer', scopes: ['attest:read', 'aoc:verify'], description: 'View attestations' },
|
||||
{ module: 'Attestor', tier: 'operator', role: 'role/attestor-operator', scopes: ['attest:read', 'attest:create', 'aoc:verify'], description: 'Create and view attestations' },
|
||||
{ module: 'Attestor', tier: 'admin', role: 'role/attestor-admin', scopes: ['attest:read', 'attest:create', 'attest:admin', 'aoc:verify'], description: 'Full attestation administration' },
|
||||
|
||||
// Signer
|
||||
{ module: 'Signer', tier: 'viewer', role: 'role/signer-viewer', scopes: ['signer:read', 'aoc:verify'], description: 'View signing keys' },
|
||||
{ module: 'Signer', tier: 'operator', role: 'role/signer-operator', scopes: ['signer:read', 'signer:sign', 'aoc:verify'], description: 'Sign artifacts and view keys' },
|
||||
{ module: 'Signer', tier: 'admin', role: 'role/signer-admin', scopes: ['signer:read', 'signer:sign', 'signer:rotate', 'signer:admin', 'aoc:verify'], description: 'Full signing key administration' },
|
||||
|
||||
// SBOM
|
||||
{ module: 'SBOM', tier: 'viewer', role: 'role/sbom-viewer', scopes: ['sbom:read', 'aoc:verify'], description: 'View SBOMs' },
|
||||
{ module: 'SBOM', tier: 'operator', role: 'role/sbom-operator', scopes: ['sbom:read', 'sbom:export', 'aoc:verify'], description: 'Export SBOMs' },
|
||||
{ module: 'SBOM', tier: 'admin', role: 'role/sbom-admin', scopes: ['sbom:read', 'sbom:export', 'sbom:write', 'aoc:verify'], description: 'Full SBOM administration' },
|
||||
|
||||
// Excititor (VEX)
|
||||
{ module: 'Excititor', tier: 'viewer', role: 'role/vex-viewer', scopes: ['vex:read', 'aoc:verify'], description: 'View VEX documents' },
|
||||
{ module: 'Excititor', tier: 'operator', role: 'role/vex-operator', scopes: ['vex:read', 'vex:export', 'aoc:verify'], description: 'Export VEX documents' },
|
||||
{ module: 'Excititor', tier: 'admin', role: 'role/vex-admin', scopes: ['vex:read', 'vex:export', 'vex:write', 'aoc:verify'], description: 'Full VEX administration' },
|
||||
|
||||
// Notify
|
||||
{ module: 'Notify', tier: 'viewer', role: 'role/notify-viewer', scopes: ['notify:read', 'aoc:verify'], description: 'View notification config' },
|
||||
{ module: 'Notify', tier: 'operator', role: 'role/notify-operator', scopes: ['notify:read', 'notify:send', 'aoc:verify'], description: 'Send notifications' },
|
||||
{ module: 'Notify', tier: 'admin', role: 'role/notify-admin', scopes: ['notify:read', 'notify:send', 'notify:write', 'aoc:verify'], description: 'Full notification administration' },
|
||||
|
||||
// Zastava
|
||||
{ module: 'Zastava', tier: 'viewer', role: 'role/zastava-viewer', scopes: ['zastava:read', 'aoc:verify'], description: 'View webhook events' },
|
||||
{ module: 'Zastava', tier: 'operator', role: 'role/zastava-operator', scopes: ['zastava:read', 'zastava:subscribe', 'aoc:verify'], description: 'Subscribe to webhooks' },
|
||||
{ module: 'Zastava', tier: 'admin', role: 'role/zastava-admin', scopes: ['zastava:read', 'zastava:subscribe', 'zastava:write', 'aoc:verify'], description: 'Full webhook administration' },
|
||||
|
||||
// Release
|
||||
{ module: 'Release', tier: 'viewer', role: 'role/release-viewer', scopes: ['release:read', 'aoc:verify'], description: 'View releases' },
|
||||
{ module: 'Release', tier: 'operator', role: 'role/release-operator', scopes: ['release:read', 'release:create', 'aoc:verify'], description: 'Create and view releases' },
|
||||
{ module: 'Release', tier: 'admin', role: 'role/release-admin', scopes: ['release:read', 'release:create', 'release:write', 'aoc:verify'], description: 'Full release administration' },
|
||||
];
|
||||
|
||||
readonly availableScopes = Object.keys(SCOPE_LABELS).sort();
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_ROLES_WRITE);
|
||||
}
|
||||
|
||||
get filteredModules(): string[] {
|
||||
const modules = [...new Set(this.roleBundles.map(b => b.module))];
|
||||
if (!this.catalogFilter) return modules;
|
||||
return modules.filter(m => m.toLowerCase().includes(this.catalogFilter.toLowerCase()));
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.activeTab === 'custom') {
|
||||
this.loadCustomRoles();
|
||||
}
|
||||
}
|
||||
|
||||
getBundlesForModule(module: string): RoleBundle[] {
|
||||
return this.roleBundles.filter(b => b.module === module);
|
||||
}
|
||||
|
||||
getScopeLabel(scope: string): string {
|
||||
return SCOPE_LABELS[scope] || scope;
|
||||
}
|
||||
|
||||
loadCustomRoles(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listRoles().subscribe({
|
||||
next: (response) => {
|
||||
this.customRoles = response.roles;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load custom roles: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showCreateForm(): void {
|
||||
this.isCreating = true;
|
||||
this.editingRole = null;
|
||||
this.formData = {
|
||||
roleName: '',
|
||||
description: '',
|
||||
selectedScopes: []
|
||||
};
|
||||
}
|
||||
|
||||
editRole(role: RoleResponse): void {
|
||||
this.isCreating = false;
|
||||
this.editingRole = role;
|
||||
this.formData = {
|
||||
roleName: role.roleName,
|
||||
description: role.description,
|
||||
selectedScopes: [...role.scopes]
|
||||
};
|
||||
}
|
||||
|
||||
cancelForm(): void {
|
||||
this.isCreating = false;
|
||||
this.editingRole = null;
|
||||
this.formData = {
|
||||
roleName: '',
|
||||
description: '',
|
||||
selectedScopes: []
|
||||
};
|
||||
}
|
||||
|
||||
toggleScope(scope: string): void {
|
||||
const index = this.formData.selectedScopes.indexOf(scope);
|
||||
if (index !== -1) {
|
||||
this.formData.selectedScopes.splice(index, 1);
|
||||
} else {
|
||||
this.formData.selectedScopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
async createRole(): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Create custom role requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.createRole({
|
||||
roleName: this.formData.roleName,
|
||||
description: this.formData.description,
|
||||
scopes: this.formData.selectedScopes
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
this.customRoles.push(response.role);
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to create role: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(): Promise<void> {
|
||||
if (!this.editingRole) return;
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Update custom role requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.updateRole(this.editingRole.roleName, {
|
||||
description: this.formData.description,
|
||||
scopes: this.formData.selectedScopes
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
const index = this.customRoles.findIndex(r => r.roleName === this.editingRole!.roleName);
|
||||
if (index !== -1) {
|
||||
this.customRoles[index] = response.role;
|
||||
}
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to update role: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRole(roleName: string): Promise<void> {
|
||||
if (!confirm(`Are you sure you want to delete role "${roleName}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Delete custom role requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.deleteRole(roleName).subscribe({
|
||||
next: () => {
|
||||
const index = this.customRoles.findIndex(r => r.roleName === roleName);
|
||||
if (index !== -1) {
|
||||
this.customRoles.splice(index, 1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to delete role: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,553 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, TokenResponse } from '../services/console-admin-api.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<h1>Tokens</h1>
|
||||
<p>Token inventory and revocation interface - implementation pending (follows tenants pattern)</p>
|
||||
<header class="admin-header">
|
||||
<h1>Tokens</h1>
|
||||
<div class="header-actions">
|
||||
<div class="filter-controls">
|
||||
<select [(ngModel)]="filterStatus" (change)="loadTokens()" class="filter-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
</select>
|
||||
<select [(ngModel)]="filterTokenType" (change)="loadTokens()" class="filter-select">
|
||||
<option value="">All Types</option>
|
||||
<option value="access_token">Access Token</option>
|
||||
<option value="refresh_token">Refresh Token</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading">Loading tokens...</div>
|
||||
} @else if (tokens.length === 0) {
|
||||
<div class="empty-state">No tokens found</div>
|
||||
} @else {
|
||||
<div class="tokens-summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Total Tokens</div>
|
||||
<div class="summary-value">{{ tokens.length }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Active</div>
|
||||
<div class="summary-value">{{ countByStatus('active') }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Expired</div>
|
||||
<div class="summary-value">{{ countByStatus('expired') }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Revoked</div>
|
||||
<div class="summary-value">{{ countByStatus('revoked') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token ID</th>
|
||||
<th>Type</th>
|
||||
<th>Subject</th>
|
||||
<th>Client ID</th>
|
||||
<th>Tenant ID</th>
|
||||
<th>Issued At</th>
|
||||
<th>Expires At</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (token of tokens; track token.tokenId) {
|
||||
<tr [class.revoked]="token.status === 'revoked'" [class.expired]="token.status === 'expired'">
|
||||
<td><code class="token-id">{{ formatTokenId(token.tokenId) }}</code></td>
|
||||
<td>
|
||||
<span class="type-badge" [class]="'type-' + token.tokenType">
|
||||
{{ formatTokenType(token.tokenType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ token.subject }}</td>
|
||||
<td><code>{{ token.clientId }}</code></td>
|
||||
<td><code>{{ token.tenantId }}</code></td>
|
||||
<td>{{ formatDate(token.issuedAt) }}</td>
|
||||
<td>{{ formatDate(token.expiresAt) }}</td>
|
||||
<td>
|
||||
<span class="status-badge" [class]="'status-' + token.status">
|
||||
{{ token.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (canWrite && token.status === 'active') {
|
||||
<button
|
||||
class="btn-sm btn-danger"
|
||||
(click)="revokeToken(token.tokenId)"
|
||||
title="Revoke token">
|
||||
Revoke
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (tokens.length > 0) {
|
||||
<div class="bulk-actions">
|
||||
@if (canWrite) {
|
||||
<button
|
||||
class="btn-danger"
|
||||
(click)="revokeAllExpired()"
|
||||
[disabled]="countByStatus('expired') === 0"
|
||||
title="Revoke all expired tokens">
|
||||
Revoke All Expired ({{ countByStatus('expired') }})
|
||||
</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
(click)="revokeBySubject()"
|
||||
title="Revoke all tokens for a user">
|
||||
Revoke by Subject
|
||||
</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
(click)="revokeByClient()"
|
||||
title="Revoke all tokens for a client">
|
||||
Revoke by Client
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
styles: [`
|
||||
.admin-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--theme-bg-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tokens-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--theme-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.admin-table tbody tr.revoked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.admin-table tbody tr.expired {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token-id {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-badge.type-access_token {
|
||||
background: var(--theme-status-info);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.type-badge.type-refresh_token {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.status-active {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-expired {
|
||||
background: var(--theme-status-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-revoked {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm.btn-danger {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TokensListComponent {}
|
||||
export class TokensListComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
tokens: TokenResponse[] = [];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
filterStatus = '';
|
||||
filterTokenType = '';
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_TOKENS_WRITE);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTokens();
|
||||
}
|
||||
|
||||
loadTokens(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listTokens().subscribe({
|
||||
next: (response) => {
|
||||
this.tokens = this.applyFilters(response.tokens);
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load tokens: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(tokens: TokenResponse[]): TokenResponse[] {
|
||||
let filtered = tokens;
|
||||
|
||||
if (this.filterStatus) {
|
||||
filtered = filtered.filter(t => t.status === this.filterStatus);
|
||||
}
|
||||
|
||||
if (this.filterTokenType) {
|
||||
filtered = filtered.filter(t => t.tokenType === this.filterTokenType);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
countByStatus(status: string): number {
|
||||
return this.tokens.filter(t => t.status === status).length;
|
||||
}
|
||||
|
||||
formatTokenId(tokenId: string): string {
|
||||
if (tokenId.length > 16) {
|
||||
return tokenId.substring(0, 8) + '...' + tokenId.substring(tokenId.length - 8);
|
||||
}
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
formatTokenType(type: string): string {
|
||||
return type.replace('_', ' ');
|
||||
}
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async revokeToken(tokenId: string): Promise<void> {
|
||||
if (!confirm(`Are you sure you want to revoke this token? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Revoke token requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.revokeToken(tokenId).subscribe({
|
||||
next: () => {
|
||||
const token = this.tokens.find(t => t.tokenId === tokenId);
|
||||
if (token) {
|
||||
token.status = 'revoked';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to revoke token: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAllExpired(): Promise<void> {
|
||||
const expiredCount = this.countByStatus('expired');
|
||||
if (!confirm(`Are you sure you want to revoke all ${expiredCount} expired tokens? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Bulk revoke tokens requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
const expiredTokens = this.tokens.filter(t => t.status === 'expired');
|
||||
let revoked = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const token of expiredTokens) {
|
||||
this.api.revokeToken(token.tokenId).subscribe({
|
||||
next: () => {
|
||||
token.status = 'revoked';
|
||||
revoked++;
|
||||
},
|
||||
error: () => {
|
||||
failed++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (failed > 0) {
|
||||
this.error = `Revoked ${revoked} tokens, ${failed} failed`;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async revokeBySubject(): Promise<void> {
|
||||
const subject = prompt('Enter the subject (user email) to revoke all tokens for:');
|
||||
if (!subject) return;
|
||||
|
||||
const matchingTokens = this.tokens.filter(t => t.subject === subject && t.status === 'active');
|
||||
if (matchingTokens.length === 0) {
|
||||
alert(`No active tokens found for subject: ${subject}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to revoke all ${matchingTokens.length} active tokens for "${subject}"? This will immediately log out the user.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Bulk revoke tokens requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
for (const token of matchingTokens) {
|
||||
this.api.revokeToken(token.tokenId).subscribe({
|
||||
next: () => {
|
||||
token.status = 'revoked';
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async revokeByClient(): Promise<void> {
|
||||
const clientId = prompt('Enter the client ID to revoke all tokens for:');
|
||||
if (!clientId) return;
|
||||
|
||||
const matchingTokens = this.tokens.filter(t => t.clientId === clientId && t.status === 'active');
|
||||
if (matchingTokens.length === 0) {
|
||||
alert(`No active tokens found for client: ${clientId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to revoke all ${matchingTokens.length} active tokens for client "${clientId}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Bulk revoke tokens requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
for (const token of matchingTokens) {
|
||||
this.api.revokeToken(token.tokenId).subscribe({
|
||||
next: () => {
|
||||
token.status = 'revoked';
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,537 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, UserResponse } from '../services/console-admin-api.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-panel">
|
||||
<h1>Users</h1>
|
||||
<p>User management interface - implementation pending (follows tenants pattern)</p>
|
||||
<header class="admin-header">
|
||||
<h1>Users</h1>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="showCreateForm()"
|
||||
[disabled]="!canWrite || isCreating">
|
||||
Create User
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (isCreating || editingUser) {
|
||||
<div class="admin-form">
|
||||
<h2>{{ isCreating ? 'Create User' : 'Edit User' }}</h2>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
[(ngModel)]="formData.email"
|
||||
[disabled]="!isCreating"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="displayName">Display Name</label>
|
||||
<input
|
||||
id="displayName"
|
||||
type="text"
|
||||
[(ngModel)]="formData.displayName"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tenantId">Tenant ID</label>
|
||||
<input
|
||||
id="tenantId"
|
||||
type="text"
|
||||
[(ngModel)]="formData.tenantId"
|
||||
[disabled]="!isCreating"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Roles (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="formData.rolesInput"
|
||||
placeholder="role/scanner-viewer,role/policy-operator">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="isCreating ? createUser() : updateUser()"
|
||||
[disabled]="isSaving">
|
||||
{{ isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="cancelForm()"
|
||||
[disabled]="isSaving">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading">Loading users...</div>
|
||||
} @else if (users.length === 0 && !isCreating) {
|
||||
<div class="empty-state">No users found</div>
|
||||
} @else {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Display Name</th>
|
||||
<th>Tenant ID</th>
|
||||
<th>Roles</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (user of users; track user.userId) {
|
||||
<tr [class.disabled]="user.status === 'disabled'">
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.displayName }}</td>
|
||||
<td><code>{{ user.tenantId }}</code></td>
|
||||
<td>
|
||||
<div class="role-badges">
|
||||
@for (role of user.roles; track role) {
|
||||
<span class="badge">{{ role }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [class]="'status-' + user.status">
|
||||
{{ user.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (canWrite) {
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="editUser(user)"
|
||||
title="Edit user">
|
||||
Edit
|
||||
</button>
|
||||
@if (user.status === 'active') {
|
||||
<button
|
||||
class="btn-sm btn-warning"
|
||||
(click)="disableUser(user.userId)"
|
||||
title="Disable user">
|
||||
Disable
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="btn-sm btn-success"
|
||||
(click)="enableUser(user.userId)"
|
||||
title="Enable user">
|
||||
Enable
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
styles: [`
|
||||
.admin-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-form h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--theme-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.admin-table tbody tr.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.role-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.status-active {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-disabled {
|
||||
background: var(--theme-status-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm.btn-warning {
|
||||
background: var(--theme-status-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm.btn-success {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class UsersListComponent {}
|
||||
export class UsersListComponent implements OnInit {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
users: UserResponse[] = [];
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
isCreating = false;
|
||||
editingUser: UserResponse | null = null;
|
||||
isSaving = false;
|
||||
|
||||
formData = {
|
||||
email: '',
|
||||
displayName: '',
|
||||
tenantId: '',
|
||||
rolesInput: ''
|
||||
};
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_USERS_WRITE);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
loadUsers(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.api.listUsers().subscribe({
|
||||
next: (response) => {
|
||||
this.users = response.users;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load users: ' + (err.error?.message || err.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showCreateForm(): void {
|
||||
this.isCreating = true;
|
||||
this.editingUser = null;
|
||||
this.formData = {
|
||||
email: '',
|
||||
displayName: '',
|
||||
tenantId: '',
|
||||
rolesInput: ''
|
||||
};
|
||||
}
|
||||
|
||||
editUser(user: UserResponse): void {
|
||||
this.isCreating = false;
|
||||
this.editingUser = user;
|
||||
this.formData = {
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
tenantId: user.tenantId,
|
||||
rolesInput: user.roles.join(',')
|
||||
};
|
||||
}
|
||||
|
||||
cancelForm(): void {
|
||||
this.isCreating = false;
|
||||
this.editingUser = null;
|
||||
this.formData = {
|
||||
email: '',
|
||||
displayName: '',
|
||||
tenantId: '',
|
||||
rolesInput: ''
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Create user requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
|
||||
const roles = this.formData.rolesInput
|
||||
.split(',')
|
||||
.map(r => r.trim())
|
||||
.filter(r => r.length > 0);
|
||||
|
||||
this.api.createUser({
|
||||
email: this.formData.email,
|
||||
displayName: this.formData.displayName,
|
||||
tenantId: this.formData.tenantId,
|
||||
roles
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
this.users.push(response.user);
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to create user: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(): Promise<void> {
|
||||
if (!this.editingUser) return;
|
||||
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Update user requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.error = null;
|
||||
|
||||
const roles = this.formData.rolesInput
|
||||
.split(',')
|
||||
.map(r => r.trim())
|
||||
.filter(r => r.length > 0);
|
||||
|
||||
this.api.updateUser(this.editingUser.userId, {
|
||||
displayName: this.formData.displayName,
|
||||
roles
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
const index = this.users.findIndex(u => u.userId === this.editingUser!.userId);
|
||||
if (index !== -1) {
|
||||
this.users[index] = response.user;
|
||||
}
|
||||
this.cancelForm();
|
||||
this.isSaving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to update user: ' + (err.error?.message || err.message);
|
||||
this.isSaving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async disableUser(userId: string): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Disable user requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.disableUser(userId).subscribe({
|
||||
next: () => {
|
||||
const user = this.users.find(u => u.userId === userId);
|
||||
if (user) {
|
||||
user.status = 'disabled';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to disable user: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async enableUser(userId: string): Promise<void> {
|
||||
const freshAuthOk = await this.freshAuth.requireFreshAuth('Enable user requires fresh authentication');
|
||||
if (!freshAuthOk) return;
|
||||
|
||||
this.api.enableUser(userId).subscribe({
|
||||
next: () => {
|
||||
const user = this.users.find(u => u.userId === userId);
|
||||
if (user) {
|
||||
user.status = 'active';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to enable user: ' + (err.error?.message || err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user