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:
master
2025-12-23 18:20:00 +02:00
parent b444284be5
commit dac8e10e36
241 changed files with 22567 additions and 307 deletions

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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