feat(ui): ship offline operations cutover
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { OPERATIONS_PATHS } from '../../features/platform/ops/operations-paths';
|
||||
import { NavGroup, NavigationConfig } from './navigation.types';
|
||||
|
||||
/**
|
||||
@@ -227,51 +228,51 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'pack-registry',
|
||||
label: 'Pack Registry',
|
||||
route: '/ops/packs',
|
||||
route: OPERATIONS_PATHS.packs,
|
||||
icon: 'package',
|
||||
tooltip: 'Browse TaskRunner packs, verify DSSE metadata, and run compatibility-checked installs/upgrades',
|
||||
},
|
||||
{
|
||||
id: 'quotas',
|
||||
label: 'Quota Dashboard',
|
||||
route: '/ops/quotas',
|
||||
route: OPERATIONS_PATHS.quotas,
|
||||
icon: 'gauge',
|
||||
tooltip: 'License quota consumption and capacity planning',
|
||||
children: [
|
||||
{
|
||||
id: 'quota-overview',
|
||||
label: 'Overview',
|
||||
route: '/ops/quotas',
|
||||
route: OPERATIONS_PATHS.quotas,
|
||||
tooltip: 'Quota consumption KPIs and trends',
|
||||
},
|
||||
{
|
||||
id: 'quota-tenants',
|
||||
label: 'Tenant Usage',
|
||||
route: '/ops/quotas/tenants',
|
||||
route: `${OPERATIONS_PATHS.quotas}/tenants`,
|
||||
tooltip: 'Per-tenant quota consumption',
|
||||
},
|
||||
{
|
||||
id: 'quota-throttle',
|
||||
label: 'Throttle Events',
|
||||
route: '/ops/quotas/throttle',
|
||||
route: `${OPERATIONS_PATHS.quotas}/throttle`,
|
||||
tooltip: 'Rate limit violations and recommendations',
|
||||
},
|
||||
{
|
||||
id: 'quota-forecast',
|
||||
label: 'Forecast',
|
||||
route: '/ops/quotas/forecast',
|
||||
route: `${OPERATIONS_PATHS.quotas}/forecast`,
|
||||
tooltip: 'Quota exhaustion predictions',
|
||||
},
|
||||
{
|
||||
id: 'quota-alerts',
|
||||
label: 'Alert Config',
|
||||
route: '/ops/quotas/alerts',
|
||||
route: `${OPERATIONS_PATHS.quotas}/alerts`,
|
||||
tooltip: 'Configure quota alert thresholds',
|
||||
},
|
||||
{
|
||||
id: 'quota-reports',
|
||||
label: 'Reports',
|
||||
route: '/ops/quotas/reports',
|
||||
route: `${OPERATIONS_PATHS.quotas}/reports`,
|
||||
tooltip: 'Export quota reports',
|
||||
},
|
||||
],
|
||||
@@ -348,32 +349,32 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'feed-mirror',
|
||||
label: 'Feed Mirror & AirGap',
|
||||
route: '/ops/feeds',
|
||||
route: OPERATIONS_PATHS.feedsAirgap,
|
||||
icon: 'mirror',
|
||||
tooltip: 'Vulnerability feed mirroring, offline bundles, and version locks',
|
||||
children: [
|
||||
{
|
||||
id: 'feed-dashboard',
|
||||
label: 'Dashboard',
|
||||
route: '/ops/feeds',
|
||||
route: OPERATIONS_PATHS.feedsAirgap,
|
||||
tooltip: 'Feed mirror dashboard and status',
|
||||
},
|
||||
{
|
||||
id: 'airgap-import',
|
||||
label: 'Import Bundle',
|
||||
route: '/ops/feeds/airgap/import',
|
||||
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
|
||||
tooltip: 'Import air-gapped bundles from external media',
|
||||
},
|
||||
{
|
||||
id: 'airgap-export',
|
||||
label: 'Export Bundle',
|
||||
route: '/ops/feeds/airgap/export',
|
||||
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
|
||||
tooltip: 'Create bundles for air-gapped deployment',
|
||||
},
|
||||
{
|
||||
id: 'version-locks',
|
||||
label: 'Version Locks',
|
||||
route: '/ops/feeds/version-locks',
|
||||
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
|
||||
tooltip: 'Lock feed versions for reproducible scans',
|
||||
},
|
||||
],
|
||||
@@ -381,32 +382,32 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'offline-kit',
|
||||
label: 'Offline Kit',
|
||||
route: '/ops/offline-kit',
|
||||
route: OPERATIONS_PATHS.offlineKit,
|
||||
icon: 'offline',
|
||||
tooltip: 'Offline bundle management, verification, and JWKS',
|
||||
children: [
|
||||
{
|
||||
id: 'offline-dashboard',
|
||||
label: 'Dashboard',
|
||||
route: '/ops/offline-kit/dashboard',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/dashboard`,
|
||||
tooltip: 'Offline mode status and overview',
|
||||
},
|
||||
{
|
||||
id: 'offline-bundles',
|
||||
label: 'Bundles',
|
||||
route: '/ops/offline-kit/bundles',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/bundles`,
|
||||
tooltip: 'Manage offline bundles and assets',
|
||||
},
|
||||
{
|
||||
id: 'offline-verify',
|
||||
label: 'Verification',
|
||||
route: '/ops/offline-kit/verify',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/verify`,
|
||||
tooltip: 'Verify audit bundles offline',
|
||||
},
|
||||
{
|
||||
id: 'offline-jwks',
|
||||
label: 'JWKS',
|
||||
route: '/ops/offline-kit/jwks',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/jwks`,
|
||||
tooltip: 'Manage Authority JWKS for offline validation',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -26,6 +26,9 @@ interface LoadedBundle {
|
||||
<div class="management-header">
|
||||
<h2>Bundle Management</h2>
|
||||
<p class="description">Load, verify, and manage offline bundles for air-gapped operation</p>
|
||||
@if (lastExportMessage()) {
|
||||
<p class="status-note">{{ lastExportMessage() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="management-grid">
|
||||
@@ -145,6 +148,12 @@ interface LoadedBundle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -361,6 +370,7 @@ export class BundleManagementComponent implements OnInit {
|
||||
readonly activeBundleId = signal<string | null>(null);
|
||||
readonly activeManifest = this.offlineService.cachedManifest;
|
||||
readonly assetCategories = signal<{ name: string; icon: string; count: number; assets: string[] }[]>([]);
|
||||
readonly lastExportMessage = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBundles();
|
||||
@@ -390,6 +400,7 @@ export class BundleManagementComponent implements OnInit {
|
||||
)
|
||||
]);
|
||||
this.activeBundleId.set(bundle.id);
|
||||
this.synchronizeManifestForBundle(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,10 +435,19 @@ export class BundleManagementComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
this.activeBundleId.set(bundle.id);
|
||||
this.synchronizeManifestForBundle(bundle);
|
||||
}
|
||||
|
||||
exportBundle(bundle: LoadedBundle): void {
|
||||
console.log('Exporting bundle:', bundle.id);
|
||||
const exportPayload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
bundle,
|
||||
activeBundleId: this.activeBundleId(),
|
||||
manifest: bundle.id === this.activeBundleId() ? this.activeManifest() : this.buildManifestForBundle(bundle),
|
||||
};
|
||||
|
||||
this.downloadJsonFile(`offline-bundle-${bundle.version}.json`, exportPayload);
|
||||
this.lastExportMessage.set(`Exported bundle v${bundle.version} as a portable JSON summary.`);
|
||||
}
|
||||
|
||||
removeBundle(bundle: LoadedBundle): void {
|
||||
@@ -500,4 +520,44 @@ export class BundleManagementComponent implements OnInit {
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
private synchronizeManifestForBundle(bundle: LoadedBundle): void {
|
||||
this.offlineService.loadManifest(this.buildManifestForBundle(bundle));
|
||||
}
|
||||
|
||||
private buildManifestForBundle(bundle: LoadedBundle): OfflineManifest {
|
||||
const categories = this.assetCategories();
|
||||
const findAssets = (name: string, fallback: string[]) =>
|
||||
categories.find((category) => category.name === name)?.assets ?? fallback;
|
||||
|
||||
return {
|
||||
version: bundle.version,
|
||||
createdAt: bundle.createdAt,
|
||||
expiresAt: bundle.expiresAt,
|
||||
signature: `sig:${bundle.id}:${bundle.version}`,
|
||||
assets: {
|
||||
ui: this.buildAssetRecord(findAssets('UI Assets', ['index.html', 'main.js'])),
|
||||
api_contracts: this.buildAssetRecord(findAssets('API Contracts', ['policy.openapi.json'])),
|
||||
authority: this.buildAssetRecord(findAssets('Authority', ['jwks.json'])),
|
||||
feeds: this.buildAssetRecord(findAssets('Feed Data', ['advisory_snapshot.ndjson.gz'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildAssetRecord(names: string[]): Record<string, string> {
|
||||
return names.reduce<Record<string, string>>((record, name, index) => {
|
||||
record[name] = `sha256:${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-${index}`;
|
||||
return record;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private downloadJsonFile(filename: string, payload: unknown): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// JWKS Management Component
|
||||
// Sprint 026: Offline Kit Integration
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface JwkEntry {
|
||||
kid: string;
|
||||
@@ -26,13 +27,16 @@ interface TrustAnchor {
|
||||
|
||||
@Component({
|
||||
selector: 'app-jwks-management',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="jwks-management">
|
||||
<div class="management-header">
|
||||
<h2>JWKS & Trust Anchor Management</h2>
|
||||
<p class="description">Manage Authority signing keys and trust anchors for offline token validation</p>
|
||||
@if (statusMessage()) {
|
||||
<p class="status-note">{{ statusMessage() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="management-grid">
|
||||
@@ -47,9 +51,9 @@ interface TrustAnchor {
|
||||
}
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn--secondary btn--small" (click)="importJwks()">
|
||||
Import
|
||||
</button>
|
||||
<a class="btn btn--secondary btn--small" routerLink="/setup/trust-signing">
|
||||
Open Trust & Signing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,9 +120,9 @@ interface TrustAnchor {
|
||||
<section class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Trust Anchors</h3>
|
||||
<button class="btn btn--secondary btn--small" (click)="importAnchor()">
|
||||
Import Anchor
|
||||
</button>
|
||||
<a class="btn btn--secondary btn--small" routerLink="/setup/trust-signing">
|
||||
Open Trust & Signing
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="anchors-list">
|
||||
@@ -148,6 +152,20 @@ interface TrustAnchor {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (selectedAnchor(); as anchor) {
|
||||
<div class="anchor-detail" data-testid="trust-anchor-detail">
|
||||
<h4>{{ anchor.name }}</h4>
|
||||
<p><strong>Fingerprint:</strong> <code>{{ anchor.fingerprint }}</code></p>
|
||||
<p><strong>Validity:</strong> {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</p>
|
||||
<p><strong>Status:</strong> {{ anchor.status }}</p>
|
||||
<div class="anchor-detail__actions">
|
||||
<a class="btn btn--ghost btn--small" routerLink="/setup/trust-signing">Manage in Trust & Signing</a>
|
||||
<button class="btn btn--ghost btn--small" type="button" (click)="exportAnchor(anchor)">
|
||||
Export Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -214,6 +232,12 @@ interface TrustAnchor {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -455,6 +479,27 @@ interface TrustAnchor {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.anchor-detail {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.anchor-detail h4,
|
||||
.anchor-detail p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anchor-detail__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.validation-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -607,6 +652,11 @@ export class JwksManagementComponent implements OnInit {
|
||||
readonly activeKeyCount = signal(3);
|
||||
readonly tokenInput = signal('');
|
||||
readonly validationResult = signal<{ valid: boolean; message?: string; claims?: unknown } | null>(null);
|
||||
readonly selectedAnchorId = signal<string | null>(null);
|
||||
readonly statusMessage = signal<string | null>(null);
|
||||
readonly selectedAnchor = computed(
|
||||
() => this.trustAnchors().find((anchor) => anchor.id === this.selectedAnchorId()) ?? null
|
||||
);
|
||||
|
||||
private refreshing = false;
|
||||
|
||||
@@ -625,23 +675,23 @@ export class JwksManagementComponent implements OnInit {
|
||||
// Simulate refresh
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
this.jwksLastUpdated.set(new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC');
|
||||
this.statusMessage.set('JWKS cache refreshed from the active offline trust source.');
|
||||
this.refreshing = false;
|
||||
}
|
||||
|
||||
importJwks(): void {
|
||||
console.log('Importing JWKS...');
|
||||
}
|
||||
|
||||
importAnchor(): void {
|
||||
console.log('Importing trust anchor...');
|
||||
}
|
||||
|
||||
viewAnchor(anchor: TrustAnchor): void {
|
||||
console.log('Viewing anchor:', anchor.id);
|
||||
this.selectedAnchorId.set(anchor.id);
|
||||
}
|
||||
|
||||
exportAnchor(anchor: TrustAnchor): void {
|
||||
console.log('Exporting anchor:', anchor.id);
|
||||
const blob = new Blob([JSON.stringify(anchor, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${anchor.name.replace(/[^a-z0-9.-]+/gi, '-').toLowerCase()}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.statusMessage.set(`Exported trust anchor ${anchor.name}.`);
|
||||
}
|
||||
|
||||
onTokenInput(event: Event): void {
|
||||
@@ -738,5 +788,6 @@ export class JwksManagementComponent implements OnInit {
|
||||
status: 'active'
|
||||
}
|
||||
]);
|
||||
this.selectedAnchorId.set('anchor-001');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@angular/core';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { OfflineModeService } from '../../../core/services/offline-mode.service';
|
||||
import { BundleFreshnessWidgetComponent } from '../../../shared/components/bundle-freshness-widget.component';
|
||||
import { OfflineAssetCategories } from '../../../core/api/offline-kit.models';
|
||||
@@ -14,9 +15,17 @@ interface DashboardStats {
|
||||
offlineDuration: string;
|
||||
}
|
||||
|
||||
interface DashboardFeature {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
route: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-offline-dashboard',
|
||||
imports: [BundleFreshnessWidgetComponent],
|
||||
imports: [BundleFreshnessWidgetComponent, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="offline-dashboard">
|
||||
@@ -93,16 +102,27 @@ interface DashboardStats {
|
||||
<h3>Available Features</h3>
|
||||
<div class="features-list">
|
||||
@for (feature of features(); track feature.id) {
|
||||
<div class="feature-item" [class.disabled]="!feature.available">
|
||||
<span class="feature-icon" [innerHTML]="feature.icon"></span>
|
||||
<div class="feature-info">
|
||||
<span class="feature-name">{{ feature.name }}</span>
|
||||
<span class="feature-status">
|
||||
{{ feature.available ? 'Available' : 'Requires Online' }}
|
||||
</span>
|
||||
@if (feature.route && feature.available) {
|
||||
<a class="feature-item feature-item--link" [routerLink]="feature.route">
|
||||
<span class="feature-icon" [innerHTML]="feature.icon"></span>
|
||||
<div class="feature-info">
|
||||
<span class="feature-name">{{ feature.name }}</span>
|
||||
<span class="feature-status">Open workflow</span>
|
||||
</div>
|
||||
<span class="availability-dot available"></span>
|
||||
</a>
|
||||
} @else {
|
||||
<div class="feature-item" [class.disabled]="!feature.available">
|
||||
<span class="feature-icon" [innerHTML]="feature.icon"></span>
|
||||
<div class="feature-info">
|
||||
<span class="feature-name">{{ feature.name }}</span>
|
||||
<span class="feature-status">
|
||||
{{ feature.available ? 'Available' : 'Requires Online' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="availability-dot" [class.available]="feature.available"></span>
|
||||
</div>
|
||||
<span class="availability-dot" [class.available]="feature.available"></span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -251,6 +271,16 @@ interface DashboardStats {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.feature-item--link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.feature-item--link:hover {
|
||||
border-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.feature-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -364,7 +394,7 @@ export class OfflineDashboardComponent implements OnInit {
|
||||
offlineDuration: 'N/A'
|
||||
});
|
||||
|
||||
readonly features = signal<{ id: string; name: string; icon: string; available: boolean }[]>([]);
|
||||
readonly features = signal<DashboardFeature[]>([]);
|
||||
readonly recentActivity = signal<{ id: string; message: string; time: string; icon: string }[]>([]);
|
||||
|
||||
private retrying = false;
|
||||
@@ -410,14 +440,14 @@ export class OfflineDashboardComponent implements OnInit {
|
||||
private loadFeatures(): void {
|
||||
const isOffline = this.offlineService.isOffline();
|
||||
this.features.set([
|
||||
{ id: 'dashboard', name: 'Dashboard & KPIs', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>', available: true },
|
||||
{ id: 'findings', name: 'View Findings', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', available: true },
|
||||
{ id: 'sbom', name: 'SBOM Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>', available: true },
|
||||
{ id: 'policy', name: 'Policy Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', available: true },
|
||||
{ id: 'evidence', name: 'Evidence Verification', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', available: true },
|
||||
{ id: 'triage', name: 'Triage & VEX Creation', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', available: !isOffline },
|
||||
{ id: 'integrations', name: 'Manage Integrations', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', available: !isOffline },
|
||||
{ id: 'export', name: 'Export Audit Bundles', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', available: !isOffline }
|
||||
{ id: 'dashboard', name: 'Dashboard & KPIs', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>', available: true, route: '/ops/operations/offline-kit/dashboard' },
|
||||
{ id: 'findings', name: 'View Findings', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', available: true, route: '/security/findings' },
|
||||
{ id: 'sbom', name: 'SBOM Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>', available: true, route: '/security/sbom' },
|
||||
{ id: 'policy', name: 'Policy Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', available: true, route: '/ops/policy/overview' },
|
||||
{ id: 'evidence', name: 'Evidence Verification', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', available: true, route: '/evidence/verify-replay' },
|
||||
{ id: 'triage', name: 'Triage & VEX Creation', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', available: !isOffline, route: !isOffline ? '/triage/artifacts' : null },
|
||||
{ id: 'integrations', name: 'Manage Integrations', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', available: !isOffline, route: !isOffline ? '/ops/integrations' : null },
|
||||
{ id: 'export', name: 'Export Audit Bundles', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', available: !isOffline, route: !isOffline ? '/evidence/exports' : null }
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Verification Center Component
|
||||
// Sprint 026: Offline Kit Integration
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
|
||||
|
||||
import { OfflineVerificationComponent } from '../../../shared/components/offline-verification.component';
|
||||
import { OfflineVerificationResult } from '../../../core/api/offline-kit.models';
|
||||
@@ -24,6 +24,9 @@ interface VerificationHistory {
|
||||
<div class="center-header">
|
||||
<h2>Offline Verification Center</h2>
|
||||
<p class="description">Verify audit bundles and evidence chains without network access</p>
|
||||
@if (exportMessage()) {
|
||||
<p class="status-note">{{ exportMessage() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="center-grid">
|
||||
@@ -39,7 +42,7 @@ interface VerificationHistory {
|
||||
@if (history().length > 0) {
|
||||
<div class="history-list">
|
||||
@for (item of history(); track item.id) {
|
||||
<div class="history-item" [class.valid]="item.valid">
|
||||
<div class="history-item" [class.valid]="item.valid" [class.selected]="selectedHistoryId() === item.id">
|
||||
<div class="history-status">
|
||||
<span class="status-icon">@if (item.valid) {<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>} @else {<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>}</span>
|
||||
</div>
|
||||
@@ -56,6 +59,20 @@ interface VerificationHistory {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (selectedHistory(); as selected) {
|
||||
<div class="detail-panel" data-testid="verification-detail-panel">
|
||||
<h4>{{ selected.bundleName }}</h4>
|
||||
<p>Verified {{ formatTime(selected.verifiedAt) }}</p>
|
||||
<p>
|
||||
Chain status:
|
||||
<strong>{{ selected.chainItemsValid }}/{{ selected.chainItemsTotal }}</strong>
|
||||
items valid
|
||||
</p>
|
||||
<button class="btn btn--ghost" type="button" (click)="exportReport(selected)">
|
||||
Export selected report
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<p>No verification history. Upload a bundle to verify.</p>
|
||||
@@ -107,6 +124,12 @@ interface VerificationHistory {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.center-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
@@ -147,6 +170,10 @@ interface VerificationHistory {
|
||||
border-left-color: var(--color-status-success-border);
|
||||
}
|
||||
|
||||
.history-item.selected {
|
||||
outline: 1px solid var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.history-status {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -232,6 +259,21 @@ interface VerificationHistory {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-panel h4,
|
||||
.detail-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
@@ -266,6 +308,11 @@ export class VerificationCenterComponent {
|
||||
chainItemsTotal: 6
|
||||
}
|
||||
]);
|
||||
readonly selectedHistoryId = signal<string | null>('1');
|
||||
readonly exportMessage = signal<string | null>(null);
|
||||
readonly selectedHistory = computed(() =>
|
||||
this.history().find((item) => item.id === this.selectedHistoryId()) ?? null
|
||||
);
|
||||
|
||||
onVerificationComplete(result: OfflineVerificationResult): void {
|
||||
const newEntry: VerificationHistory = {
|
||||
@@ -278,6 +325,7 @@ export class VerificationCenterComponent {
|
||||
};
|
||||
|
||||
this.history.update(h => [newEntry, ...h].slice(0, 10));
|
||||
this.selectedHistoryId.set(newEntry.id);
|
||||
}
|
||||
|
||||
formatTime(timestamp: string): string {
|
||||
@@ -287,20 +335,55 @@ export class VerificationCenterComponent {
|
||||
}
|
||||
|
||||
viewDetails(item: VerificationHistory): void {
|
||||
console.log('Viewing details for:', item.id);
|
||||
this.selectedHistoryId.set(item.id);
|
||||
}
|
||||
|
||||
verifyLastBundle(): void {
|
||||
console.log('Re-verifying last bundle...');
|
||||
const lastBundle = this.history()[0];
|
||||
if (!lastBundle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reverified: VerificationHistory = {
|
||||
...lastBundle,
|
||||
id: `${lastBundle.id}-recheck-${Date.now()}`,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.history.update((items) => [reverified, ...items].slice(0, 10));
|
||||
this.selectedHistoryId.set(reverified.id);
|
||||
}
|
||||
|
||||
exportReport(): void {
|
||||
console.log('Exporting verification report...');
|
||||
exportReport(item?: VerificationHistory): void {
|
||||
const report = item ?? this.selectedHistory() ?? this.history()[0];
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
report,
|
||||
summary: {
|
||||
valid: report.valid,
|
||||
verifiedItems: report.chainItemsValid,
|
||||
totalItems: report.chainItemsTotal,
|
||||
},
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${report.bundleName.replace(/[^a-z0-9.-]+/gi, '-').toLowerCase()}-report.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.exportMessage.set(`Exported verification report for ${report.bundleName}.`);
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
if (confirm('Clear all verification history?')) {
|
||||
this.history.set([]);
|
||||
this.selectedHistoryId.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
<div class="header-content">
|
||||
<h1>Offline Kit Management</h1>
|
||||
<p class="subtitle">Manage offline bundles, verify audit packages, and configure air-gap operation</p>
|
||||
<div class="page-shortcuts">
|
||||
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/evidence/exports">Evidence Exports</a>
|
||||
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
|
||||
<a routerLink="/setup/trust-signing">Trust & Signing</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="connection-status" [class.offline]="isOffline()">
|
||||
@@ -91,6 +97,27 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-shortcuts {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.page-shortcuts a {
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-shortcuts a:hover {
|
||||
color: var(--color-border-primary);
|
||||
border-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import {
|
||||
OPERATIONS_INTEGRATION_PATHS,
|
||||
OPERATIONS_PATHS,
|
||||
dataIntegrityPath,
|
||||
} from './operations-paths';
|
||||
|
||||
type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
type FeedsAirgapAction = 'import' | 'export' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-feeds-airgap-page',
|
||||
@@ -20,22 +26,37 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
</p>
|
||||
</div>
|
||||
<div class="feeds-offline__actions">
|
||||
<a routerLink="/ops/integrations/advisory-vex-sources">Configure Sources</a>
|
||||
<button type="button">Sync Now</button>
|
||||
<button type="button">Import Airgap Bundle</button>
|
||||
<a [routerLink]="OPERATIONS_INTEGRATION_PATHS.advisorySources">Configure Sources</a>
|
||||
<a [routerLink]="feedsFreshnessPath">Open Freshness Lens</a>
|
||||
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Bundles</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" [class.active]="tab() === 'feed-mirrors'" (click)="tab.set('feed-mirrors')">
|
||||
<nav class="tabs" aria-label="Feeds and airgap sections">
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ tab: 'feed-mirrors', action: null }"
|
||||
queryParamsHandling="merge"
|
||||
[class.active]="tab() === 'feed-mirrors'"
|
||||
>
|
||||
Feed Mirrors
|
||||
</button>
|
||||
<button type="button" [class.active]="tab() === 'airgap-bundles'" (click)="tab.set('airgap-bundles')">
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ tab: 'airgap-bundles', action: airgapAction() }"
|
||||
queryParamsHandling="merge"
|
||||
[class.active]="tab() === 'airgap-bundles'"
|
||||
>
|
||||
Airgap Bundles
|
||||
</button>
|
||||
<button type="button" [class.active]="tab() === 'version-locks'" (click)="tab.set('version-locks')">
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ tab: 'version-locks', action: null }"
|
||||
queryParamsHandling="merge"
|
||||
[class.active]="tab() === 'version-locks'"
|
||||
>
|
||||
Version Locks
|
||||
</button>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section class="summary">
|
||||
@@ -51,7 +72,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
<span>Impact: BLOCKING</span>
|
||||
<span>Mode: last-known-good snapshot (read-only)</span>
|
||||
<code>corr-feed-8841</code>
|
||||
<button type="button">Retry</button>
|
||||
<a [routerLink]="feedsFreshnessPath">Open incident</a>
|
||||
</section>
|
||||
|
||||
<article class="panel">
|
||||
@@ -88,10 +109,22 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
</table>
|
||||
}
|
||||
@if (tab() === 'airgap-bundles') {
|
||||
@if (airgapAction()) {
|
||||
<div class="action-banner" data-testid="feeds-airgap-action-banner">
|
||||
@if (airgapAction() === 'import') {
|
||||
<strong>Import workflow selected.</strong>
|
||||
<span>Use Offline Kit Bundles to load a signed airgap bundle into the active site context.</span>
|
||||
} @else {
|
||||
<strong>Export workflow selected.</strong>
|
||||
<span>Open Evidence Exports to prepare a portable bundle, then move it through Offline Kit.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p>Offline import/export workflows and bundle verification controls.</p>
|
||||
<div class="panel__links">
|
||||
<a routerLink="/ops/operations/offline-kit">Open Offline Kit Operations</a>
|
||||
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Kit Bundles</a>
|
||||
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
|
||||
<a routerLink="/evidence/verify-replay">Open Verify & Replay</a>
|
||||
</div>
|
||||
}
|
||||
@if (tab() === 'version-locks') {
|
||||
@@ -134,8 +167,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feeds-offline__actions a,
|
||||
.feeds-offline__actions button {
|
||||
.feeds-offline__actions a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
@@ -152,7 +184,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
.tabs a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
@@ -160,9 +192,10 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
.tabs a.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
@@ -204,14 +237,14 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
.status-banner button {
|
||||
.status-banner a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.67rem;
|
||||
padding: 0.08rem 0.34rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -250,6 +283,17 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.action-banner {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.5rem 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.panel__links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -267,7 +311,11 @@ export class PlatformFeedsAirgapPageComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
|
||||
readonly OPERATIONS_INTEGRATION_PATHS = OPERATIONS_INTEGRATION_PATHS;
|
||||
readonly feedsFreshnessPath = dataIntegrityPath('feeds-freshness');
|
||||
readonly tab = signal<FeedsOfflineTab>('feed-mirrors');
|
||||
readonly airgapAction = signal<FeedsAirgapAction>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParamMap
|
||||
@@ -281,6 +329,14 @@ export class PlatformFeedsAirgapPageComponent implements OnInit {
|
||||
) {
|
||||
this.tab.set(requested);
|
||||
}
|
||||
|
||||
const requestedAction = params.get('action');
|
||||
if (requestedAction === 'import' || requestedAction === 'export') {
|
||||
this.airgapAction.set(requestedAction);
|
||||
return;
|
||||
}
|
||||
|
||||
this.airgapAction.set(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
class="chip"
|
||||
[class.chip--fresh]="!isStale()"
|
||||
[class.chip--stale]="isStale()"
|
||||
routerLink="/platform/ops/feeds-airgap"
|
||||
routerLink="/ops/operations/feeds-airgap"
|
||||
[attr.title]="tooltip()"
|
||||
>
|
||||
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
@@ -123,4 +123,3 @@ export class FeedSnapshotChipComponent {
|
||||
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
class="chip"
|
||||
[class.chip--ok]="status() === 'ok'"
|
||||
[class.chip--degraded]="status() === 'degraded'"
|
||||
routerLink="/platform/ops/offline-kit"
|
||||
routerLink="/ops/operations/offline-kit"
|
||||
[attr.title]="tooltip()"
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -113,4 +113,3 @@ export class OfflineStatusChipComponent {
|
||||
return 'Online mode active with live backend connectivity.';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { Params, Router, Routes } from '@angular/router';
|
||||
|
||||
export const OPS_ROUTES: Routes = [
|
||||
{
|
||||
@@ -57,6 +58,27 @@ export const OPS_ROUTES: Routes = [
|
||||
redirectTo: 'operations/feeds-airgap',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds/version-locks',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', { tab: 'version-locks' }),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/import',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', {
|
||||
tab: 'airgap-bundles',
|
||||
action: 'import',
|
||||
}),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/export',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', {
|
||||
tab: 'airgap-bundles',
|
||||
action: 'export',
|
||||
}),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds-airgap',
|
||||
redirectTo: 'operations/feeds-airgap',
|
||||
@@ -87,6 +109,11 @@ export const OPS_ROUTES: Routes = [
|
||||
redirectTo: 'operations/offline-kit',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'offline-kit/:page',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/offline-kit/:page'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'quotas',
|
||||
redirectTo: 'operations/quotas',
|
||||
@@ -98,3 +125,32 @@ export const OPS_ROUTES: Routes = [
|
||||
pathMatch: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
function preserveOpsRedirect(template: string, extraQueryParams: Params = {}) {
|
||||
return ({
|
||||
params,
|
||||
queryParams,
|
||||
fragment,
|
||||
}: {
|
||||
params: Params;
|
||||
queryParams: Params;
|
||||
fragment?: string | null;
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl(interpolateOpsRedirectTarget(template, params));
|
||||
target.queryParams = { ...target.queryParams, ...extraQueryParams, ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
function interpolateOpsRedirectTarget(template: string, params: Params): string {
|
||||
let target = template;
|
||||
|
||||
for (const [name, rawValue] of Object.entries(params ?? {})) {
|
||||
const value = Array.isArray(rawValue) ? rawValue.join('/') : String(rawValue);
|
||||
target = target.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,21 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
|
||||
},
|
||||
{ path: 'jobs-queues', redirectTo: OPERATIONS_PATHS.jobsQueues },
|
||||
{ path: 'feeds', redirectTo: OPERATIONS_PATHS.feedsAirgap },
|
||||
{
|
||||
path: 'feeds/version-locks',
|
||||
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/import',
|
||||
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/export',
|
||||
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
|
||||
},
|
||||
{ path: 'feeds-airgap', redirectTo: OPERATIONS_PATHS.feedsAirgap },
|
||||
{ path: 'offline-kit', redirectTo: OPERATIONS_PATHS.offlineKit },
|
||||
{ path: 'offline-kit/:page', redirectTo: `${OPERATIONS_PATHS.offlineKit}/:page` },
|
||||
{ path: 'health', redirectTo: OPERATIONS_PATHS.healthSlo },
|
||||
{ path: 'health-slo', redirectTo: OPERATIONS_PATHS.healthSlo },
|
||||
{ path: 'doctor', redirectTo: OPERATIONS_PATHS.doctor },
|
||||
@@ -76,7 +89,7 @@ export const PLATFORM_OPS_ROUTES: Routes = [
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl(interpolateLegacyTarget(template.redirectTo, params));
|
||||
target.queryParams = { ...queryParams };
|
||||
target.queryParams = { ...target.queryParams, ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { JwksManagementComponent } from '../../app/features/offline-kit/components/jwks-management.component';
|
||||
|
||||
describe('JwksManagementComponent', () => {
|
||||
let fixture: ComponentFixture<JwksManagementComponent>;
|
||||
let component: JwksManagementComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [JwksManagementComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(JwksManagementComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('shows the selected trust anchor in the detail panel', () => {
|
||||
component.viewAnchor(component.trustAnchors()[1]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const detailPanel = fixture.nativeElement.querySelector('[data-testid="trust-anchor-detail"]') as HTMLElement;
|
||||
expect(detailPanel).not.toBeNull();
|
||||
expect(detailPanel.textContent).toContain('Enterprise Intermediate CA');
|
||||
});
|
||||
|
||||
it('exports trust anchor details through a real download flow', () => {
|
||||
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:anchor');
|
||||
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
|
||||
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
||||
|
||||
component.exportAnchor(component.trustAnchors()[0]);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(anchorClickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:anchor');
|
||||
expect(component.statusMessage()).toContain('StellaOps Root CA');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
import { OfflineManifest } from '../../app/core/api/offline-kit.models';
|
||||
import { BundleValidationResult, OfflineManifest } from '../../app/core/api/offline-kit.models';
|
||||
import { OfflineModeService } from '../../app/core/services/offline-mode.service';
|
||||
import { BundleManagementComponent } from '../../app/features/offline-kit/components/bundle-management.component';
|
||||
import { offlineKitRoutes } from '../../app/features/offline-kit/offline-kit.routes';
|
||||
@@ -65,4 +65,43 @@ describe('Offline Kit UI Integration (B24-001)', () => {
|
||||
expect(root?.children?.some((child) => child.path === 'verify')).toBeTrue();
|
||||
expect(root?.children?.some((child) => child.path === 'jwks')).toBeTrue();
|
||||
});
|
||||
|
||||
it('loads the generated manifest into offline state when a valid bundle is added', () => {
|
||||
const result: BundleValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
assetIntegrity: {
|
||||
totalAssets: 6,
|
||||
validAssets: 6,
|
||||
invalidAssets: 0,
|
||||
missingAssets: [],
|
||||
hashMismatches: [],
|
||||
},
|
||||
signatureStatus: {
|
||||
valid: true,
|
||||
algorithm: 'ES256',
|
||||
},
|
||||
};
|
||||
|
||||
component.onManifestValidated(result);
|
||||
|
||||
expect(offlineStub.loadManifest).toHaveBeenCalled();
|
||||
expect(component.activeBundleId()).not.toBeNull();
|
||||
expect(component.loadedBundles()[0].status).toBe('active');
|
||||
});
|
||||
|
||||
it('exports bundle summaries through a real download flow', () => {
|
||||
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
|
||||
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
|
||||
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
||||
const bundle = component.loadedBundles()[0];
|
||||
|
||||
component.exportBundle(bundle);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(anchorClickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test');
|
||||
expect(component.lastExportMessage()).toContain(`v${bundle.version}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { VerificationCenterComponent } from '../../app/features/offline-kit/components/verification-center.component';
|
||||
|
||||
describe('VerificationCenterComponent', () => {
|
||||
let fixture: ComponentFixture<VerificationCenterComponent>;
|
||||
let component: VerificationCenterComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VerificationCenterComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VerificationCenterComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('shows a detail panel for the selected verification history item', () => {
|
||||
const buttons = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('.history-item .btn--ghost'),
|
||||
) as HTMLButtonElement[];
|
||||
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const detailPanel = fixture.nativeElement.querySelector('[data-testid="verification-detail-panel"]') as HTMLElement;
|
||||
expect(detailPanel).not.toBeNull();
|
||||
expect(detailPanel.textContent).toContain('audit-bundle-2025-01-14.zip');
|
||||
});
|
||||
|
||||
it('re-verifies the latest bundle by prepending a fresh history record', () => {
|
||||
const initialLength = component.history().length;
|
||||
|
||||
component.verifyLastBundle();
|
||||
|
||||
expect(component.history().length).toBe(initialLength + 1);
|
||||
expect(component.history()[0].id).toContain('recheck');
|
||||
});
|
||||
|
||||
it('exports a verification report through a real download flow', () => {
|
||||
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:verification');
|
||||
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
|
||||
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
||||
|
||||
component.exportReport(component.history()[0]);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(anchorClickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:verification');
|
||||
expect(component.exportMessage()).toContain(component.history()[0].bundleName);
|
||||
});
|
||||
});
|
||||
@@ -40,4 +40,13 @@ describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => {
|
||||
|
||||
expect(component.tab()).toBe('version-locks');
|
||||
});
|
||||
|
||||
it('tracks the airgap action from query params for canonical action links', () => {
|
||||
queryParamMap$.next(convertToParamMap({ tab: 'airgap-bundles', action: 'import' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.tab()).toBe('airgap-bundles');
|
||||
expect(component.airgapAction()).toBe('import');
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,12 +28,16 @@ describe('Platform and Operations route contracts', () => {
|
||||
const aliases = OPS_ROUTES.filter((route) => route.redirectTo);
|
||||
expect(aliases.map((route) => route.path)).toEqual([
|
||||
'feeds',
|
||||
'feeds/version-locks',
|
||||
'feeds/airgap/import',
|
||||
'feeds/airgap/export',
|
||||
'feeds-airgap',
|
||||
'airgap',
|
||||
'health-slo',
|
||||
'signals',
|
||||
'scheduler',
|
||||
'offline-kit',
|
||||
'offline-kit/:page',
|
||||
'quotas',
|
||||
'packs',
|
||||
]);
|
||||
@@ -82,8 +86,12 @@ describe('Platform and Operations route contracts', () => {
|
||||
'data-integrity/:section',
|
||||
'jobs-queues',
|
||||
'feeds',
|
||||
'feeds/version-locks',
|
||||
'feeds/airgap/import',
|
||||
'feeds/airgap/export',
|
||||
'feeds-airgap',
|
||||
'offline-kit',
|
||||
'offline-kit/:page',
|
||||
'health',
|
||||
'health-slo',
|
||||
'doctor',
|
||||
|
||||
167
src/Web/StellaOps.Web/tests/e2e/offline-operations.spec.ts
Normal file
167
src/Web/StellaOps.Web/tests/e2e/offline-operations.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||
|
||||
const adminSession: StubAuthSession = {
|
||||
subjectId: 'offline-ops-e2e-user',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'admin',
|
||||
'ui.read',
|
||||
'ui.admin',
|
||||
'orch:read',
|
||||
'orch:operate',
|
||||
'health:read',
|
||||
'notify.viewer',
|
||||
'policy:read',
|
||||
],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: '/authority',
|
||||
clientId: 'stella-ops-ui',
|
||||
authorizeEndpoint: '/authority/connect/authorize',
|
||||
tokenEndpoint: '/authority/connect/token',
|
||||
logoutEndpoint: '/authority/connect/logout',
|
||||
redirectUri: 'https://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
|
||||
scope: 'openid profile email ui.read',
|
||||
audience: '/gateway',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: '/authority',
|
||||
scanner: '/scanner',
|
||||
policy: '/policy',
|
||||
concelier: '/concelier',
|
||||
attestor: '/attestor',
|
||||
gateway: '/gateway',
|
||||
},
|
||||
quickstartMode: true,
|
||||
setup: 'complete',
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown): Promise<void> {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateClientSide(page: Page, target: string): Promise<void> {
|
||||
await page.evaluate((url) => {
|
||||
window.history.pushState({}, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
|
||||
}, target);
|
||||
}
|
||||
|
||||
async function setupHarness(page: Page): Promise<void> {
|
||||
await page.addInitScript((session) => {
|
||||
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||
}, adminSession);
|
||||
|
||||
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
fulfillJson(route, {
|
||||
issuer: 'https://127.0.0.1:4400/authority',
|
||||
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
||||
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
||||
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
}),
|
||||
);
|
||||
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
|
||||
await page.route('**/console/profile**', (route) =>
|
||||
fulfillJson(route, {
|
||||
subjectId: adminSession.subjectId,
|
||||
username: 'offline-ops-e2e',
|
||||
displayName: 'Offline Ops E2E',
|
||||
tenant: adminSession.tenant,
|
||||
roles: ['admin'],
|
||||
scopes: adminSession.scopes,
|
||||
}),
|
||||
);
|
||||
await page.route('**/console/token/introspect**', (route) =>
|
||||
fulfillJson(route, {
|
||||
active: true,
|
||||
tenant: adminSession.tenant,
|
||||
subject: adminSession.subjectId,
|
||||
scopes: adminSession.scopes,
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v2/context/regions', (route) =>
|
||||
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
|
||||
);
|
||||
await page.route('**/api/v2/context/environments**', (route) =>
|
||||
fulfillJson(route, [
|
||||
{
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
environmentType: 'prod',
|
||||
displayName: 'Prod',
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await page.route('**/api/v2/context/preferences', (route) =>
|
||||
fulfillJson(route, {
|
||||
tenantId: adminSession.tenant,
|
||||
actorId: adminSession.subjectId,
|
||||
regions: ['eu-west'],
|
||||
environments: ['prod'],
|
||||
timeWindow: '24h',
|
||||
stage: 'all',
|
||||
updatedAt: '2026-03-08T12:00:00Z',
|
||||
updatedBy: adminSession.subjectId,
|
||||
}),
|
||||
);
|
||||
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
|
||||
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
|
||||
await page.route('**/health', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupHarness(page);
|
||||
});
|
||||
|
||||
test('offline kit exposes working canonical shortcuts and child routes', async ({ page }) => {
|
||||
await page.goto('/ops/operations/offline-kit', { waitUntil: 'networkidle' });
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Offline Kit Management' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Evidence Exports' })).toBeVisible();
|
||||
|
||||
await page.locator('a.tab-link', { hasText: 'Bundles' }).click();
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/offline-kit\/bundles$/);
|
||||
await expect(page.getByText('Load New Bundle')).toBeVisible();
|
||||
});
|
||||
|
||||
test('legacy offline aliases resolve into canonical operations routes', async ({ page }) => {
|
||||
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
|
||||
|
||||
await navigateClientSide(page, '/platform/ops/offline-kit/bundles?from=legacy');
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/offline-kit\/bundles\?from=legacy$/);
|
||||
await expect(page.getByText('Bundle Management')).toBeVisible();
|
||||
|
||||
await navigateClientSide(page, '/ops/feeds/airgap/import');
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/feeds-airgap/);
|
||||
await expect
|
||||
.poll(() => {
|
||||
const currentUrl = new URL(page.url());
|
||||
return `${currentUrl.searchParams.get('tab')}:${currentUrl.searchParams.get('action')}`;
|
||||
})
|
||||
.toBe('airgap-bundles:import');
|
||||
await expect(page.getByTestId('feeds-airgap-action-banner')).toContainText('Import workflow selected.');
|
||||
});
|
||||
Reference in New Issue
Block a user