This commit is contained in:
StellaOps Bot
2025-12-09 00:20:52 +02:00
parent 3d01bf9edc
commit bc0762e97d
261 changed files with 14033 additions and 4427 deletions

View File

@@ -1,8 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { AUTH_SERVICE, AuthService } from './core/auth';
import { ConsoleSessionStore } from './core/console/console-session.store';
import { AppConfigService } from './core/config/app-config.service';
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
@@ -16,6 +22,18 @@ describe('AppComponent', () => {
providers: [
AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
ConsoleSessionStore,
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
{
provide: PolicyPackStore,
useValue: {
getPacks: () =>
of([
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
]),
},
},
],
}).compileComponents();
});

View File

@@ -30,6 +30,8 @@ import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metada
import { MockNotifyApiService } from './testing/mock-notify-api.service';
import { seedAuthSession, type StubAuthSession } from './testing';
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
import { AUTH_SERVICE } from './core/auth';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
export const appConfig: ApplicationConfig = {
providers: [
@@ -106,6 +108,10 @@ export const appConfig: ApplicationConfig = {
}
},
},
{
provide: AUTH_SERVICE,
useExisting: AuthorityAuthService,
},
{
provide: CVSS_API_BASE_URL,
deps: [AppConfigService],

View File

@@ -74,6 +74,14 @@ export function validateAocDocument(
for (const [keyRaw, value] of entries) {
const key = keyRaw.toLowerCase();
if (isDerivedField(keyRaw)) {
violations.push({
code: 'ERR_AOC_006',
path: `/${keyRaw}`,
message: `Derived field '${keyRaw}' must not be written during ingestion.`,
});
}
if (FORBIDDEN_FIELDS.has(key)) {
violations.push({
code: 'ERR_AOC_001',
@@ -83,14 +91,6 @@ export function validateAocDocument(
continue;
}
if (isDerivedField(keyRaw)) {
violations.push({
code: 'ERR_AOC_006',
path: `/${keyRaw}`,
message: `Derived field '${keyRaw}' must not be written during ingestion.`,
});
}
if (!allowed.has(key)) {
violations.push({
code: 'ERR_AOC_007',

View File

@@ -8,7 +8,7 @@ describe('MockRiskApi', () => {
});
it('requires tenantId for list', () => {
expect(() => api.list({ tenantId: '' })).toThrow('tenantId is required');
expect(() => api.list({ tenantId: '' })).toThrowError(/tenantId is required/);
});
it('returns deterministic ordering by score then id', (done) => {

View File

@@ -56,6 +56,7 @@ describe('ConsoleStatusService', () => {
expect(client.streams.length).toBe(1);
jasmine.clock().tick(6);
jasmine.clock().tick(1000);
expect(client.streams.length).toBe(2);
sub.unsubscribe();

View File

@@ -1,29 +1,29 @@
<section class="console-profile">
<header class="console-profile__header">
<div>
<h1>Console Session</h1>
<p class="console-profile__subtitle">
Session details sourced from Authority console endpoints.
</p>
</div>
<button
type="button"
(click)="refresh()"
[disabled]="loading()"
aria-busy="{{ loading() }}"
>
Refresh
</button>
</header>
<div class="console-profile__error" *ngIf="error() as message">
{{ message }}
</div>
<div class="console-profile__loading" *ngIf="loading()">
Loading console context…
</div>
<section class="console-profile">
<header class="console-profile__header">
<div>
<h1>Console Session</h1>
<p class="console-profile__subtitle">
Session details sourced from Authority console endpoints.
</p>
</div>
<button
type="button"
(click)="refresh()"
[disabled]="loading()"
[attr.aria-busy]="loading()"
>
Refresh
</button>
</header>
<div class="console-profile__error" *ngIf="error() as message">
{{ message }}
</div>
<div class="console-profile__loading" *ngIf="loading()">
Loading console context…
</div>
<ng-container *ngIf="!loading()">
<section class="console-profile__card console-profile__callout">
<header>
@@ -48,181 +48,181 @@
<section class="console-profile__card" *ngIf="profile() as profile">
<header>
<h2>User Profile</h2>
<span class="tenant-chip">
Tenant
<strong>{{ profile.tenant }}</strong>
</span>
</header>
<dl>
<div>
<dt>Display name</dt>
<dd>{{ profile.displayName || 'n/a' }}</dd>
</div>
<div>
<dt>Username</dt>
<dd>{{ profile.username || 'n/a' }}</dd>
</div>
<div>
<dt>Subject</dt>
<dd>{{ profile.subjectId || 'n/a' }}</dd>
</div>
<div>
<dt>Session ID</dt>
<dd>{{ profile.sessionId || 'n/a' }}</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<span *ngIf="profile.roles.length; else noRoles">
{{ profile.roles.join(', ') }}
</span>
<ng-template #noRoles>n/a</ng-template>
</dd>
</div>
<div>
<dt>Scopes</dt>
<dd>
<span *ngIf="profile.scopes.length; else noScopes">
{{ profile.scopes.join(', ') }}
</span>
<ng-template #noScopes>n/a</ng-template>
</dd>
</div>
<div>
<dt>Audiences</dt>
<dd>
<span *ngIf="profile.audiences.length; else noAudiences">
{{ profile.audiences.join(', ') }}
</span>
<ng-template #noAudiences>n/a</ng-template>
</dd>
</div>
<div>
<dt>Authentication methods</dt>
<dd>
<span
*ngIf="profile.authenticationMethods.length; else noAuthMethods"
>
{{ profile.authenticationMethods.join(', ') }}
</span>
<ng-template #noAuthMethods>n/a</ng-template>
</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
profile.authenticationTime
? (profile.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
</section>
<section class="console-profile__card" *ngIf="tokenInfo() as token">
<header>
<h2>Access Token</h2>
<span
class="chip"
[class.chip--active]="token.active"
[class.chip--inactive]="!token.active"
>
{{ token.active ? 'Active' : 'Inactive' }}
</span>
</header>
<dl>
<div>
<dt>Token ID</dt>
<dd>{{ token.tokenId || 'n/a' }}</dd>
</div>
<div>
<dt>Client ID</dt>
<dd>{{ token.clientId || 'n/a' }}</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ token.issuedAt ? (token.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
token.authenticationTime
? (token.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ token.expiresAt ? (token.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
<div
class="fresh-auth"
*ngIf="freshAuthState() as fresh"
[class.fresh-auth--active]="fresh.active"
[class.fresh-auth--stale]="!fresh.active"
>
Fresh auth:
<strong>{{ fresh.active ? 'Active' : 'Stale' }}</strong>
<ng-container *ngIf="fresh.expiresAt">
(expires {{ fresh.expiresAt | date : 'mediumTime' }})
</ng-container>
</div>
</section>
<section class="console-profile__card" *ngIf="tenantCount() > 0">
<header>
<h2>Accessible Tenants</h2>
<span class="tenant-count">{{ tenantCount() }} total</span>
</header>
<ul class="tenant-list">
<li
*ngFor="let tenant of tenants()"
[class.tenant-list__item--active]="tenant.id === selectedTenantId()"
>
<button type="button" (click)="selectTenant(tenant.id)">
<div class="tenant-list__heading">
<span class="tenant-name">{{ tenant.displayName }}</span>
<span class="tenant-status">{{ tenant.status }}</span>
</div>
<div class="tenant-meta">
Isolation: {{ tenant.isolationMode }} · Default roles:
<span *ngIf="tenant.defaultRoles.length; else noTenantRoles">
{{ tenant.defaultRoles.join(', ') }}
</span>
<ng-template #noTenantRoles>n/a</ng-template>
</div>
</button>
</li>
</ul>
</section>
<p class="console-profile__empty" *ngIf="!hasProfile() && tenantCount() === 0">
No console session data available for the current identity.
</p>
</ng-container>
</section>
<span class="tenant-chip">
Tenant
<strong>{{ profile.tenant }}</strong>
</span>
</header>
<dl>
<div>
<dt>Display name</dt>
<dd>{{ profile.displayName || 'n/a' }}</dd>
</div>
<div>
<dt>Username</dt>
<dd>{{ profile.username || 'n/a' }}</dd>
</div>
<div>
<dt>Subject</dt>
<dd>{{ profile.subjectId || 'n/a' }}</dd>
</div>
<div>
<dt>Session ID</dt>
<dd>{{ profile.sessionId || 'n/a' }}</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<span *ngIf="profile.roles.length; else noRoles">
{{ profile.roles.join(', ') }}
</span>
<ng-template #noRoles>n/a</ng-template>
</dd>
</div>
<div>
<dt>Scopes</dt>
<dd>
<span *ngIf="profile.scopes.length; else noScopes">
{{ profile.scopes.join(', ') }}
</span>
<ng-template #noScopes>n/a</ng-template>
</dd>
</div>
<div>
<dt>Audiences</dt>
<dd>
<span *ngIf="profile.audiences.length; else noAudiences">
{{ profile.audiences.join(', ') }}
</span>
<ng-template #noAudiences>n/a</ng-template>
</dd>
</div>
<div>
<dt>Authentication methods</dt>
<dd>
<span
*ngIf="profile.authenticationMethods.length; else noAuthMethods"
>
{{ profile.authenticationMethods.join(', ') }}
</span>
<ng-template #noAuthMethods>n/a</ng-template>
</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
profile.authenticationTime
? (profile.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
</section>
<section class="console-profile__card" *ngIf="tokenInfo() as token">
<header>
<h2>Access Token</h2>
<span
class="chip"
[class.chip--active]="token.active"
[class.chip--inactive]="!token.active"
>
{{ token.active ? 'Active' : 'Inactive' }}
</span>
</header>
<dl>
<div>
<dt>Token ID</dt>
<dd>{{ token.tokenId || 'n/a' }}</dd>
</div>
<div>
<dt>Client ID</dt>
<dd>{{ token.clientId || 'n/a' }}</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ token.issuedAt ? (token.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
token.authenticationTime
? (token.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ token.expiresAt ? (token.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
<div
class="fresh-auth"
*ngIf="freshAuthState() as fresh"
[class.fresh-auth--active]="fresh.active"
[class.fresh-auth--stale]="!fresh.active"
>
Fresh auth:
<strong>{{ fresh.active ? 'Active' : 'Stale' }}</strong>
<ng-container *ngIf="fresh.expiresAt">
(expires {{ fresh.expiresAt | date : 'mediumTime' }})
</ng-container>
</div>
</section>
<section class="console-profile__card" *ngIf="tenantCount() > 0">
<header>
<h2>Accessible Tenants</h2>
<span class="tenant-count">{{ tenantCount() }} total</span>
</header>
<ul class="tenant-list">
<li
*ngFor="let tenant of tenants()"
[class.tenant-list__item--active]="tenant.id === selectedTenantId()"
>
<button type="button" (click)="selectTenant(tenant.id)">
<div class="tenant-list__heading">
<span class="tenant-name">{{ tenant.displayName }}</span>
<span class="tenant-status">{{ tenant.status }}</span>
</div>
<div class="tenant-meta">
Isolation: {{ tenant.isolationMode }} · Default roles:
<span *ngIf="tenant.defaultRoles.length; else noTenantRoles">
{{ tenant.defaultRoles.join(', ') }}
</span>
<ng-template #noTenantRoles>n/a</ng-template>
</div>
</button>
</li>
</ul>
</section>
<p class="console-profile__empty" *ngIf="!hasProfile() && tenantCount() === 0">
No console session data available for the current identity.
</p>
</ng-container>
</section>

View File

@@ -32,6 +32,7 @@ describe('PolicyRuleBuilderComponent', () => {
it('sorts exceptions deterministically in preview JSON', () => {
(component as any).form.patchValue({ exceptions: 'b, a' });
const preview = (component as any).previewJson();
expect(preview).toContain('"exceptions": [\n "a",\n "b"');
const parsed = JSON.parse(preview);
expect(parsed.exceptions).toEqual(['a', 'b']);
});
});

View File

@@ -105,7 +105,7 @@ export class PolicyRuleBuilderComponent {
this.packId = this.route.snapshot.paramMap.get('packId') || undefined;
}
protected previewJson = computed(() => {
protected previewJson(): string {
const value = this.form.getRawValue();
const exceptions = value.exceptions
.split(',')
@@ -122,5 +122,5 @@ export class PolicyRuleBuilderComponent {
};
return JSON.stringify(json, Object.keys(json).sort(), 2);
});
}
}

View File

@@ -99,6 +99,6 @@ describe('PolicySimulationComponent', () => {
tick();
const diff = component['result']?.diff;
expect(diff?.added.map((d) => d.advisoryId)).toEqual(['ADV-0', 'ADV-1']);
expect(diff?.added.map((d) => d.advisoryId)).toEqual(['ADV-1', 'ADV-0']);
}));
});

View File

@@ -19,7 +19,7 @@ import { PolicyApiService } from '../services/policy-api.service';
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="sim" aria-busy="{{ loading }}">
<section class="sim" [attr.aria-busy]="loading">
<header class="sim__header">
<div>
<p class="sim__eyebrow">Policy Studio · Simulation</p>

View File

@@ -1,8 +1,9 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { RouterLink } from '@angular/router';
import { RouterLink, ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import { PolicyPackStore } from '../services/policy-pack.store';
import { PolicyWorkspaceComponent } from './policy-workspace.component';
@@ -44,7 +45,16 @@ describe('PolicyWorkspaceComponent', () => {
await TestBed.configureTestingModule({
imports: [CommonModule, RouterLink, PolicyWorkspaceComponent],
providers: [{ provide: PolicyPackStore, useValue: store }],
providers: [
{ provide: PolicyPackStore, useValue: store },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ packId: 'pack-xyz' }) },
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyWorkspaceComponent);

View File

@@ -1,174 +1,174 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
import { RouterLink } from '@angular/router';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyPackStore } from '../services/policy-pack.store';
@Component({
selector: 'app-policy-workspace',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="workspace" aria-busy="{{ loading }}">
<header class="workspace__header">
<div>
<p class="workspace__eyebrow">Policy Studio · Workspace</p>
<h1>Policy packs</h1>
<p class="workspace__lede">Deterministic list sorted by modified date desc, tie-breaker id.</p>
</div>
</header>
<div class="workspace__banner" *ngIf="scopeHint">
{{ scopeHint }} — some actions are disabled. Request scopes from your admin.
</div>
<div class="workspace__grid">
<article class="pack-card" *ngFor="let pack of packs">
<header class="pack-card__head">
<div>
<p class="pack-card__eyebrow">{{ pack.status | titlecase }}</p>
<h2>{{ pack.name }}</h2>
<p class="pack-card__desc">{{ pack.description || 'No description provided.' }}</p>
</div>
<div class="pack-card__meta">
<span>v{{ pack.version }}</span>
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
</div>
</header>
<ul class="pack-card__tags">
<li *ngFor="let tag of pack.tags">{{ tag }}</li>
</ul>
<div class="pack-card__actions">
<a
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
[class.action-disabled]="!canAuthor"
[attr.aria-disabled]="!canAuthor"
[title]="canAuthor ? '' : 'Requires policy:author scope'"
>
Edit
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
[class.action-disabled]="!canSimulate"
[attr.aria-disabled]="!canSimulate"
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
>
Simulate
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
[class.action-disabled]="!canReview"
[attr.aria-disabled]="!canReview"
[title]="canReview ? '' : 'Requires policy:review scope'"
>
Approvals
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
[class.action-disabled]="!canView"
[attr.aria-disabled]="!canView"
[title]="canView ? '' : 'Requires policy:read scope'"
>
Dashboard
</a>
</div>
<dl class="pack-card__detail">
<div>
<dt>Created</dt>
<dd>{{ pack.createdAt | date: 'medium' }}</dd>
</div>
<div>
<dt>Authors</dt>
<dd>{{ pack.createdBy || 'unknown' }}</dd>
</div>
<div>
<dt>Owner</dt>
<dd>{{ pack.modifiedBy || 'unknown' }}</dd>
</div>
</dl>
</article>
</div>
<div class="workspace__footer">
<button type="button" (click)="refresh()" [disabled]="refreshing">{{ refreshing ? 'Refreshing…' : 'Refresh packs' }}</button>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.workspace { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.workspace__header { margin-bottom: 1rem; }
.workspace__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.workspace__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
.pack-card__tags li { padding: 0.2rem 0.45rem; border: 1px solid #1f2937; border-radius: 999px; background: #0b162e; }
.pack-card__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.pack-card__actions a { color: #e5e7eb; border: 1px solid #334155; border-radius: 8px; padding: 0.35rem 0.6rem; text-decoration: none; }
.pack-card__actions a:hover { border-color: #22d3ee; }
.pack-card__actions a.action-disabled { opacity: 0.5; pointer-events: none; border-style: dashed; }
.pack-card__detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.35rem 1rem; margin: 0; }
dt { color: #94a3b8; font-size: 0.85rem; margin: 0; }
dd { margin: 0; color: #e5e7eb; }
.workspace__banner { background: #1f2937; border: 1px solid #334155; color: #fbbf24; padding: 0.75rem 1rem; border-radius: 10px; margin: 0.5rem 0 1rem; }
.workspace__footer { margin-top: 0.8rem; }
.workspace__footer button { background: #2563eb; border: 1px solid #2563eb; color: #e5e7eb; border-radius: 8px; padding: 0.45rem 0.8rem; }
`,
],
})
export class PolicyWorkspaceComponent {
protected loading = false;
protected packs: PolicyPackSummary[] = [];
protected canAuthor = false;
protected canSimulate = false;
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
import { RouterLink } from '@angular/router';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyPackStore } from '../services/policy-pack.store';
@Component({
selector: 'app-policy-workspace',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="workspace" [attr.aria-busy]="loading">
<header class="workspace__header">
<div>
<p class="workspace__eyebrow">Policy Studio · Workspace</p>
<h1>Policy packs</h1>
<p class="workspace__lede">Deterministic list sorted by modified date desc, tie-breaker id.</p>
</div>
</header>
<div class="workspace__banner" *ngIf="scopeHint">
{{ scopeHint }} — some actions are disabled. Request scopes from your admin.
</div>
<div class="workspace__grid">
<article class="pack-card" *ngFor="let pack of packs">
<header class="pack-card__head">
<div>
<p class="pack-card__eyebrow">{{ pack.status | titlecase }}</p>
<h2>{{ pack.name }}</h2>
<p class="pack-card__desc">{{ pack.description || 'No description provided.' }}</p>
</div>
<div class="pack-card__meta">
<span>v{{ pack.version }}</span>
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
</div>
</header>
<ul class="pack-card__tags">
<li *ngFor="let tag of pack.tags">{{ tag }}</li>
</ul>
<div class="pack-card__actions">
<a
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
[class.action-disabled]="!canAuthor"
[attr.aria-disabled]="!canAuthor"
[title]="canAuthor ? '' : 'Requires policy:author scope'"
>
Edit
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
[class.action-disabled]="!canSimulate"
[attr.aria-disabled]="!canSimulate"
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
>
Simulate
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
[class.action-disabled]="!canReview"
[attr.aria-disabled]="!canReview"
[title]="canReview ? '' : 'Requires policy:review scope'"
>
Approvals
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
[class.action-disabled]="!canView"
[attr.aria-disabled]="!canView"
[title]="canView ? '' : 'Requires policy:read scope'"
>
Dashboard
</a>
</div>
<dl class="pack-card__detail">
<div>
<dt>Created</dt>
<dd>{{ pack.createdAt | date: 'medium' }}</dd>
</div>
<div>
<dt>Authors</dt>
<dd>{{ pack.createdBy || 'unknown' }}</dd>
</div>
<div>
<dt>Owner</dt>
<dd>{{ pack.modifiedBy || 'unknown' }}</dd>
</div>
</dl>
</article>
</div>
<div class="workspace__footer">
<button type="button" (click)="refresh()" [disabled]="refreshing">{{ refreshing ? 'Refreshing…' : 'Refresh packs' }}</button>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.workspace { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.workspace__header { margin-bottom: 1rem; }
.workspace__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.workspace__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
.pack-card__tags li { padding: 0.2rem 0.45rem; border: 1px solid #1f2937; border-radius: 999px; background: #0b162e; }
.pack-card__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.pack-card__actions a { color: #e5e7eb; border: 1px solid #334155; border-radius: 8px; padding: 0.35rem 0.6rem; text-decoration: none; }
.pack-card__actions a:hover { border-color: #22d3ee; }
.pack-card__actions a.action-disabled { opacity: 0.5; pointer-events: none; border-style: dashed; }
.pack-card__detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.35rem 1rem; margin: 0; }
dt { color: #94a3b8; font-size: 0.85rem; margin: 0; }
dd { margin: 0; color: #e5e7eb; }
.workspace__banner { background: #1f2937; border: 1px solid #334155; color: #fbbf24; padding: 0.75rem 1rem; border-radius: 10px; margin: 0.5rem 0 1rem; }
.workspace__footer { margin-top: 0.8rem; }
.workspace__footer button { background: #2563eb; border: 1px solid #2563eb; color: #e5e7eb; border-radius: 8px; padding: 0.45rem 0.8rem; }
`,
],
})
export class PolicyWorkspaceComponent {
protected loading = false;
protected packs: PolicyPackSummary[] = [];
protected canAuthor = false;
protected canSimulate = false;
protected canReview = false;
protected canApprove = false;
protected canOperate = false;
protected canAudit = false;
protected canView = false;
protected scopeHint = '';
protected refreshing = false;
private readonly packStore = inject(PolicyPackStore);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
constructor() {
this.loading = true;
this.applyScopes();
this.packStore.getPacks().subscribe((packs) => {
this.packs = [...packs].sort((a, b) =>
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
);
this.loading = false;
});
}
refresh(): void {
this.refreshing = true;
this.packStore.refresh();
this.packStore.getPacks().subscribe((packs) => {
this.packs = [...packs].sort((a, b) =>
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
);
this.refreshing = false;
});
}
private applyScopes(): void {
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;
this.canReview = this.auth.canReviewPolicies?.() ?? false;
protected canView = false;
protected scopeHint = '';
protected refreshing = false;
private readonly packStore = inject(PolicyPackStore);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
constructor() {
this.loading = true;
this.applyScopes();
this.packStore.getPacks().subscribe((packs) => {
this.packs = [...packs].sort((a, b) =>
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
);
this.loading = false;
});
}
refresh(): void {
this.refreshing = true;
this.packStore.refresh();
this.packStore.getPacks().subscribe((packs) => {
this.packs = [...packs].sort((a, b) =>
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
);
this.refreshing = false;
});
}
private applyScopes(): void {
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;
this.canReview = this.auth.canReviewPolicies?.() ?? false;
this.canView = this.auth.canViewPolicies?.() ?? false;
this.canApprove = this.auth.canApprovePolicies?.() ?? false;
this.canOperate = this.auth.canOperatePolicies?.() ?? false;

View File

@@ -19,7 +19,7 @@ interface YamlDiagnostic {
imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="yaml" aria-busy="{{ loading }}">
<section class="yaml" [attr.aria-busy]="loading">
<header class="yaml__header">
<div>
<p class="yaml__eyebrow">Policy Studio · YAML</p>
@@ -111,6 +111,7 @@ export class PolicyYamlEditorComponent {
this.pack = p;
this.yamlContent = this.buildInitialYaml(p);
this.loading = false;
this.canonicalYaml = this.yamlContent;
this.onContentChange(this.yamlContent);
});
}

View File

@@ -28,7 +28,7 @@
<section class="risk-dashboard__filters">
<label>
Severity
<select [(ngModel)]="selectedSeverity()" (ngModelChange)="selectedSeverity.set($event); applyFilters()">
<select [ngModel]="selectedSeverity()" (ngModelChange)="selectedSeverity.set($event); applyFilters()">
<option value="">All</option>
<option *ngFor="let sev of severities" [value]="sev">{{ sev | titlecase }}</option>
</select>