|
|
|
|
@@ -1,177 +1,186 @@
|
|
|
|
|
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" 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 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 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;
|
|
|
|
|
this.canView = this.auth.canViewPolicies?.() ?? false;
|
|
|
|
|
this.canApprove = this.auth.canApprovePolicies?.() ?? false;
|
|
|
|
|
this.canOperate = this.auth.canOperatePolicies?.() ?? false;
|
|
|
|
|
this.canAudit = this.auth.canAuditPolicies?.() ?? false;
|
|
|
|
|
const missing: string[] = [];
|
|
|
|
|
if (!this.canView) missing.push('policy:read');
|
|
|
|
|
if (!this.canAuthor) missing.push('policy:author');
|
|
|
|
|
if (!this.canSimulate) missing.push('policy:simulate');
|
|
|
|
|
if (!this.canReview) missing.push('policy:review');
|
|
|
|
|
if (!this.canApprove) missing.push('policy:approve');
|
|
|
|
|
if (!this.canOperate) missing.push('policy:operate');
|
|
|
|
|
if (!this.canAudit) missing.push('policy:audit');
|
|
|
|
|
this.scopeHint = missing.length ? `Missing scopes: ${missing.join(', ')}` : '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|