up
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user