diff --git a/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md b/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md index 35f7b9490..3b278d41b 100644 --- a/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md +++ b/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md @@ -118,7 +118,7 @@ Completion criteria: - [ ] Integration test: set preference, resolve provider, confirm correct provider is selected ### CP-003 - Angular crypto provider dashboard panel -Status: TODO +Status: DONE Dependency: CP-001 Owners: Frontend Developer @@ -144,14 +144,14 @@ Angular implementation: - Use existing StellaOps design system components (cards, status badges, tables) Completion criteria: -- [ ] Panel renders provider list with live status from API -- [ ] Stopped providers show start command with copy button -- [ ] Auto-refresh works and stops when navigating away -- [ ] Panel is accessible only to admin users -- [ ] Responsive layout (works on tablet and desktop) +- [x] Panel renders provider list with live status from API +- [x] Stopped providers show start command with copy button +- [x] Auto-refresh works and stops when navigating away +- [x] Panel is accessible only to admin users +- [x] Responsive layout (works on tablet and desktop) ### CP-004 - Active provider selection UI -Status: TODO +Status: DONE Dependency: CP-002, CP-003 Owners: Frontend Developer @@ -168,11 +168,11 @@ UI additions: The selection calls `PUT /api/v1/admin/crypto-providers/preferences` and updates the UI immediately. Completion criteria: -- [ ] Admin can select active provider per tenant -- [ ] Selection persists across page refreshes (reads from API) -- [ ] Cannot select a provider that is currently stopped/unreachable (button disabled with tooltip) -- [ ] Confirmation dialog shown before changing provider -- [ ] Priority ordering updates the registry's preferred order +- [x] Admin can select active provider per tenant +- [x] Selection persists across page refreshes (reads from API) +- [x] Cannot select a provider that is currently stopped/unreachable (button disabled with tooltip) +- [x] Confirmation dialog shown before changing provider +- [x] Priority ordering updates the registry's preferred order ### CP-005 - ICryptoProviderRegistry tenant-aware resolution Status: TODO @@ -206,6 +206,7 @@ Completion criteria: | 2026-04-08 | Sprint created. Crypto provider compose overlays refactored (smremote extracted, files renamed). | Planning | | 2026-04-08 | CP-001 implemented: CryptoProviderHealthService + CryptoProviderAdminEndpoints (health probe). CP-002 implemented: SQL migration 062, ICryptoProviderPreferenceStore with Postgres and InMemory impls, CRUD endpoints. Both wired in Program.cs. Build verified (0 errors, 0 warnings). Unit tests pending. | Developer | | 2026-04-08 | Compose refactoring confirmed complete: smremote extracted (Slot 31 comment in main compose), overlay files already named `docker-compose.crypto-provider.*.yml`, README Crypto Provider Overlays section up to date, INSTALL_GUIDE.md references correct filenames. No old-named files to rename. | Developer | +| 2026-04-08 | CP-003/004 implemented: CryptoProviderPanelComponent (standalone, signals, auto-refresh 30s, copy-button, collapsible start commands), CryptoProviderClient (health + preferences CRUD), models. Route at `/setup/crypto-providers`, Setup overview card added. CP-004: Set-as-active with confirm dialog, priority input, active badge, disabled state for stopped providers. Build verified (0 errors). CP-005 is backend-only, not in scope for this FE pass. | Frontend Developer | ## Decisions & Risks - **Risk: Provider health probing from within containers.** The Platform service runs inside the Docker network; it can reach other containers by DNS alias but cannot determine whether a compose overlay is loaded vs. the container is unhealthy. Mitigation: treat any non-200 response (including DNS resolution failure) as `unreachable`. diff --git a/src/Web/StellaOps.Web/src/app/core/api/crypto-provider.client.ts b/src/Web/StellaOps.Web/src/app/core/api/crypto-provider.client.ts new file mode 100644 index 000000000..2268c6bd4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/crypto-provider.client.ts @@ -0,0 +1,58 @@ +/** + * Crypto Provider API Client + * Sprint: SPRINT_20260408_001_FE_crypto_provider_picker (CP-003/004/005) + * + * Calls the Platform admin endpoints for crypto provider health probing + * and tenant preference CRUD. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + CryptoProviderHealthResponse, + CryptoProviderPreference, + CryptoProviderPreferenceUpdate, +} from './crypto-provider.models'; + +@Injectable({ providedIn: 'root' }) +export class CryptoProviderClient { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/v1/admin/crypto-providers'; + + // ───────────────────────────────────────────────────────────────────────── + // Health (CP-001) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Probe all configured crypto providers and return aggregated health. + */ + getHealth(): Observable { + return this.http.get(`${this.baseUrl}/health`); + } + + // ───────────────────────────────────────────────────────────────────────── + // Preferences (CP-002) + // ───────────────────────────────────────────────────────────────────────── + + /** + * List current tenant's crypto provider preferences. + */ + getPreferences(): Observable { + return this.http.get(`${this.baseUrl}/preferences`); + } + + /** + * Create or update a provider preference for the current tenant. + */ + updatePreference(body: CryptoProviderPreferenceUpdate): Observable { + return this.http.put(`${this.baseUrl}/preferences`, body); + } + + /** + * Remove a provider preference. + */ + deletePreference(id: string): Observable { + return this.http.delete(`${this.baseUrl}/preferences/${id}`); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/crypto-provider.models.ts b/src/Web/StellaOps.Web/src/app/core/api/crypto-provider.models.ts new file mode 100644 index 000000000..359bb46b1 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/crypto-provider.models.ts @@ -0,0 +1,73 @@ +/** + * Crypto Provider Models + * Sprint: SPRINT_20260408_001_FE_crypto_provider_picker (CP-003/004/005) + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Health probe models (CP-001 response) +// ───────────────────────────────────────────────────────────────────────────── + +export type CryptoProviderStatus = 'running' | 'stopped' | 'unreachable' | 'degraded'; + +export interface CryptoProviderHealth { + id: string; + name: string; + status: CryptoProviderStatus; + healthEndpoint: string; + responseTimeMs: number | null; + composeOverlay: string; + startCommand: string; +} + +export interface CryptoProviderHealthResponse { + providers: CryptoProviderHealth[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tenant preference models (CP-002 response) +// ───────────────────────────────────────────────────────────────────────────── + +export interface CryptoProviderPreference { + id: string; + tenantId: string; + providerId: string; + algorithmScope: string; + priority: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CryptoProviderPreferenceUpdate { + providerId: string; + algorithmScope: string; + priority: number; + isActive: boolean; +} + +// ───────────────────────────────────────────────────────────────────────────── +// UI helpers +// ───────────────────────────────────────────────────────────────────────────── + +export const PROVIDER_STATUS_COLORS: Record = { + running: 'status-dot--running', + stopped: 'status-dot--stopped', + unreachable: 'status-dot--unreachable', + degraded: 'status-dot--degraded', +}; + +export const PROVIDER_STATUS_LABELS: Record = { + running: 'Running', + stopped: 'Stopped', + unreachable: 'Unreachable', + degraded: 'Degraded', +}; + +export const ALGORITHM_SCOPES = [ + { value: '*', label: 'All Algorithms (Global)' }, + { value: 'SM', label: 'SM (SM2/SM3/SM4)' }, + { value: 'GOST', label: 'GOST (R 34.10/34.11/34.12)' }, + { value: 'RSA', label: 'RSA' }, + { value: 'ECDSA', label: 'ECDSA' }, + { value: 'EdDSA', label: 'EdDSA (Ed25519/Ed448)' }, +] as const; diff --git a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts index c2cd61aeb..5335d9230 100644 --- a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts @@ -28,7 +28,7 @@ interface SetupCard {

- 7 setup domains + 8 setup domains 3 operational drilldowns Offline-first safe
@@ -309,6 +309,13 @@ export class AdministrationOverviewComponent { route: '/setup/usage', icon: 'QTA', }, + { + id: 'crypto-providers', + title: 'Crypto Providers', + description: 'Discover, monitor, and select cryptographic providers per tenant.', + route: '/setup/crypto-providers', + icon: 'KEY', + }, { id: 'system', title: 'System Settings', diff --git a/src/Web/StellaOps.Web/src/app/features/settings/crypto-providers/crypto-provider-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/crypto-providers/crypto-provider-panel.component.ts new file mode 100644 index 000000000..9c747e254 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/settings/crypto-providers/crypto-provider-panel.component.ts @@ -0,0 +1,1018 @@ +/** + * Crypto Provider Panel Component + * Sprint: SPRINT_20260408_001_FE_crypto_provider_picker (CP-003/004/005) + * + * Admin panel for discovering, monitoring, and selecting crypto providers + * per tenant. Lives under Setup > Crypto Providers (/setup/crypto-providers). + */ + +import { + Component, + ChangeDetectionStrategy, + OnInit, + OnDestroy, + ViewChild, + inject, + signal, + computed, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Subject, interval, switchMap, startWith, takeUntil, forkJoin, catchError, of } from 'rxjs'; + +import { CryptoProviderClient } from '../../../core/api/crypto-provider.client'; +import { + CryptoProviderHealth, + CryptoProviderPreference, + CryptoProviderStatus, + PROVIDER_STATUS_LABELS, + ALGORITHM_SCOPES, +} from '../../../core/api/crypto-provider.models'; +import { CopyButtonComponent } from '../../../shared/components/copy-button/copy-button.component'; +import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component'; + +@Component({ + selector: 'app-crypto-provider-panel', + standalone: true, + imports: [CommonModule, FormsModule, CopyButtonComponent, ConfirmDialogComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + @if (loadError()) { +
+ + {{ loadError() }} +
+ } + + + @if (activePreference()) { +
+
+ + + Active Provider: {{ getProviderName(activePreference()!.providerId) }} + @if (activePreference()!.algorithmScope !== '*') { + {{ activePreference()!.algorithmScope }} + } + +
+
+ } + + + @if (loading() && providers().length === 0) { +
+
+

Probing crypto providers...

+
+ } @else { +
+ @for (provider of providers(); track provider.id) { +
+ +
+
+ {{ getProviderBadge(provider.id) }} +
+

{{ provider.name }}

+ {{ provider.id }} +
+
+
+ + + {{ getStatusLabel(provider.status) }} + +
+
+ + +
+ @if (provider.status === 'running' || provider.status === 'degraded') { +
+ Latency + + {{ provider.responseTimeMs != null ? provider.responseTimeMs + 'ms' : '--' }} + +
+
+ Health Endpoint + {{ provider.healthEndpoint }} +
+ } + @if (lastChecked()) { +
+ Last Checked + {{ lastChecked() | date:'medium' }} +
+ } +
+ + + @if (provider.status === 'stopped' || provider.status === 'unreachable') { +
+ + @if (expandedCommands().has(provider.id)) { +
+
+ {{ provider.startCommand }} + +
+

+ Run this command from the devops/compose/ directory. +

+
+ } +
+ } + + +
+ @if (isActiveProvider(provider.id)) { + + + + + Active + + } @else { + + } + + @if (hasPreference(provider.id) && !isActiveProvider(provider.id)) { + + } +
+ + + @if (hasPreference(provider.id)) { +
+ + + Lower = higher priority +
+ } +
+ } +
+ } + + + @if (preferences().length > 0) { +
+

Algorithm Scope Mapping

+

+ Configure which provider handles specific algorithm families. + Global scope (*) applies when no specific mapping exists. +

+
+
+ Provider + Scope + Priority + Status +
+ @for (pref of preferences(); track pref.id) { +
+ {{ getProviderName(pref.providerId) }} + {{ pref.algorithmScope }} + {{ pref.priority }} + + @if (pref.isActive) { + Active + } @else { + Inactive + } + +
+ } +
+
+ } + + + +
+ `, + styles: [` + .crypto-providers { + max-width: 1100px; + animation: page-enter 200ms ease; + } + + @keyframes page-enter { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + + /* ─── Page Header ─── */ + .page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .page-title { + margin: 0 0 0.25rem; + font-size: 1.35rem; + font-weight: var(--font-weight-bold, 700); + color: var(--color-text-heading); + } + + .page-subtitle { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.5; + max-width: 640px; + } + + /* ─── Error Banner ─── */ + .error-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + background: rgba(239, 68, 68, 0.06); + color: var(--color-status-error); + font-size: 0.8125rem; + } + + /* ─── Active Banner ─── */ + .active-banner { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius-md); + background: rgba(34, 197, 94, 0.06); + } + + .active-banner__content { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--color-status-success); + } + + .active-banner__content strong { + font-weight: var(--font-weight-semibold, 600); + } + + /* ─── Loading State ─── */ + .loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 1rem; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--color-border-primary); + border-top-color: var(--color-brand-primary, #F5A623); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* ─── Provider Grid ─── */ + .provider-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; + } + + /* ─── Provider Card ─── */ + .provider-card { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg, 12px); + padding: 1.25rem; + transition: border-color 150ms ease, box-shadow 150ms ease; + } + + .provider-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } + + .provider-card--running { + border-left: 3px solid var(--color-status-success); + } + + .provider-card--degraded { + border-left: 3px solid var(--color-status-warning); + } + + .provider-card--stopped, + .provider-card--unreachable { + border-left: 3px solid var(--color-status-error); + opacity: 0.85; + } + + .provider-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; + } + + .provider-card__identity { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .provider-card__badge { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: var(--color-surface-tertiary); + font-size: 0.625rem; + font-weight: var(--font-weight-bold, 700); + letter-spacing: 0.05em; + color: var(--color-text-secondary); + flex-shrink: 0; + } + + .provider-card__name { + margin: 0; + font-size: 0.9375rem; + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-heading); + } + + .provider-card__id { + font-size: 0.6875rem; + color: var(--color-text-muted); + font-family: var(--font-mono, monospace); + } + + .provider-card__status { + display: flex; + align-items: center; + gap: 0.375rem; + flex-shrink: 0; + } + + /* ─── Status Dot ─── */ + .status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .status-dot--running { + background: var(--color-status-success); + box-shadow: 0 0 4px rgba(34, 197, 94, 0.4); + } + + .status-dot--degraded { + background: var(--color-status-warning); + box-shadow: 0 0 4px rgba(234, 179, 8, 0.4); + } + + .status-dot--stopped, + .status-dot--unreachable { + background: var(--color-status-error); + } + + .status-label { + font-size: 0.75rem; + font-weight: var(--font-weight-medium, 500); + } + + .status-text--running { color: var(--color-status-success); } + .status-text--degraded { color: var(--color-status-warning); } + .status-text--stopped, + .status-text--unreachable { color: var(--color-status-error); } + + /* ─── Metrics ─── */ + .provider-card__metrics { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-bottom: 1rem; + } + + .metric { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 0.75rem; + } + + .metric__label { + color: var(--color-text-muted); + min-width: 100px; + flex-shrink: 0; + } + + .metric__value { + color: var(--color-text-primary); + font-weight: var(--font-weight-medium, 500); + } + + .metric__value--mono { + font-family: var(--font-mono, monospace); + font-size: 0.6875rem; + word-break: break-all; + } + + /* ─── Start Instructions ─── */ + .start-instructions { + margin-bottom: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + overflow: hidden; + } + + .start-instructions__toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 0.75rem; + background: var(--color-surface-secondary); + border: none; + font-size: 0.8125rem; + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-primary); + cursor: pointer; + text-align: left; + transition: background-color 150ms ease; + } + + .start-instructions__toggle:hover { + background: var(--color-surface-tertiary); + } + + .start-instructions__toggle svg { + transition: transform 200ms ease; + } + + .start-instructions__toggle svg.rotated { + transform: rotate(90deg); + } + + .start-instructions__content { + padding: 0.75rem; + animation: slide-down 150ms ease; + } + + @keyframes slide-down { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } + } + + .command-block { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + background: var(--color-surface-tertiary); + border-radius: var(--radius-sm); + margin-bottom: 0.5rem; + } + + .command-block__code { + flex: 1; + font-family: var(--font-mono, monospace); + font-size: 0.75rem; + color: var(--color-text-primary); + word-break: break-all; + line-height: 1.5; + } + + .start-instructions__hint { + margin: 0; + font-size: 0.6875rem; + color: var(--color-text-muted); + } + + .start-instructions__hint code { + font-family: var(--font-mono, monospace); + background: var(--color-surface-tertiary); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + /* ─── Actions ─── */ + .provider-card__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .active-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-full, 999px); + background: rgba(34, 197, 94, 0.1); + color: var(--color-status-success); + font-size: 0.75rem; + font-weight: var(--font-weight-semibold, 600); + } + + /* ─── Priority ─── */ + .provider-card__priority { + display: flex; + align-items: center; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-primary); + } + + .priority-label { + font-size: 0.75rem; + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-secondary); + } + + .priority-input { + width: 56px; + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + font-family: var(--font-mono, monospace); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-primary); + text-align: center; + } + + .priority-input:focus { + outline: none; + border-color: var(--color-brand-primary, #F5A623); + box-shadow: 0 0 0 2px rgba(245, 166, 35, 0.2); + } + + .priority-hint { + font-size: 0.6875rem; + color: var(--color-text-muted); + } + + /* ─── Scope Section ─── */ + .scope-section { + margin-top: 1.5rem; + padding: 1.25rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg, 12px); + } + + .scope-section__title { + margin: 0 0 0.25rem; + font-size: 1rem; + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-heading); + } + + .scope-section__description { + margin: 0 0 1rem; + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.5; + } + + .scope-section__description code { + font-family: var(--font-mono, monospace); + background: var(--color-surface-tertiary); + padding: 0.125rem 0.25rem; + border-radius: 2px; + font-size: 0.75rem; + } + + .scope-chip { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: var(--radius-full, 999px); + background: var(--color-surface-tertiary); + font-size: 0.6875rem; + font-family: var(--font-mono, monospace); + color: var(--color-text-secondary); + } + + /* ─── Scope Table ─── */ + .scope-table { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + overflow: hidden; + } + + .scope-table__header { + display: grid; + grid-template-columns: 2fr 1fr 0.5fr 1fr; + gap: 0.75rem; + padding: 0.625rem 1rem; + background: var(--color-surface-secondary); + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold, 600); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + } + + .scope-table__row { + display: grid; + grid-template-columns: 2fr 1fr 0.5fr 1fr; + gap: 0.75rem; + padding: 0.625rem 1rem; + font-size: 0.8125rem; + border-top: 1px solid var(--color-border-primary); + align-items: center; + } + + .scope-table__provider { + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-primary); + } + + .scope-table__status { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + } + + /* ─── Buttons ─── */ + .btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + font-size: 0.8125rem; + font-weight: var(--font-weight-medium, 500); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color 150ms ease, transform 100ms ease; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn:active:not(:disabled) { + transform: scale(0.98); + } + + .btn--primary { + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + } + + .btn--primary:hover:not(:disabled) { + background: var(--color-btn-primary-bg-hover); + } + + .btn--secondary { + background: var(--color-surface-tertiary); + color: var(--color-text-primary); + border: 1px solid var(--color-border-primary); + } + + .btn--secondary:hover:not(:disabled) { + background: var(--color-surface-secondary); + } + + .btn--ghost { + background: transparent; + color: var(--color-text-secondary); + } + + .btn--ghost:hover:not(:disabled) { + background: var(--color-surface-tertiary); + } + + .btn--sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + } + + .btn__icon { + display: inline-block; + font-size: 1rem; + } + + .spin { + animation: spin 0.8s linear infinite; + } + + /* ─── Responsive ─── */ + @media (max-width: 768px) { + .page-header { + flex-direction: column; + } + + .provider-grid { + grid-template-columns: 1fr; + } + + .scope-table__header, + .scope-table__row { + grid-template-columns: 1fr 1fr; + } + } + `], +}) +export class CryptoProviderPanelComponent implements OnInit, OnDestroy { + @ViewChild('confirmDialog') confirmDialog!: ConfirmDialogComponent; + + private readonly client = inject(CryptoProviderClient); + private readonly destroy$ = new Subject(); + private readonly AUTO_REFRESH_MS = 30_000; + + // ─── State ─── + providers = signal([]); + preferences = signal([]); + loading = signal(false); + refreshing = signal(false); + loadError = signal(null); + lastChecked = signal(null); + expandedCommands = signal>(new Set()); + pendingActivation = signal(null); + + // ─── Computed ─── + activePreference = computed(() => + this.preferences().find(p => p.isActive) ?? null + ); + + confirmConfig = computed(() => { + const provider = this.pendingActivation(); + return { + title: 'Change Active Crypto Provider', + message: provider + ? `You are about to set "${provider.name}" as the active crypto provider for this tenant. ` + + `All subsequent signing and verification operations will use this provider. ` + + `This change takes effect within 60 seconds across all services.` + : '', + confirmLabel: 'Activate Provider', + }; + }); + + // ─── Lifecycle ─── + ngOnInit(): void { + interval(this.AUTO_REFRESH_MS).pipe( + startWith(0), + takeUntil(this.destroy$), + switchMap(() => { + this.loading.set(true); + this.loadError.set(null); + return forkJoin({ + health: this.client.getHealth().pipe(catchError(() => of({ providers: [] as CryptoProviderHealth[] }))), + prefs: this.client.getPreferences().pipe(catchError(() => of([] as CryptoProviderPreference[]))), + }); + }), + ).subscribe({ + next: ({ health, prefs }) => { + this.providers.set(health.providers); + this.preferences.set(prefs); + this.lastChecked.set(new Date()); + this.loading.set(false); + this.loadError.set(null); + }, + error: () => { + this.loadError.set('Unable to load crypto provider data. Check network connectivity and admin permissions.'); + this.loading.set(false); + }, + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // ─── Actions ─── + refresh(): void { + this.refreshing.set(true); + this.loadError.set(null); + + forkJoin({ + health: this.client.getHealth().pipe(catchError(() => of({ providers: [] as CryptoProviderHealth[] }))), + prefs: this.client.getPreferences().pipe(catchError(() => of([] as CryptoProviderPreference[]))), + }).subscribe({ + next: ({ health, prefs }) => { + this.providers.set(health.providers); + this.preferences.set(prefs); + this.lastChecked.set(new Date()); + this.refreshing.set(false); + this.loadError.set(null); + }, + error: () => { + this.loadError.set('Unable to refresh crypto provider data.'); + this.refreshing.set(false); + }, + }); + } + + onSetActive(provider: CryptoProviderHealth): void { + this.pendingActivation.set(provider); + this.confirmDialog.open(); + } + + onConfirmActivation(): void { + const provider = this.pendingActivation(); + if (!provider) return; + + this.client.updatePreference({ + providerId: provider.id, + algorithmScope: '*', + priority: 0, + isActive: true, + }).subscribe({ + next: () => { + this.pendingActivation.set(null); + this.refresh(); + }, + error: () => { + this.loadError.set(`Failed to activate ${provider.name}. Please try again.`); + this.pendingActivation.set(null); + }, + }); + } + + onPriorityChange(providerId: string, event: Event): void { + const input = event.target as HTMLInputElement; + const priority = parseInt(input.value, 10); + if (isNaN(priority) || priority < 0) return; + + const pref = this.preferences().find(p => p.providerId === providerId); + if (!pref) return; + + this.client.updatePreference({ + providerId: pref.providerId, + algorithmScope: pref.algorithmScope, + priority, + isActive: pref.isActive, + }).subscribe({ + next: () => this.refresh(), + error: () => this.loadError.set('Failed to update priority.'), + }); + } + + onRemovePreference(providerId: string): void { + const pref = this.preferences().find(p => p.providerId === providerId); + if (!pref) return; + + this.client.deletePreference(pref.id).subscribe({ + next: () => this.refresh(), + error: () => this.loadError.set('Failed to remove preference.'), + }); + } + + toggleStartCommand(providerId: string): void { + const expanded = new Set(this.expandedCommands()); + if (expanded.has(providerId)) { + expanded.delete(providerId); + } else { + expanded.add(providerId); + } + this.expandedCommands.set(expanded); + } + + // ─── Helpers ─── + isActiveProvider(providerId: string): boolean { + return this.activePreference()?.providerId === providerId; + } + + hasPreference(providerId: string): boolean { + return this.preferences().some(p => p.providerId === providerId); + } + + getPreferencePriority(providerId: string): number { + return this.preferences().find(p => p.providerId === providerId)?.priority ?? 0; + } + + getProviderName(providerId: string): string { + return this.providers().find(p => p.id === providerId)?.name ?? providerId; + } + + getProviderBadge(providerId: string): string { + const badges: Record = { + 'smremote': 'SM', + 'cryptopro': 'GOST', + 'crypto-sim': 'SIM', + }; + return badges[providerId] ?? providerId.substring(0, 3).toUpperCase(); + } + + getStatusLabel(status: CryptoProviderStatus): string { + return PROVIDER_STATUS_LABELS[status]; + } + + getStatusDotClass(status: CryptoProviderStatus): string { + return `status-dot--${status}`; + } + + getStatusTextClass(status: CryptoProviderStatus): string { + return `status-text--${status}`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts b/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts index 639bcdda3..61355eff5 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts @@ -132,6 +132,12 @@ export const SETTINGS_ROUTES: Routes = [ // ----------------------------------------------------------------------- // Operations config redirects -> canonical ops/setup owners // ----------------------------------------------------------------------- + { + path: 'crypto-providers', + title: 'Crypto Providers', + redirectTo: redirectToCanonical('/setup/crypto-providers'), + pathMatch: 'full' as const, + }, { path: 'integrations', title: 'Integrations', diff --git a/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts b/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts index 0657cc183..073de97f4 100644 --- a/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts @@ -60,6 +60,15 @@ export const SETUP_ROUTES: Routes = [ data: { breadcrumb: 'Environments' }, loadChildren: () => import('./topology.routes').then((m) => m.TOPOLOGY_ROUTES), }, + { + path: 'crypto-providers', + title: 'Crypto Providers', + data: { breadcrumb: 'Crypto Providers' }, + loadComponent: () => + import('../features/settings/crypto-providers/crypto-provider-panel.component').then( + (m) => m.CryptoProviderPanelComponent, + ), + }, { path: 'trust-signing', title: 'Certificates',