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) {
+
+
+
+
+
+
+ @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.
+
+
+
+ @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',