feat: Implement DefaultCryptoHmac for compliance-aware HMAC operations
- Added DefaultCryptoHmac class implementing ICryptoHmac interface. - Introduced purpose-based HMAC computation methods. - Implemented verification methods for HMACs with constant-time comparison. - Created HmacAlgorithms and HmacPurpose classes for well-known identifiers. - Added compliance profile support for HMAC algorithms. - Included asynchronous methods for HMAC computation from streams.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
|
||||
| UI-POLICY-23-002 | DONE (2025-12-05) | YAML editor route `/policy-studio/packs/:packId/yaml` with canonical preview and lint diagnostics. |
|
||||
| UI-POLICY-23-003 | DONE (2025-12-05) | Rule Builder route `/policy-studio/packs/:packId/rules` with guided inputs and deterministic preview JSON. |
|
||||
| UI-POLICY-23-004 | DONE (2025-12-05) | Approval workflow UI updated with readiness checklist, schedule window card, comment thread, and two-person indicator; tests attempted but Angular CLI hit missing rxjs util module. |
|
||||
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
|
||||
| UI-POLICY-23-006 | DOING (2025-12-05) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON export; PDF export pending backend. |
|
||||
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('PolicyApprovalsComponent', () => {
|
||||
'getApprovalWorkflow',
|
||||
'submitForReview',
|
||||
'addReview',
|
||||
'updateApprovalSchedule',
|
||||
'updateChecklist',
|
||||
'addComment',
|
||||
]);
|
||||
|
||||
api.getApprovalWorkflow.and.returnValue(
|
||||
@@ -46,11 +49,31 @@ describe('PolicyApprovalsComponent', () => {
|
||||
],
|
||||
requiredApprovers: 2,
|
||||
currentApprovers: 1,
|
||||
checklist: [
|
||||
{ id: 'c1', label: 'Coverage present', hint: 'Link to coverage run', required: true, status: 'pending' },
|
||||
{ id: 'c2', label: 'Simulated', hint: 'Simulation diff attached', required: true, status: 'complete' },
|
||||
],
|
||||
comments: [
|
||||
{ id: 'cm1', authorId: 'user-a', authorName: 'User A', message: 'Initial submit', createdAt: '2025-12-05T00:05:00Z' },
|
||||
],
|
||||
schedule: {
|
||||
start: '2025-12-10T00:00',
|
||||
end: '2025-12-11T00:00',
|
||||
},
|
||||
}) as any
|
||||
);
|
||||
|
||||
api.submitForReview.and.returnValue(of({}) as any);
|
||||
api.addReview.and.returnValue(of({}) as any);
|
||||
api.updateApprovalSchedule.and.returnValue(of({}) as any);
|
||||
api.updateChecklist.and.returnValue(of([]) as any);
|
||||
api.addComment.and.returnValue(of({
|
||||
id: 'cm2',
|
||||
authorId: 'user-x',
|
||||
authorName: 'User X',
|
||||
message: 'Ack',
|
||||
createdAt: '2025-12-05T02:00:00Z',
|
||||
}) as any);
|
||||
|
||||
auth = {
|
||||
canApprovePolicies: () => true,
|
||||
@@ -86,11 +109,13 @@ describe('PolicyApprovalsComponent', () => {
|
||||
expect(reviews[1].reviewerId).toBe('user-b');
|
||||
});
|
||||
|
||||
it('includes schedule fields in submission payload', () => {
|
||||
it('submits with schedule window attached', () => {
|
||||
component.submitForm.patchValue({
|
||||
message: 'Please review',
|
||||
scheduleStart: '2025-12-10T00:00',
|
||||
scheduleEnd: '2025-12-11T00:00',
|
||||
});
|
||||
component.scheduleForm.patchValue({
|
||||
start: '2025-12-10T00:00',
|
||||
end: '2025-12-11T00:00',
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
@@ -106,13 +131,27 @@ describe('PolicyApprovalsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('calls addReview with decision', fakeAsync(() => {
|
||||
component.reviewForm.setValue({ comment: 'Approve now' });
|
||||
component.onReview('approve');
|
||||
tick();
|
||||
expect(api.addReview).toHaveBeenCalledWith('pack-1', '1.0.0', {
|
||||
decision: 'approve',
|
||||
comment: 'Approve now',
|
||||
it('persists schedule changes via updateApprovalSchedule', () => {
|
||||
component.scheduleForm.patchValue({ start: '2025-12-12T00:00', end: '2025-12-13T00:00' });
|
||||
component.onScheduleSave();
|
||||
expect(api.updateApprovalSchedule).toHaveBeenCalledWith('pack-1', '1.0.0', {
|
||||
start: '2025-12-12T00:00',
|
||||
end: '2025-12-13T00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates checklist status', fakeAsync(() => {
|
||||
component.setChecklistStatus(component.checklistSorted[0], 'complete');
|
||||
tick();
|
||||
const sentChecklist = api.updateChecklist.calls.mostRecent().args[2];
|
||||
expect(sentChecklist[0].status).toBe('complete');
|
||||
}));
|
||||
|
||||
it('posts a comment', fakeAsync(() => {
|
||||
component.commentForm.setValue({ message: 'Looks good' });
|
||||
component.onComment();
|
||||
tick();
|
||||
expect(api.addComment).toHaveBeenCalledWith('pack-1', '1.0.0', 'Looks good');
|
||||
expect(component.sortedComments.length).toBeGreaterThan(1);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
|
||||
import {
|
||||
type ApprovalChecklistItem,
|
||||
type ApprovalComment,
|
||||
type ApprovalReview,
|
||||
type ApprovalScheduleWindow,
|
||||
type ApprovalWorkflow,
|
||||
type PolicySubmissionRequest,
|
||||
} from '../models/policy.models';
|
||||
@@ -25,7 +28,7 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
<p class="approvals__eyebrow">Policy Studio · Approvals</p>
|
||||
<h1>Submit, review, approve</h1>
|
||||
<p class="approvals__lede">
|
||||
Two-person approval with deterministic audit trail. Status: {{ workflow?.status || 'unknown' | titlecase }}
|
||||
Two-person approval with deterministic audit trail and scoped activation windows.
|
||||
</p>
|
||||
</div>
|
||||
<div class="approvals__meta" *ngIf="workflow">
|
||||
@@ -37,6 +40,12 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
<span class="approvals__badge" [class.approvals__badge--ready]="isReadyToApprove" [class.approvals__badge--missing]="!isReadyToApprove">
|
||||
Two-person rule: {{ isReadyToApprove ? 'Satisfied' : 'Missing second approver' }}
|
||||
</span>
|
||||
<span class="approvals__badge" [class.approvals__badge--ready]="pendingChecklist === 0" [class.approvals__badge--missing]="pendingChecklist > 0">
|
||||
Checklist: {{ pendingChecklist === 0 ? 'Ready' : pendingChecklist + ' open item' + (pendingChecklist === 1 ? '' : 's') }}
|
||||
</span>
|
||||
<span class="approvals__badge" [class.approvals__badge--ready]="scheduleSummary !== 'Unscheduled'" [class.approvals__badge--missing]="scheduleSummary === 'Unscheduled'">
|
||||
Schedule: {{ scheduleSummary }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -59,17 +68,35 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
<span>Simulation diff reference (optional)</span>
|
||||
<input formControlName="simulationDiff" placeholder="Run ID or artifact path" />
|
||||
</label>
|
||||
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">
|
||||
{{ submitting ? 'Submitting…' : 'Submit for review' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card--accent">
|
||||
<header>
|
||||
<h3>Scope scheduling</h3>
|
||||
<p>Define when the approved scope becomes active.</p>
|
||||
</header>
|
||||
<form [formGroup]="scheduleForm" (ngSubmit)="onScheduleSave()" class="stack">
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span>Scope start (UTC)</span>
|
||||
<input type="datetime-local" formControlName="scheduleStart" />
|
||||
<input type="datetime-local" formControlName="start" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Scope end (UTC)</span>
|
||||
<input type="datetime-local" formControlName="scheduleEnd" />
|
||||
<input type="datetime-local" formControlName="end" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">{{ submitting ? 'Submitting…' : 'Submit for review' }}</button>
|
||||
<div class="schedule__summary">
|
||||
<strong>{{ scheduleSummary }}</strong>
|
||||
<span class="muted">Persisted per policy version; deterministic ISO-8601.</span>
|
||||
</div>
|
||||
<button class="btn btn--ghost" type="submit" [disabled]="scheduleSaving">
|
||||
{{ scheduleSaving ? 'Saving…' : 'Save schedule' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +119,36 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="readiness" *ngIf="workflow">
|
||||
<header class="readiness__header">
|
||||
<div>
|
||||
<h3>Readiness checklist</h3>
|
||||
<p>Shared guardrails for authors and reviewers.</p>
|
||||
</div>
|
||||
<span class="badge" [class.badge--ready]="pendingChecklist === 0">
|
||||
{{ pendingChecklist === 0 ? 'All items complete' : pendingChecklist + ' open' }}
|
||||
</span>
|
||||
</header>
|
||||
<ul class="checklist">
|
||||
<li *ngFor="let item of checklistSorted">
|
||||
<div class="checklist__row">
|
||||
<div>
|
||||
<div class="checklist__label">{{ item.label }}</div>
|
||||
<div class="checklist__hint">{{ item.hint }}</div>
|
||||
</div>
|
||||
<span class="checklist__pill" [attr.data-status]="item.status">
|
||||
{{ item.status === 'complete' ? 'Complete' : item.status === 'blocked' ? 'Blocked' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="checklist__actions">
|
||||
<button class="btn btn--ghost" type="button" [disabled]="checklistSaving" (click)="setChecklistStatus(item, 'complete')">Mark complete</button>
|
||||
<button class="btn btn--ghost" type="button" [disabled]="checklistSaving" (click)="setChecklistStatus(item, 'pending')">Reset</button>
|
||||
<button class="btn btn--warn" type="button" [disabled]="checklistSaving" (click)="setChecklistStatus(item, 'blocked')">Flag block</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="timeline" *ngIf="workflow">
|
||||
<header class="timeline__header">
|
||||
<h3>Approvals log</h3>
|
||||
@@ -111,6 +168,31 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="comments" *ngIf="workflow">
|
||||
<header class="comments__header">
|
||||
<h3>Comments</h3>
|
||||
<p>Deterministic thread; newest first.</p>
|
||||
</header>
|
||||
<ol class="comments__list">
|
||||
<li *ngFor="let comment of sortedComments">
|
||||
<div class="comments__meta">
|
||||
<strong>{{ comment.authorName }}</strong>
|
||||
<span class="muted">{{ comment.createdAt | date:'medium' }}</span>
|
||||
</div>
|
||||
<p class="comments__body">{{ comment.message }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
<form [formGroup]="commentForm" (ngSubmit)="onComment()" class="stack comments__form">
|
||||
<label class="field">
|
||||
<span>Add comment</span>
|
||||
<textarea rows="2" formControlName="message" placeholder="Share context, risks, or requests"></textarea>
|
||||
</label>
|
||||
<button class="btn" type="submit" [disabled]="commentForm.invalid || commenting">
|
||||
{{ commenting ? 'Posting…' : 'Post comment' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
@@ -126,12 +208,16 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.approvals__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.approvals__eyebrow {
|
||||
@@ -174,7 +260,6 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -182,7 +267,12 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.25);
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.card--accent {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 15px 40px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
@@ -219,6 +309,12 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
font-family: 'Monaco','Consolas', monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -243,8 +339,84 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
.btn--ghost { background: transparent; border-color: #334155; color: #cbd5e1; }
|
||||
|
||||
.approvals__badge {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.approvals__badge--ready { border-color: #22c55e; color: #22c55e; }
|
||||
.approvals__badge--missing { border-color: #f59e0b; color: #f59e0b; }
|
||||
|
||||
.readiness {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.readiness__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.badge--ready {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
list-style: none;
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.checklist__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.checklist__label {
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.checklist__hint { color: #94a3b8; margin-top: 0.1rem; }
|
||||
|
||||
.checklist__pill {
|
||||
align-self: start;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.checklist__pill[data-status='complete'] { border-color: #22c55e; color: #22c55e; }
|
||||
.checklist__pill[data-status='pending'] { border-color: #f59e0b; color: #f59e0b; }
|
||||
.checklist__pill[data-status='blocked'] { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
.checklist__actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-top: 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
@@ -271,28 +443,54 @@ import { PolicyApiService } from '../services/policy-api.service';
|
||||
.timeline__time { font-size: 0.9rem; }
|
||||
.timeline__comment { margin: 0.15rem 0 0; color: #e5e7eb; }
|
||||
|
||||
.comments {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.comments__list { list-style: none; margin: 0.5rem 0 1rem; padding: 0; display: grid; gap: 0.6rem; }
|
||||
.comments__meta { display: flex; gap: 0.5rem; align-items: baseline; }
|
||||
.comments__body { margin: 0.15rem 0 0; color: #e5e7eb; }
|
||||
.muted { color: #94a3b8; font-size: 0.9rem; }
|
||||
|
||||
.schedule__summary { display: flex; flex-direction: column; gap: 0.15rem; color: #cbd5e1; }
|
||||
|
||||
@media (max-width: 960px) { .approvals__header { flex-direction: column; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyApprovalsComponent {
|
||||
protected workflow?: ApprovalWorkflow;
|
||||
protected checklist: ApprovalChecklistItem[] = [];
|
||||
protected comments: ApprovalComment[] = [];
|
||||
protected loading = false;
|
||||
protected submitting = false;
|
||||
protected reviewing = false;
|
||||
protected checklistSaving = false;
|
||||
protected scheduleSaving = false;
|
||||
protected commenting = false;
|
||||
|
||||
protected readonly submitForm = this.fb.group({
|
||||
message: ['', [Validators.required, Validators.minLength(5)]],
|
||||
coverageResults: [''],
|
||||
simulationDiff: [''],
|
||||
scheduleStart: [''],
|
||||
scheduleEnd: [''],
|
||||
});
|
||||
|
||||
protected readonly reviewForm = this.fb.group({
|
||||
comment: ['', [Validators.required, Validators.minLength(3)]],
|
||||
});
|
||||
|
||||
protected readonly scheduleForm = this.fb.group({
|
||||
start: [''],
|
||||
end: [''],
|
||||
});
|
||||
|
||||
protected readonly commentForm = this.fb.group({
|
||||
message: ['', [Validators.required, Validators.minLength(2)]],
|
||||
});
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
@@ -300,7 +498,21 @@ export class PolicyApprovalsComponent {
|
||||
|
||||
get sortedReviews(): ApprovalReview[] {
|
||||
if (!this.workflow?.reviews) return [];
|
||||
return [...this.workflow.reviews].sort((a, b) => b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId));
|
||||
return [...this.workflow.reviews].sort((a, b) =>
|
||||
b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId)
|
||||
);
|
||||
}
|
||||
|
||||
get checklistSorted(): ApprovalChecklistItem[] {
|
||||
return this.sortChecklist(this.checklist);
|
||||
}
|
||||
|
||||
get sortedComments(): ApprovalComment[] {
|
||||
return this.sortComments(this.comments);
|
||||
}
|
||||
|
||||
get pendingChecklist(): number {
|
||||
return this.checklist.filter((item) => item.status !== 'complete').length;
|
||||
}
|
||||
|
||||
get isReadyToApprove(): boolean {
|
||||
@@ -308,6 +520,15 @@ export class PolicyApprovalsComponent {
|
||||
return this.workflow.currentApprovers >= this.workflow.requiredApprovers;
|
||||
}
|
||||
|
||||
get scheduleSummary(): string {
|
||||
const start = this.workflow?.schedule?.start;
|
||||
const end = this.workflow?.schedule?.end;
|
||||
if (start && end) return `${start} → ${end} UTC`;
|
||||
if (start) return `Starts ${start} UTC`;
|
||||
if (end) return `Ends ${end} UTC`;
|
||||
return 'Unscheduled';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
@@ -317,14 +538,16 @@ export class PolicyApprovalsComponent {
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (!packId || this.submitForm.invalid) return;
|
||||
|
||||
const schedule = this.schedulePayload();
|
||||
|
||||
const payload: PolicySubmissionRequest = {
|
||||
policyId: packId,
|
||||
version: version ?? 'latest',
|
||||
message: this.submitForm.value.message ?? '',
|
||||
coverageResults: this.submitForm.value.coverageResults ?? undefined,
|
||||
simulationDiff: this.submitForm.value.simulationDiff ?? undefined,
|
||||
scheduleStart: this.submitForm.value.scheduleStart ?? undefined,
|
||||
scheduleEnd: this.submitForm.value.scheduleEnd ?? undefined,
|
||||
scheduleStart: schedule.start,
|
||||
scheduleEnd: schedule.end,
|
||||
};
|
||||
|
||||
this.submitting = true;
|
||||
@@ -336,6 +559,19 @@ export class PolicyApprovalsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onScheduleSave(): void {
|
||||
if (!this.workflow) return;
|
||||
|
||||
const schedule = this.schedulePayload();
|
||||
this.scheduleSaving = true;
|
||||
this.policyApi
|
||||
.updateApprovalSchedule(this.workflow.policyId, this.workflow.policyVersion, schedule)
|
||||
.pipe(finalize(() => (this.scheduleSaving = false)))
|
||||
.subscribe({
|
||||
next: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
onReview(decision: 'approve' | 'reject' | 'request_changes'): void {
|
||||
if (!this.workflow || this.reviewForm.invalid) return;
|
||||
if (decision === 'approve' && !this.auth.canApprovePolicies?.()) return;
|
||||
@@ -353,6 +589,44 @@ export class PolicyApprovalsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
setChecklistStatus(item: ApprovalChecklistItem, status: ApprovalChecklistItem['status']): void {
|
||||
if (!this.workflow) return;
|
||||
const updated = this.checklist.map((entry) =>
|
||||
entry.id === item.id
|
||||
? {
|
||||
...entry,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: entry
|
||||
);
|
||||
|
||||
this.checklist = this.sortChecklist(updated);
|
||||
this.checklistSaving = true;
|
||||
this.policyApi
|
||||
.updateChecklist(this.workflow.policyId, this.workflow.policyVersion, updated)
|
||||
.pipe(finalize(() => (this.checklistSaving = false)))
|
||||
.subscribe({
|
||||
next: (serverChecklist) => (this.checklist = this.sortChecklist(serverChecklist ?? updated)),
|
||||
});
|
||||
}
|
||||
|
||||
onComment(): void {
|
||||
if (!this.workflow || this.commentForm.invalid) return;
|
||||
const message = this.commentForm.value.message ?? '';
|
||||
|
||||
this.commenting = true;
|
||||
this.policyApi
|
||||
.addComment(this.workflow.policyId, this.workflow.policyVersion, message)
|
||||
.pipe(finalize(() => (this.commenting = false)))
|
||||
.subscribe({
|
||||
next: (comment) => {
|
||||
this.commentForm.reset();
|
||||
this.comments = this.sortComments([comment, ...this.comments]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
@@ -363,7 +637,37 @@ export class PolicyApprovalsComponent {
|
||||
.getApprovalWorkflow(packId, version ?? 'latest')
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (wf) => (this.workflow = wf),
|
||||
next: (wf) => this.applyWorkflow(wf),
|
||||
});
|
||||
}
|
||||
|
||||
private applyWorkflow(workflow: ApprovalWorkflow): void {
|
||||
this.workflow = workflow;
|
||||
this.checklist = this.sortChecklist(workflow.checklist ?? []);
|
||||
this.comments = this.sortComments(workflow.comments ?? []);
|
||||
this.scheduleForm.patchValue(
|
||||
{
|
||||
start: workflow.schedule?.start ?? '',
|
||||
end: workflow.schedule?.end ?? '',
|
||||
},
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
|
||||
private sortChecklist(items: readonly ApprovalChecklistItem[]): ApprovalChecklistItem[] {
|
||||
return [...items].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
private sortComments(items: readonly ApprovalComment[]): ApprovalComment[] {
|
||||
return [...items].sort(
|
||||
(a, b) => b.createdAt.localeCompare(a.createdAt) || a.authorId.localeCompare(b.authorId)
|
||||
);
|
||||
}
|
||||
|
||||
private schedulePayload(): ApprovalScheduleWindow {
|
||||
return {
|
||||
start: this.scheduleForm.value.start || undefined,
|
||||
end: this.scheduleForm.value.end || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,9 @@ export interface ApprovalWorkflow {
|
||||
readonly reviews: readonly ApprovalReview[];
|
||||
readonly requiredApprovers: number;
|
||||
readonly currentApprovers: number;
|
||||
readonly checklist: readonly ApprovalChecklistItem[];
|
||||
readonly comments: readonly ApprovalComment[];
|
||||
readonly schedule?: ApprovalScheduleWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +302,38 @@ export interface ApprovalReview {
|
||||
readonly reviewedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval checklist item describing pre-merge guardrails.
|
||||
*/
|
||||
export interface ApprovalChecklistItem {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly hint?: string;
|
||||
readonly required: boolean;
|
||||
readonly status: 'pending' | 'complete' | 'blocked';
|
||||
readonly updatedAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment on an approval workflow.
|
||||
*/
|
||||
export interface ApprovalComment {
|
||||
readonly id: string;
|
||||
readonly authorId: string;
|
||||
readonly authorName: string;
|
||||
readonly message: string;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled activation window for policy scope.
|
||||
*/
|
||||
export interface ApprovalScheduleWindow {
|
||||
readonly start?: string;
|
||||
readonly end?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy run dashboard data.
|
||||
*/
|
||||
@@ -350,6 +385,8 @@ export interface PolicySubmissionRequest {
|
||||
readonly message: string;
|
||||
readonly coverageResults?: string;
|
||||
readonly simulationDiff?: string;
|
||||
readonly scheduleStart?: string;
|
||||
readonly scheduleEnd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,9 @@ import type {
|
||||
PolicyRunDashboard,
|
||||
PolicySubmissionRequest,
|
||||
PolicyPromotionRequest,
|
||||
ApprovalChecklistItem,
|
||||
ApprovalComment,
|
||||
ApprovalScheduleWindow,
|
||||
} from '../models/policy.models';
|
||||
|
||||
/**
|
||||
@@ -238,6 +241,20 @@ export class PolicyApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the activation window for an approval workflow.
|
||||
*/
|
||||
updateApprovalSchedule(
|
||||
packId: string,
|
||||
version: string,
|
||||
schedule: ApprovalScheduleWindow
|
||||
): Observable<ApprovalScheduleWindow> {
|
||||
return this.http.put<ApprovalScheduleWindow>(
|
||||
`${API_BASE}/packs/${packId}/versions/${version}/approval/schedule`,
|
||||
schedule
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a review to the approval workflow.
|
||||
*
|
||||
@@ -259,6 +276,34 @@ export class PolicyApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the approval checklist for a policy version.
|
||||
*/
|
||||
updateChecklist(
|
||||
packId: string,
|
||||
version: string,
|
||||
checklist: ApprovalChecklistItem[]
|
||||
): Observable<ApprovalChecklistItem[]> {
|
||||
return this.http.put<ApprovalChecklistItem[]>(
|
||||
`${API_BASE}/packs/${packId}/versions/${version}/approval/checklist`,
|
||||
{ checklist }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a discussion comment.
|
||||
*/
|
||||
addComment(
|
||||
packId: string,
|
||||
version: string,
|
||||
message: string
|
||||
): Observable<ApprovalComment> {
|
||||
return this.http.post<ApprovalComment>(
|
||||
`${API_BASE}/packs/${packId}/versions/${version}/approval/comments`,
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a policy to a target environment.
|
||||
* Requires interactive authentication (policy:promote scope).
|
||||
|
||||
Reference in New Issue
Block a user