filegrc 0.16.7 → 0.16.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/applicability-scope.js +22 -22
- package/src/audit-preparation.js +3 -0
- package/src/batch-review.js +12 -2
- package/src/program-readiness.js +32 -6
- package/src/state.js +20 -1
- package/src/web.js +124 -84
- package/src/workflow.js +3 -2
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@ const unorderedStringArrayFields = new Set([
|
|
|
21
21
|
"tags"
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
|
-
export function applicabilityScopeRevision(record, program, resources, model) {
|
|
24
|
+
export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
|
|
25
25
|
const selectedSystemIds = new Set(program?.systemIds || []);
|
|
26
26
|
const selectedFrameworkIds = new Set(program?.frameworkIds || []);
|
|
27
27
|
const selectedControlIds = new Set(program?.controlIds || []);
|
|
@@ -117,19 +117,19 @@ export function applicabilityScopeRevision(record, program, resources, model) {
|
|
|
117
117
|
policies: resources
|
|
118
118
|
.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
|
|
119
119
|
.sort(compareRecordIds)
|
|
120
|
-
.map((policy) =>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
.map((policy) => options.legacyPolicyStatus
|
|
121
|
+
? pick({
|
|
122
|
+
...policy,
|
|
123
|
+
status: options.legacyPolicyStatus === "approved" && ["in-review", "active"].includes(policy.status)
|
|
124
|
+
? "approved" : policy.status,
|
|
125
|
+
effectiveOn: options.legacyPolicyStatus === "approved" && policy.status === "active"
|
|
126
|
+
? undefined : policy.effectiveOn
|
|
127
|
+
}, [
|
|
128
|
+
"id", "status", "policyKind", "version", "programRole", "effectiveOn",
|
|
129
|
+
"requirementIds", "audience", "acknowledgementRequired",
|
|
130
|
+
"relatedDocumentIds", "approvedContentRevisions"
|
|
131
|
+
], model)
|
|
132
|
+
: pick(policy, ["id", "requirementIds"], model)),
|
|
133
133
|
requirements: resources
|
|
134
134
|
.filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId))
|
|
135
135
|
.sort(compareRecordIds)
|
|
@@ -165,14 +165,14 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
165
165
|
&& !review.scopeRevision.startsWith("filegrc:applicability-scope:")
|
|
166
166
|
&& Number(model?.modelVersion || 0) < 7
|
|
167
167
|
) return true;
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
168
|
+
if (!review?.scopeRevision) return false;
|
|
169
|
+
const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
|
|
170
|
+
if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
|
|
171
|
+
// Reviews written before the scope fix included Policy lifecycle fields. Accept
|
|
172
|
+
// that earlier fingerprint only when its material inputs still match.
|
|
173
|
+
if (matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "current" }))) return true;
|
|
174
|
+
return resources.some(({ type, status }) => type === "policy" && ["in-review", "active"].includes(status))
|
|
175
|
+
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "approved" }));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function pick(record, fields, model, objectType = null) {
|
package/src/audit-preparation.js
CHANGED
|
@@ -1831,6 +1831,9 @@ function item(id, status, title, message, resource = {}, options = {}) {
|
|
|
1831
1831
|
message,
|
|
1832
1832
|
...(resource.type ? { resourceType: resource.type } : {}),
|
|
1833
1833
|
...(resource.id ? { resourceId: resource.id } : {}),
|
|
1834
|
+
...(resource.type ? { destination: resource.id
|
|
1835
|
+
? { kind: "record", resourceType: resource.type, resourceId: resource.id }
|
|
1836
|
+
: { kind: "collection", resourceType: resource.type } } : {}),
|
|
1834
1837
|
...(options.commands?.length ? { commands: options.commands } : {}),
|
|
1835
1838
|
...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
|
|
1836
1839
|
...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
|
package/src/batch-review.js
CHANGED
|
@@ -40,6 +40,8 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
40
40
|
&& applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
|
|
41
41
|
))
|
|
42
42
|
.map(({ requirementId }) => requirementId));
|
|
43
|
+
const requirementReviews = new Map((program.requirementApplicability || [])
|
|
44
|
+
.map((review) => [review.requirementId, review]));
|
|
43
45
|
const records = loaded.resources.filter((record) => (
|
|
44
46
|
REVIEWABLE_TYPES.has(record.type)
|
|
45
47
|
&& (!requestedType || record.type === requestedType)
|
|
@@ -56,10 +58,18 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
56
58
|
.sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
|
|
57
59
|
.map((record) => {
|
|
58
60
|
const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
|
|
61
|
+
const saved = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
62
|
+
? requirementReviews.get(record.id)
|
|
63
|
+
: record.applicabilityReview;
|
|
64
|
+
const prior = saved && ["applicable", "not-applicable", "externally-managed", "zero-population"].includes(saved.decision)
|
|
65
|
+
? saved : null;
|
|
59
66
|
return {
|
|
60
67
|
id: record.id,
|
|
61
|
-
decision: constraint?.requiredDecision || null,
|
|
62
|
-
rationale: constraint?.
|
|
68
|
+
decision: constraint?.requiredDecision || prior?.decision || null,
|
|
69
|
+
rationale: constraint?.requiredDecision && constraint.requiredDecision !== prior?.decision
|
|
70
|
+
? constraint.defaultRationale || null
|
|
71
|
+
: prior?.rationale || constraint?.defaultRationale || null,
|
|
72
|
+
reviewStatus: prior ? "stale" : "missing",
|
|
63
73
|
...(constraint ? { constraint } : {})
|
|
64
74
|
};
|
|
65
75
|
})
|
package/src/program-readiness.js
CHANGED
|
@@ -493,12 +493,17 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
493
493
|
const requirementById = new Map(records
|
|
494
494
|
.filter(({ type }) => type === "requirement")
|
|
495
495
|
.map((record) => [record.id, record]));
|
|
496
|
-
const
|
|
496
|
+
const reviewedRequirements = (workspace?.requirementApplicability || [])
|
|
497
497
|
.filter((decision) => (
|
|
498
498
|
requirementById.has(decision.requirementId)
|
|
499
499
|
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
|
|
500
|
-
))
|
|
501
|
-
|
|
500
|
+
));
|
|
501
|
+
const v4Decisions = new Map(reviewedRequirements.map((decision) => [decision.requirementId, decision.decision]));
|
|
502
|
+
const staleRequirementIds = new Set((workspace?.requirementApplicability || [])
|
|
503
|
+
.filter((decision) => requirementById.has(decision.requirementId)
|
|
504
|
+
&& ["applicable", "not-applicable"].includes(decision.decision)
|
|
505
|
+
&& !v4Decisions.has(decision.requirementId))
|
|
506
|
+
.map(({ requirementId }) => requirementId));
|
|
502
507
|
const applicableRequirements = records.filter((record) => (
|
|
503
508
|
record.type === "requirement"
|
|
504
509
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
@@ -514,6 +519,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
514
519
|
const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
515
520
|
const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
|
|
516
521
|
const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
522
|
+
const staleRequirements = unresolvedRequirements.filter(({ id }) => staleRequirementIds.has(id));
|
|
523
|
+
const missingDecisionRequirements = unresolvedRequirements.filter(({ id }) => !staleRequirementIds.has(id));
|
|
517
524
|
const uncoveredRequirements = scope.requirements.filter((requirement) => (
|
|
518
525
|
!isDescriptionRequirement(requirement)
|
|
519
526
|
&& !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
@@ -541,6 +548,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
541
548
|
const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
|
|
542
549
|
v4Decisions.get(requirement.id) !== "applicable"
|
|
543
550
|
));
|
|
551
|
+
const invalidMandatoryStaleCount = invalidMandatoryDecisions.filter(({ id }) => staleRequirementIds.has(id)).length;
|
|
552
|
+
const invalidMandatoryOtherCount = invalidMandatoryDecisions.length - invalidMandatoryStaleCount;
|
|
544
553
|
const criteriaComplete = Boolean(
|
|
545
554
|
scope.frameworks.length
|
|
546
555
|
&& scope.requirements.length
|
|
@@ -564,11 +573,13 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
564
573
|
...missingRequiredDescriptionReferences
|
|
565
574
|
].join(", ")}.`
|
|
566
575
|
: invalidMandatoryDecisions.length
|
|
567
|
-
? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision
|
|
568
|
-
: `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services
|
|
576
|
+
? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision needs" : "decisions need"} attention: ${invalidMandatoryStaleCount} prior reviews are stale and ${invalidMandatoryOtherCount} are missing, undetermined, or not applicable.`
|
|
577
|
+
: `Resolve the program criteria and Controls. ${missingDecisionRequirements.length} applicability decisions are missing or undetermined and ${staleRequirements.length} prior reviews are stale. ${unresolvedTrustServicesRequirements.length} Trust Services criteria and ${unresolvedDescriptionRequirements.length} Description Criteria need current decisions, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
|
|
569
578
|
workspace || { type: "workspace" },
|
|
570
579
|
{
|
|
571
580
|
unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
|
|
581
|
+
staleRequirementIds: staleRequirements.map(({ id }) => id),
|
|
582
|
+
missingDecisionRequirementIds: missingDecisionRequirements.map(({ id }) => id),
|
|
572
583
|
missingRequirementIds: missingRequirements.map(({ id }) => id),
|
|
573
584
|
uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
|
|
574
585
|
invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
|
|
@@ -763,9 +774,23 @@ export function collectionReviewReadinessItem(assessment) {
|
|
|
763
774
|
? { id: firstIncomplete.resourceId, type: assessment.resourceType }
|
|
764
775
|
: needsFirstRecord ? { type: assessment.resourceType }
|
|
765
776
|
: approvalAction ? { type: approvalAction.resourceType, ...(approvalAction.resourceId ? { id: approvalAction.resourceId } : {}) }
|
|
766
|
-
:
|
|
777
|
+
: { type: assessment.resourceType },
|
|
767
778
|
{
|
|
768
779
|
resourceType: approvalAction?.resourceType || assessment.resourceType,
|
|
780
|
+
...(!proposalAction && !needsFirstRecord && !approvalAction && assessment.review?.id
|
|
781
|
+
? { subject: { type: "collection-review", id: assessment.review.id } }
|
|
782
|
+
: {}),
|
|
783
|
+
destination: proposalAction
|
|
784
|
+
? { kind: "record", resourceType: assessment.resourceType, resourceId: firstIncomplete.resourceId }
|
|
785
|
+
: needsFirstRecord
|
|
786
|
+
? { kind: "collection", resourceType: assessment.resourceType, create: true }
|
|
787
|
+
: approvalAction
|
|
788
|
+
? approvalAction.reviewCollection
|
|
789
|
+
? { kind: "collection-review", resourceType: approvalAction.resourceType }
|
|
790
|
+
: approvalAction.resourceId
|
|
791
|
+
? { kind: "record", resourceType: approvalAction.resourceType, resourceId: approvalAction.resourceId }
|
|
792
|
+
: { kind: "collection", resourceType: approvalAction.resourceType, ...(approvalAction.createResource ? { create: true, scheduleDocument: true } : {}) }
|
|
793
|
+
: { kind: "collection-review", resourceType: assessment.resourceType },
|
|
769
794
|
reviewPoints: assessment.configuration.reviewPoints,
|
|
770
795
|
...(approvalAction?.createResource ? { createResource: true } : {}),
|
|
771
796
|
...(pendingRetentionRow && !proposalAction ? { unresolvedAssignments: [{
|
|
@@ -831,6 +856,7 @@ function collectionApprovalIssueAction(assessment) {
|
|
|
831
856
|
if (issue.code === "unreviewed-similar-information-types") return {
|
|
832
857
|
title: "Review similar Information Types",
|
|
833
858
|
resourceType: "information-type",
|
|
859
|
+
reviewCollection: true,
|
|
834
860
|
commands: ["npx filegrc list information-type --workflow --json", "npx filegrc review-collection information-type --scaffold > REVIEW.json", "npx filegrc review-collection information-type REVIEW.json --preview --json", "npx filegrc review-collection information-type REVIEW.json --yes --json"]
|
|
835
861
|
};
|
|
836
862
|
return {
|
package/src/state.js
CHANGED
|
@@ -17,6 +17,7 @@ import { measureTiming } from "./timing.js";
|
|
|
17
17
|
import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
|
|
18
18
|
import { modelSupports } from "../model/index.js";
|
|
19
19
|
import { calculateRevision } from "./revisions.js";
|
|
20
|
+
import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
|
|
20
21
|
|
|
21
22
|
const renderedMarkdownCache = new Map();
|
|
22
23
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -87,6 +88,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) {
|
|
|
87
88
|
obligations: { loading: true, items: [], triggers: [], counts: {} },
|
|
88
89
|
collectionReviews: {},
|
|
89
90
|
applicabilityConstraints: {},
|
|
91
|
+
applicabilityReviewStatuses: {},
|
|
90
92
|
programReadiness: null,
|
|
91
93
|
auditPreparations: {},
|
|
92
94
|
workflow: { loading: true, findings: [], workItems: [], assessments: {} },
|
|
@@ -156,7 +158,8 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
156
158
|
applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
157
159
|
const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
|
|
158
160
|
return constraint ? [[record.id, constraint]] : [];
|
|
159
|
-
}))
|
|
161
|
+
})),
|
|
162
|
+
applicabilityReviewStatuses: assessApplicabilityReviewStatuses(loaded, activeProgram)
|
|
160
163
|
};
|
|
161
164
|
}
|
|
162
165
|
if (section === "obligations") {
|
|
@@ -330,6 +333,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
330
333
|
reconciliation,
|
|
331
334
|
collectionReviews,
|
|
332
335
|
applicabilityConstraints,
|
|
336
|
+
applicabilityReviewStatuses: assessApplicabilityReviewStatuses(loaded, activeProgram),
|
|
333
337
|
programReadiness,
|
|
334
338
|
auditPreparations,
|
|
335
339
|
workflow,
|
|
@@ -337,6 +341,21 @@ async function createAppStateUnlocked(input, options) {
|
|
|
337
341
|
};
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
function assessApplicabilityReviewStatuses(loaded, program) {
|
|
345
|
+
const decisions = new Map((program.requirementApplicability || []).map((review) => [review.requirementId, review]));
|
|
346
|
+
return Object.fromEntries(loaded.resources
|
|
347
|
+
.filter(({ type }) => ["requirement", "control", "commitment", "complementary-control"].includes(type))
|
|
348
|
+
.map((record) => {
|
|
349
|
+
const review = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
350
|
+
? decisions.get(record.id)
|
|
351
|
+
: record.applicabilityReview;
|
|
352
|
+
return [record.id, !review || !["applicable", "not-applicable", "externally-managed", "zero-population"].includes(review.decision)
|
|
353
|
+
? "missing" : applicabilityReviewIsCurrent(
|
|
354
|
+
review, record, program, loaded.resources, loaded.model
|
|
355
|
+
) ? "current" : "stale"];
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
340
359
|
function serializeCollectionReviewAssessment(assessment) {
|
|
341
360
|
return {
|
|
342
361
|
resourceType: assessment.resourceType,
|
package/src/web.js
CHANGED
|
@@ -53,6 +53,38 @@ export function pageActionIdentity(href = "", fallback = "") {
|
|
|
53
53
|
return route.startsWith("#/resource/") ? route : fallback || href;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function resolveWorkflowDestination(destination, options = {}) {
|
|
57
|
+
if (!destination?.resourceType) return "";
|
|
58
|
+
if (options.resourceTypes && !options.resourceTypes[destination.resourceType]) return "";
|
|
59
|
+
const type = encodeURIComponent(destination.resourceType);
|
|
60
|
+
if (destination.kind === "record") {
|
|
61
|
+
const exists = !options.resources || options.resources.some((entry) => {
|
|
62
|
+
const record = entry.record || entry;
|
|
63
|
+
return record.type === destination.resourceType && record.id === destination.resourceId;
|
|
64
|
+
});
|
|
65
|
+
return destination.resourceId && exists
|
|
66
|
+
? `#/resource/${type}/${encodeURIComponent(destination.resourceId)}${destination.edit ? "?edit=1" : ""}`
|
|
67
|
+
: resolveWorkflowDestination({ kind: "collection", resourceType: destination.resourceType }, options);
|
|
68
|
+
}
|
|
69
|
+
if (destination.kind === "collection-review") {
|
|
70
|
+
if (destination.resourceType === "retention-schedule-item" && options.retentionScheduleApproval) {
|
|
71
|
+
return "#/retention-schedule";
|
|
72
|
+
}
|
|
73
|
+
return `#/resources/${type}?review-collection=1`;
|
|
74
|
+
}
|
|
75
|
+
if (destination.kind === "collection") {
|
|
76
|
+
const create = destination.create && !options.readOnly;
|
|
77
|
+
if (destination.resourceType === "retention-schedule-item" && options.retentionScheduleApproval) {
|
|
78
|
+
return create ? "#/retention-schedule?new=1" : "#/retention-schedule";
|
|
79
|
+
}
|
|
80
|
+
if (destination.resourceType === "document" && create && destination.scheduleDocument) {
|
|
81
|
+
return "#/resources/document?new=1&documentScope=program&documentKind=schedule&programRole=required&title=Data%20Retention%20Schedule";
|
|
82
|
+
}
|
|
83
|
+
return `#/resources/${type}${create ? "?new=1" : ""}`;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
export function renderIndex(state = null) {
|
|
57
89
|
const snapshot = state
|
|
58
90
|
? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
|
|
@@ -100,6 +132,7 @@ const RESOURCE_GUIDE_OUTPUTS = ${JSON.stringify(RESOURCE_OUTPUTS)};
|
|
|
100
132
|
const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
101
133
|
const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
|
|
102
134
|
const pageActionIdentity = ${pageActionIdentity.toString()};
|
|
135
|
+
const resolveWorkflowDestination = ${resolveWorkflowDestination.toString()};
|
|
103
136
|
const selectDefaultAudit = ${selectDefaultAudit.toString()};
|
|
104
137
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
105
138
|
...RESOURCE_PAGE_SUMMARIES,
|
|
@@ -247,7 +280,8 @@ function normalizeAppState(next) {
|
|
|
247
280
|
function blockingStateSections(route) {
|
|
248
281
|
if (route.name === "home") return ["program", "obligations", "workflow"];
|
|
249
282
|
if (route.name === "repository") return ["repository"];
|
|
250
|
-
if (route.name === "list" &&
|
|
283
|
+
if (route.name === "list" && ["requirement", "control", "commitment", "complementary-control"].includes(route.type)) return ["program"];
|
|
284
|
+
if (route.name === "detail" && ["program", "requirement", "control", "commitment", "complementary-control"].includes(route.type)) return ["program"];
|
|
251
285
|
if (route.name === "list" && route.type === "obligation") return ["obligations"];
|
|
252
286
|
if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
|
|
253
287
|
if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
|
|
@@ -416,12 +450,6 @@ function activeProgram() {
|
|
|
416
450
|
|| state.workspace;
|
|
417
451
|
}
|
|
418
452
|
|
|
419
|
-
function reviewedRequirementIds() {
|
|
420
|
-
return new Set((activeProgram().requirementApplicability || [])
|
|
421
|
-
.filter(({ decision }) => ["applicable", "not-applicable"].includes(decision))
|
|
422
|
-
.map(({ requirementId }) => requirementId));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
453
|
function renderSidebarUtility(utility, route, direct = false) {
|
|
426
454
|
const directClass = direct ? "nav-direct " : "";
|
|
427
455
|
if (utility === "obligation-board") return "";
|
|
@@ -791,11 +819,7 @@ function renderRetentionScheduleIssues() {
|
|
|
791
819
|
));
|
|
792
820
|
if (!actions.length) return "";
|
|
793
821
|
return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
|
|
794
|
-
const href = item.href || (item
|
|
795
|
-
? retentionScheduleItemHref(item)
|
|
796
|
-
: item.resourceId && item.resourceType
|
|
797
|
-
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
798
|
-
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item"));
|
|
822
|
+
const href = item.href || workflowItemHref(item) || "#/retention-schedule";
|
|
799
823
|
return '<a href="' + href + '"><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span><b>Review</b></a>';
|
|
800
824
|
}).join("") + '</div></section>';
|
|
801
825
|
}
|
|
@@ -1291,9 +1315,7 @@ function renderFinishStepThree() {
|
|
|
1291
1315
|
));
|
|
1292
1316
|
const next = implementationItems.find(({ status }) => status !== "complete");
|
|
1293
1317
|
if (!oversightItem) {
|
|
1294
|
-
const href = next?.id?.startsWith("
|
|
1295
|
-
? "#/resources/" + encodeURIComponent(next.resourceType) + "?review-collection=1"
|
|
1296
|
-
: next?.id?.startsWith("source-family-")
|
|
1318
|
+
const href = next?.id?.startsWith("source-family-")
|
|
1297
1319
|
? "#/evidence-sources"
|
|
1298
1320
|
: next ? workflowItemHref({ ...next, state: next.status }) || "#/resources/control" : "#/resources/control";
|
|
1299
1321
|
return '<section class="finish-step panel blocked"><div><p class="kicker">Finish Step 3</p><h2>Complete implementation first</h2><p>' + esc(next?.message || "Finish the Step 3 work areas before the final review becomes available.") + '</p></div><a class="button primary" href="' + esc(href) + '">Continue implementation</a></section>';
|
|
@@ -1495,6 +1517,16 @@ function openCollectionReviewDialog(type) {
|
|
|
1495
1517
|
}
|
|
1496
1518
|
|
|
1497
1519
|
function workflowItemHref(item) {
|
|
1520
|
+
const destinationOptions = {
|
|
1521
|
+
retentionScheduleApproval: modelSupports("retention-schedule-approval"),
|
|
1522
|
+
readOnly: state.readOnly,
|
|
1523
|
+
resourceTypes: state.model.resources,
|
|
1524
|
+
resources: state.resources
|
|
1525
|
+
};
|
|
1526
|
+
const destinationHref = (destination) => resolveWorkflowDestination(destination, destinationOptions);
|
|
1527
|
+
if (item.destination) {
|
|
1528
|
+
return destinationHref(item.destination);
|
|
1529
|
+
}
|
|
1498
1530
|
const source = item.source?.id && item.source?.type && item.source.type !== "unknown"
|
|
1499
1531
|
? item.source
|
|
1500
1532
|
: null;
|
|
@@ -1523,19 +1555,18 @@ function workflowItemHref(item) {
|
|
|
1523
1555
|
&& item.subject?.id
|
|
1524
1556
|
&& state.resources.some(({ record }) => record.type === applicabilityType && record.id === item.subject.id)
|
|
1525
1557
|
) {
|
|
1526
|
-
return "
|
|
1558
|
+
return destinationHref({ kind: "record", resourceType: applicabilityType, resourceId: item.subject.id });
|
|
1527
1559
|
}
|
|
1528
1560
|
if (commands.some((command) => command.includes(" scaffold retention-schedule-item"))) {
|
|
1529
1561
|
return retentionScheduleItemHref(item);
|
|
1530
1562
|
}
|
|
1531
1563
|
if (applicabilityType && state.model.resources[applicabilityType]) {
|
|
1532
|
-
return "
|
|
1564
|
+
return destinationHref({ kind: "collection", resourceType: applicabilityType }) + "?review=1";
|
|
1533
1565
|
}
|
|
1534
1566
|
const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
|
|
1535
1567
|
const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
|
|
1536
1568
|
if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
|
|
1537
|
-
|
|
1538
|
-
return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
|
|
1569
|
+
return destinationHref({ kind: "collection-review", resourceType: collectionReviewType });
|
|
1539
1570
|
}
|
|
1540
1571
|
if (
|
|
1541
1572
|
commands.some((command) => command.includes(" get ") && command.includes(" --mutation"))
|
|
@@ -1543,25 +1574,25 @@ function workflowItemHref(item) {
|
|
|
1543
1574
|
&& item.subject?.type
|
|
1544
1575
|
&& state.model.resources[item.subject.type]
|
|
1545
1576
|
) {
|
|
1546
|
-
return "
|
|
1577
|
+
return destinationHref({ kind: "record", resourceType: item.subject.type, resourceId: item.subject.id, edit: true });
|
|
1547
1578
|
}
|
|
1548
1579
|
if (commands.some((command) => command.includes(" external-reviewer-setup"))) {
|
|
1549
1580
|
const reviewer = resourcesOfType("appointment")
|
|
1550
1581
|
.find(({ record }) => record.appointmentKind === "independent-policy-reviewer");
|
|
1551
|
-
if (reviewer) return "
|
|
1582
|
+
if (reviewer) return destinationHref({ kind: "record", resourceType: "appointment", resourceId: reviewer.record.id });
|
|
1552
1583
|
}
|
|
1553
1584
|
if (commands.some((command) => command.includes(" evidence-map"))) return "#/stage/controls";
|
|
1554
1585
|
const reference = item.subject?.id && item.subject?.type
|
|
1555
1586
|
? item.subject
|
|
1556
1587
|
: source;
|
|
1557
1588
|
if (reference && reference.type !== "unknown") {
|
|
1558
|
-
return "
|
|
1589
|
+
return destinationHref({ kind: "record", resourceType: reference.type, resourceId: reference.id });
|
|
1559
1590
|
}
|
|
1560
1591
|
const missingType = item.subject?.type && state.model.resources[item.subject.type]
|
|
1561
1592
|
? item.subject.type
|
|
1562
1593
|
: null;
|
|
1563
1594
|
if (missingType) {
|
|
1564
|
-
return "
|
|
1595
|
+
return destinationHref({ kind: "collection", resourceType: missingType, create: !state.readOnly });
|
|
1565
1596
|
}
|
|
1566
1597
|
if (item.auditId) return "#/audit-packet?auditId=" + encodeURIComponent(item.auditId);
|
|
1567
1598
|
const stage = {
|
|
@@ -1719,13 +1750,9 @@ function retentionSchedulePageItems() {
|
|
|
1719
1750
|
...item,
|
|
1720
1751
|
key: item.id,
|
|
1721
1752
|
state: "ready",
|
|
1722
|
-
href: item.id
|
|
1723
|
-
? retentionCollectionActionHref(item)
|
|
1724
|
-
: item.id?.startsWith("retention-use-")
|
|
1753
|
+
href: item.id?.startsWith("retention-use-")
|
|
1725
1754
|
? retentionScheduleItemHref(item)
|
|
1726
|
-
: item
|
|
1727
|
-
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1728
|
-
: workflowItemHref(item) || "#/retention-schedule",
|
|
1755
|
+
: workflowItemHref(item) || "#/retention-schedule",
|
|
1729
1756
|
subject: item.resourceId && item.resourceType
|
|
1730
1757
|
? { type: item.resourceType, id: item.resourceId }
|
|
1731
1758
|
: item.subject
|
|
@@ -1733,19 +1760,6 @@ function retentionSchedulePageItems() {
|
|
|
1733
1760
|
]);
|
|
1734
1761
|
}
|
|
1735
1762
|
|
|
1736
|
-
function retentionCollectionActionHref(item) {
|
|
1737
|
-
if (item.resourceType === "document") return item.resourceId
|
|
1738
|
-
? '#/resource/document/' + encodeURIComponent(item.resourceId) + '?stage=policies'
|
|
1739
|
-
: item.createResource
|
|
1740
|
-
? '#/resources/document?new=1&documentScope=program&documentKind=schedule&programRole=required&title=Data%20Retention%20Schedule'
|
|
1741
|
-
: '#/resources/document';
|
|
1742
|
-
if (item.resourceType === "information-type") return '#/resources/information-type?review-collection=1';
|
|
1743
|
-
if (item.resourceType === "retention-schedule-item" && item.resourceId && item.resourceId !== item.id) {
|
|
1744
|
-
return '#/resource/retention-schedule-item/' + encodeURIComponent(item.resourceId) + '?stage=policies';
|
|
1745
|
-
}
|
|
1746
|
-
return "#/retention-schedule";
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
1763
|
function distinctPageActionItems(items) {
|
|
1750
1764
|
const distinct = new Map();
|
|
1751
1765
|
for (const item of items) {
|
|
@@ -2778,24 +2792,28 @@ function openNextAuditCycleDialog(prior) {
|
|
|
2778
2792
|
}
|
|
2779
2793
|
|
|
2780
2794
|
function controlApplicabilityPending(record) {
|
|
2795
|
+
if (state.applicabilityReviewStatuses?.[record.id]) {
|
|
2796
|
+
return state.applicabilityReviewStatuses[record.id] !== "current";
|
|
2797
|
+
}
|
|
2781
2798
|
if (!record.applicabilityReview) return true;
|
|
2782
2799
|
const item = state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items
|
|
2783
2800
|
.find(({ resourceType, resourceId }) => resourceType === "control" && resourceId === record.id);
|
|
2784
2801
|
return item?.checks?.applicability === false;
|
|
2785
2802
|
}
|
|
2786
2803
|
|
|
2804
|
+
function applicabilityPending(record) {
|
|
2805
|
+
if (state.applicabilityReviewStatuses?.[record.id]) {
|
|
2806
|
+
return state.applicabilityReviewStatuses[record.id] !== "current";
|
|
2807
|
+
}
|
|
2808
|
+
return record.type === "control" ? controlApplicabilityPending(record)
|
|
2809
|
+
: !record.applicabilityReview || record.type === "requirement" && record.applicability === "undetermined";
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2787
2812
|
function openApplicabilityReviewDialog(type, entries) {
|
|
2788
2813
|
const definition = state.model.resources[type];
|
|
2789
2814
|
const reviewPoints = definition?.guidance?.reviewPoints || [];
|
|
2790
2815
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
2791
|
-
const
|
|
2792
|
-
const pending = entries.filter(({ record }) => (
|
|
2793
|
-
type === "requirement" && modelSupports("program-scope")
|
|
2794
|
-
? !reviewedRequirements.has(record.id)
|
|
2795
|
-
: type === "control"
|
|
2796
|
-
? controlApplicabilityPending(record)
|
|
2797
|
-
: !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
|
|
2798
|
-
));
|
|
2816
|
+
const pending = entries.filter(({ record }) => applicabilityPending(record));
|
|
2799
2817
|
const dialog = document.createElement("dialog");
|
|
2800
2818
|
dialog.className = "commit-dialog applicability-dialog";
|
|
2801
2819
|
dialog.setAttribute("aria-labelledby", "applicability-dialog-title");
|
|
@@ -2805,10 +2823,19 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
2805
2823
|
const decisions = constraint?.allowedDecisions || (type === "requirement"
|
|
2806
2824
|
? ["applicable", "not-applicable"]
|
|
2807
2825
|
: ["applicable", "not-applicable", "externally-managed", "zero-population"]);
|
|
2808
|
-
const
|
|
2826
|
+
const prior = type === "requirement" && modelSupports("program-scope")
|
|
2827
|
+
? (activeProgram().requirementApplicability || []).find(({ requirementId }) => requirementId === entry.record.id)
|
|
2828
|
+
: entry.record.applicabilityReview;
|
|
2829
|
+
const selected = constraint?.requiredDecision || (state.applicabilityReviewStatuses?.[entry.record.id] === "stale" ? prior?.decision : "") || "";
|
|
2830
|
+
const rationale = constraint?.requiredDecision && constraint.requiredDecision !== prior?.decision
|
|
2831
|
+
? constraint.defaultRationale || ""
|
|
2832
|
+
: state.applicabilityReviewStatuses?.[entry.record.id] === "stale"
|
|
2833
|
+
? prior?.rationale || constraint?.defaultRationale || ""
|
|
2834
|
+
: constraint?.defaultRationale || "";
|
|
2809
2835
|
const options = decisions.map((decision) => '<option value="' + esc(decision) + '" ' + (decision === selected ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>').join("");
|
|
2810
2836
|
const note = constraint ? '<small class="applicability-constraint">' + esc(constraint.message) + '</small>' : "";
|
|
2811
|
-
|
|
2837
|
+
const stale = state.applicabilityReviewStatuses?.[entry.record.id] === "stale";
|
|
2838
|
+
return '<div class="applicability-row" data-review-id="' + esc(entry.record.id) + '"><div><strong>' + esc(entry.record.title) + '</strong><small>' + esc(entry.record.code || entry.record.id) + (stale ? '<small>Prior review is stale. Confirm this decision against the current scope.</small>' : '') + note + '</div><select name="decision"><option value="">Review later</option>' + options + '</select><input name="rationale" value="' + esc(rationale) + '" placeholder="Decision rationale"></div>';
|
|
2812
2839
|
}).join("");
|
|
2813
2840
|
const reviewChecks = reviewPoints.length
|
|
2814
2841
|
? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
|
|
@@ -2816,7 +2843,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
2816
2843
|
const baselineNote = constrainedCount
|
|
2817
2844
|
? '<p class="applicability-baseline-note"><strong>' + constrainedCount + ' required baseline ' + pluralize("criterion", constrainedCount) + '.</strong> FileGRC has prefilled the only decision compatible with the selected SOC 2 Security goal and a review rationale. Leave an item at Review later only when management is not ready to record it.</p>'
|
|
2818
2845
|
: '<p>Record only decisions reviewed against the current service scope. Leave an item at Review later when management has not decided it.</p>';
|
|
2819
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">Review ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' + baselineNote + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
|
|
2846
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">' + (pending.some(({ record }) => state.applicabilityReviewStatuses?.[record.id] === "stale") ? 'Reconfirm' : 'Review') + ' ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' + baselineNote + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
|
|
2820
2847
|
document.body.append(dialog);
|
|
2821
2848
|
dialog.showModal();
|
|
2822
2849
|
const form = dialog.querySelector("form");
|
|
@@ -3009,11 +3036,7 @@ function renderAuditPreparation(preparation) {
|
|
|
3009
3036
|
const status = preparation.status === "management-ready" ? "Management work complete" : preparation.counts.action + " actions remaining";
|
|
3010
3037
|
const stages = preparation.stages.map((stage) => {
|
|
3011
3038
|
const items = stage.items.map((item) => {
|
|
3012
|
-
const href = item
|
|
3013
|
-
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
3014
|
-
: item.resourceType
|
|
3015
|
-
? '#/resources/' + encodeURIComponent(item.resourceType)
|
|
3016
|
-
: "";
|
|
3039
|
+
const href = workflowItemHref(item);
|
|
3017
3040
|
const content = '<span class="preparation-status ' + esc(item.status) + '" aria-hidden="true">' + preparationStatusMark(item.status) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span>';
|
|
3018
3041
|
return href ? '<a href="' + href + '">' + content + '</a>' : '<div>' + content + '</div>';
|
|
3019
3042
|
}).join("");
|
|
@@ -3139,18 +3162,12 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
3139
3162
|
return { name, label: field.label || humanize(name), values };
|
|
3140
3163
|
}).filter(({ values }) => values.length > 1);
|
|
3141
3164
|
const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
3142
|
-
const
|
|
3143
|
-
const
|
|
3144
|
-
type === "requirement" && modelSupports("program-scope")
|
|
3145
|
-
? !reviewedRequirements.has(record.id)
|
|
3146
|
-
: type === "control"
|
|
3147
|
-
? controlApplicabilityPending(record)
|
|
3148
|
-
: !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
|
|
3149
|
-
));
|
|
3165
|
+
const hasPendingApplicability = entries.some(({ record }) => applicabilityPending(record));
|
|
3166
|
+
const hasStaleApplicability = entries.some(({ record }) => state.applicabilityReviewStatuses?.[record.id] === "stale");
|
|
3150
3167
|
const applicabilityButton = !state.readOnly
|
|
3151
3168
|
&& ["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
3152
3169
|
&& hasPendingApplicability
|
|
3153
|
-
? '<button class="button" id="review-applicability">Review applicability</button>'
|
|
3170
|
+
? '<button class="button" id="review-applicability">' + (hasStaleApplicability ? 'Reconfirm applicability' : 'Review applicability') + '</button>'
|
|
3154
3171
|
: "";
|
|
3155
3172
|
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About ' + esc(definition.pluralTitle) + '" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
3156
3173
|
const listTools = '<div class="list-tools list-header-tools"><label><span class="sr-only">Filter list</span><input id="list-search" type="search" placeholder="Filter ' + esc(definition.pluralTitle.toLowerCase()) + '"></label>' +
|
|
@@ -3299,6 +3316,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3299
3316
|
&& !fields[name]?.content
|
|
3300
3317
|
&& !narrativeNames.has(name)
|
|
3301
3318
|
));
|
|
3319
|
+
if (type === "program" && !Object.hasOwn(entry.record, "requirementApplicability")) {
|
|
3320
|
+
visible.push(["requirementApplicability", []]);
|
|
3321
|
+
}
|
|
3302
3322
|
const content = Object.entries(entry.content);
|
|
3303
3323
|
const narrativeContent = narrative.length
|
|
3304
3324
|
? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
|
|
@@ -3353,7 +3373,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3353
3373
|
: "";
|
|
3354
3374
|
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
3355
3375
|
const recordFieldsPanel = visible.length
|
|
3356
|
-
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
|
|
3376
|
+
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type, false, entry.record) + '</dd></div>').join("") + '</dl></section>'
|
|
3357
3377
|
: "";
|
|
3358
3378
|
const supportPanels = renderDetailSupport({
|
|
3359
3379
|
hasRecordBody,
|
|
@@ -3362,9 +3382,17 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3362
3382
|
attachmentPanel
|
|
3363
3383
|
});
|
|
3364
3384
|
const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
|
|
3385
|
+
const applicabilityStatus = state.applicabilityReviewStatuses?.[entry.record.id];
|
|
3386
|
+
const applicabilityAction = !state.readOnly && ["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
3387
|
+
&& ["stale", "missing"].includes(applicabilityStatus)
|
|
3388
|
+
? '<a class="button" href="#/resources/' + encodeURIComponent(type) + '?review=1">' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability</a>'
|
|
3389
|
+
: "";
|
|
3365
3390
|
const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
|
|
3366
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + (type === "control" ? " detail-control" : "") + '">' + detailMain + supportPanels + '</div></div>';
|
|
3391
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + (type === "control" ? " detail-control" : "") + '">' + detailMain + supportPanels + '</div></div>';
|
|
3367
3392
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
3393
|
+
main.querySelector("[data-select-program-for-review]")?.addEventListener("click", () => {
|
|
3394
|
+
selectProgram(entry.record.id, "#/resources/requirement?review=1");
|
|
3395
|
+
});
|
|
3368
3396
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
3369
3397
|
main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
|
|
3370
3398
|
button.closest("details")?.removeAttribute("open");
|
|
@@ -5770,17 +5798,21 @@ function openContentEditor(entry, name) {
|
|
|
5770
5798
|
});
|
|
5771
5799
|
}
|
|
5772
5800
|
|
|
5801
|
+
async function selectProgram(programId, destination = null) {
|
|
5802
|
+
const selectionGeneration = ++programSelectionGeneration;
|
|
5803
|
+
const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
|
|
5804
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5805
|
+
if (selectionGeneration !== programSelectionGeneration) return;
|
|
5806
|
+
state = normalizeAppState(await response.json());
|
|
5807
|
+
stateSectionRequests.clear();
|
|
5808
|
+
if (destination) history.replaceState(null, "", destination);
|
|
5809
|
+
render();
|
|
5810
|
+
loadStateForRoute();
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5773
5813
|
function bindCommon() {
|
|
5774
|
-
root.querySelector("[data-program-select]")?.addEventListener("change",
|
|
5775
|
-
|
|
5776
|
-
const selectionGeneration = ++programSelectionGeneration;
|
|
5777
|
-
const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
|
|
5778
|
-
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5779
|
-
if (selectionGeneration !== programSelectionGeneration) return;
|
|
5780
|
-
state = normalizeAppState(await response.json());
|
|
5781
|
-
stateSectionRequests.clear();
|
|
5782
|
-
render();
|
|
5783
|
-
loadStateForRoute();
|
|
5814
|
+
root.querySelector("[data-program-select]")?.addEventListener("change", (event) => {
|
|
5815
|
+
selectProgram(event.currentTarget.value);
|
|
5784
5816
|
});
|
|
5785
5817
|
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
5786
5818
|
const group = button.closest(".nav-group");
|
|
@@ -6070,14 +6102,22 @@ function conditionMatchesValues(condition, valueFor) {
|
|
|
6070
6102
|
function conditionValueMatches(actual, expected) {
|
|
6071
6103
|
return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
|
|
6072
6104
|
}
|
|
6073
|
-
function formatValue(value, field, type, compact = false) {
|
|
6105
|
+
function formatValue(value, field, type, compact = false, record = null) {
|
|
6074
6106
|
if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
|
|
6075
6107
|
const definition = fieldDefinition(type, field);
|
|
6076
6108
|
if (type === "program" && field === "requirementApplicability" && Array.isArray(value)) {
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6109
|
+
if (record && record.id !== state.selectedProgramId) {
|
|
6110
|
+
return '<button class="button" type="button" data-select-program-for-review="' + esc(record.id) + '">Select this Program to review applicability</button>';
|
|
6111
|
+
}
|
|
6112
|
+
const frameworkIds = new Set(record?.frameworkIds || activeProgram().frameworkIds || []);
|
|
6113
|
+
const requirementIds = state.resources.filter(({ record: candidate }) => (
|
|
6114
|
+
candidate.type === "requirement" && frameworkIds.has(candidate.frameworkId)
|
|
6115
|
+
)).map(({ record: candidate }) => candidate.id);
|
|
6116
|
+
const reviewed = requirementIds.filter((id) => state.applicabilityReviewStatuses?.[id] === "current").length;
|
|
6117
|
+
const stale = requirementIds.filter((id) => state.applicabilityReviewStatuses?.[id] === "stale").length;
|
|
6118
|
+
const pending = requirementIds.length - reviewed - stale;
|
|
6119
|
+
const label = reviewed + " reviewed" + (stale ? " · " + stale + " stale" : "") + (pending ? " · " + pending + " to review" : "");
|
|
6120
|
+
return '<a class="tag relation" href="#/resources/requirement' + (stale || pending ? "?review=1" : "") + '">' + esc(label) + '</a>';
|
|
6081
6121
|
}
|
|
6082
6122
|
if (definition?.type === "date") return esc(formatCalendarDate(value));
|
|
6083
6123
|
if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
|
package/src/workflow.js
CHANGED
|
@@ -1115,9 +1115,9 @@ export function packetDeliveryIssue(delivery) {
|
|
|
1115
1115
|
|
|
1116
1116
|
function normalizeFinding(code, item, context) {
|
|
1117
1117
|
const state = findingState(item.status);
|
|
1118
|
-
const subject = item.resourceType
|
|
1118
|
+
const subject = item.subject || (item.resourceType
|
|
1119
1119
|
? { type: item.resourceType, ...(item.resourceId ? { id: item.resourceId } : {}) }
|
|
1120
|
-
: null;
|
|
1120
|
+
: null);
|
|
1121
1121
|
const dependencies = (item.unresolvedAssignments || []).map((assignment) => ({
|
|
1122
1122
|
type: assignment.resourceType,
|
|
1123
1123
|
id: assignment.resourceId,
|
|
@@ -1138,6 +1138,7 @@ function normalizeFinding(code, item, context) {
|
|
|
1138
1138
|
.filter(([, passed]) => passed === false)
|
|
1139
1139
|
.map(([name]) => name) } : {}),
|
|
1140
1140
|
...(item.resourceId ? { resourceId: item.resourceId } : {}),
|
|
1141
|
+
...(item.destination ? { destination: item.destination } : {}),
|
|
1141
1142
|
...(item.createResourceType ? { createResourceType: item.createResourceType } : {}),
|
|
1142
1143
|
...(item.sourceResourceIds ? { sourceResourceIds: item.sourceResourceIds } : {}),
|
|
1143
1144
|
...(item.informationTypeId ? { informationTypeId: item.informationTypeId } : {}),
|