diff --git a/src/components/forms/LinksEditor.vue b/src/components/forms/LinksEditor.vue new file mode 100644 index 00000000..6f247748 --- /dev/null +++ b/src/components/forms/LinksEditor.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/components/forms/PropsEditor.vue b/src/components/forms/PropsEditor.vue new file mode 100644 index 00000000..220170f2 --- /dev/null +++ b/src/components/forms/PropsEditor.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/components/lineage/__tests__/nodeMeta.spec.ts b/src/components/lineage/__tests__/nodeMeta.spec.ts index 7111a0b6..8785212f 100644 --- a/src/components/lineage/__tests__/nodeMeta.spec.ts +++ b/src/components/lineage/__tests__/nodeMeta.spec.ts @@ -242,6 +242,27 @@ describe('nodeDetailRoute', () => { ).toEqual({ name: 'risks:detail', params: { riskId: 'r-2' } }); }); + it('routes risk nodes to the SSP-scoped detail route when an sspId is given', () => { + expect( + nodeDetailRoute(base({ nodeType: 'risk', riskId: 'r-1' }), 'ssp-1'), + ).toEqual({ + name: 'system-security-plan-risk-detail', + params: { id: 'ssp-1', riskId: 'r-1' }, + }); + }); + + it("prefers the risk node's own sspId over the active scope sspId", () => { + expect( + nodeDetailRoute( + base({ nodeType: 'risk', riskId: 'r-1', sspId: 'ssp-own' }), + 'ssp-scope', + ), + ).toEqual({ + name: 'system-security-plan-risk-detail', + params: { id: 'ssp-own', riskId: 'r-1' }, + }); + }); + it('routes evidence nodes to evidence:view with the evidenceId', () => { expect( nodeDetailRoute(base({ nodeType: 'evidence', evidenceId: 'e-1' })), diff --git a/src/components/lineage/nodeMeta.ts b/src/components/lineage/nodeMeta.ts index 3321f4e9..375cdbf4 100644 --- a/src/components/lineage/nodeMeta.ts +++ b/src/components/lineage/nodeMeta.ts @@ -429,10 +429,22 @@ function idFromKey(key: string): string { */ export function nodeDetailRoute( node: LineageNode, + sspId?: string | null, ): { name: string; params: Record } | null { if (node.nodeType === 'risk') { const riskId = node.riskId || idFromKey(node.key); - return riskId ? { name: 'risks:detail', params: { riskId } } : null; + if (!riskId) return null; + // Prefer the risk's own SSP: a lineage view scoped to one SSP can still + // render cross-SSP risks, so routing by the active scope would open the + // wrong SSP's context. Fall back to the active scope, then to the unscoped + // detail route when neither is known. + const targetSspId = node.sspId || sspId; + return targetSspId + ? { + name: 'system-security-plan-risk-detail', + params: { id: targetSspId, riskId }, + } + : { name: 'risks:detail', params: { riskId } }; } if (node.nodeType === 'evidence') { // The evidence key holds the *stream* uuid, not the record id — only diff --git a/src/components/risk/RiskCreateForm.vue b/src/components/risk/RiskCreateForm.vue index d21c6ab7..2652026c 100644 --- a/src/components/risk/RiskCreateForm.vue +++ b/src/components/risk/RiskCreateForm.vue @@ -102,6 +102,49 @@ +
+
+ + +
+
+ + +
+
+
@@ -263,7 +306,9 @@ const props = defineProps<{ const emit = defineEmits<{ cancel: []; - created: [risk: Risk]; + // The SSP-scoped register create responds with a register risk carrying an + // `id`; the plain OSCAL create does not, hence the optional field. + created: [risk: Risk & { id?: string }]; }>(); const toast = useToast(); @@ -276,6 +321,14 @@ const riskStatusOptions = [ { value: 'closed', label: 'Closed' }, ]; +const riskLevelOptions = [ + { value: 'negligible', label: 'Negligible' }, + { value: 'low', label: 'Low' }, + { value: 'moderate', label: 'Moderate' }, + { value: 'high', label: 'High' }, + { value: 'critical', label: 'Critical' }, +]; + const riskContext = computed(() => { if (props.sspId) { return { @@ -332,9 +385,20 @@ const formData = reactive({ description: '', statement: '', status: '', + likelihood: '', + impact: '', deadline: '', remarks: '', }); + +function normalizeRiskLevel(value?: string | null): string { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return ''; + const canonical = normalized === 'medium' ? 'moderate' : normalized; + return riskLevelOptions.some((level) => level.value === canonical) + ? canonical + : ''; +} const showTemplateSelector = ref(false); const selectedTemplate = ref(null); const selectedTemplateName = ref(''); @@ -488,6 +552,8 @@ function applyTemplate(template: RiskTemplate) { formData.title = template.title; formData.description = statement; formData.statement = statement; + formData.likelihood = normalizeRiskLevel(template.likelihoodHint); + formData.impact = normalizeRiskLevel(template.impactHint); selectedTemplate.value = template; const templateRemarks = buildTemplateRemarks(template); @@ -579,8 +645,8 @@ async function submit() { description: formData.description, statement: formData.statement, status: formData.status, - likelihood: toOptionalString(selectedTemplate.value?.likelihoodHint), - impact: toOptionalString(selectedTemplate.value?.impactHint), + likelihood: toOptionalString(formData.likelihood), + impact: toOptionalString(formData.impact), threatIds: selectedTemplate.value ? getThreatIdsFromTemplate(selectedTemplate.value) : undefined, diff --git a/src/composables/useLineage/fixtures.ts b/src/composables/useLineage/fixtures.ts index 3ee05378..b55496a3 100644 --- a/src/composables/useLineage/fixtures.ts +++ b/src/composables/useLineage/fixtures.ts @@ -426,6 +426,8 @@ const riskCredLeak = node({ likelihood: 'likely', impact: 'high', linkedEvidenceCount: 2, + sspId: 'demo-ssp-acme', + sspTitle: 'Acme Production', reviewDeadline: '2026-08-15T00:00:00Z', firstSeenAt: '2026-05-02T00:00:00Z', lastSeenAt: '2026-07-01T00:00:00Z', @@ -455,6 +457,8 @@ const riskStaleAccess = node({ likelihood: 'possible', impact: 'critical', linkedEvidenceCount: 0, + sspId: 'demo-ssp-globex', + sspTitle: 'Globex Staging', risk: { openScoreSum: 42, mutedScoreSum: 0, diff --git a/src/composables/useLineage/types.ts b/src/composables/useLineage/types.ts index 051c2ba1..fc0f9343 100644 --- a/src/composables/useLineage/types.ts +++ b/src/composables/useLineage/types.ts @@ -138,6 +138,9 @@ export interface LineageNode { lastReviewedAt?: string; firstSeenAt?: string; lastSeenAt?: string; + /** The single SSP this risk belongs to (risks always have exactly one). */ + sspId?: string; + sspTitle?: string; // --- evidence nodes (nodeType === 'evidence') --- evidenceId?: string; diff --git a/src/views/evidence/CreateView.vue b/src/views/evidence/CreateView.vue index 3e01aea9..bac343c6 100644 --- a/src/views/evidence/CreateView.vue +++ b/src/views/evidence/CreateView.vue @@ -19,7 +19,6 @@ import type { EvidenceStatus, } from '@/stores/evidence.ts'; import router from '@/router'; -import { v4 as uuidv4 } from 'uuid'; import EvidenceForm from './partial/EvidenceForm.vue'; import type { BackMatterResource } from '@/oscal'; @@ -35,9 +34,7 @@ const { data: createdEvidence, execute: createEvidence } = useDataApi( ); const backmatterResources = ref([]); -const evidence = ref>({ - uuid: uuidv4(), -}); +const evidence = ref>({}); async function submit( updatedEvidence: Partial, diff --git a/src/views/evidence/UpdateView.vue b/src/views/evidence/UpdateView.vue index 33f8fcd8..e2109283 100644 --- a/src/views/evidence/UpdateView.vue +++ b/src/views/evidence/UpdateView.vue @@ -1,7 +1,8 @@