Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/components/forms/LinksEditor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<template>
<div class="space-y-3">
<div
v-for="(link, index) in model"
:key="index"
class="rounded-md border border-ccf-300 p-3 dark:border-slate-700"
>
<div class="flex items-start justify-between gap-2">
<div class="grid flex-1 grid-cols-1 gap-3 md:grid-cols-2">
<div class="md:col-span-2">
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Href
</label>
<InputText
v-model="link.href"
placeholder="URL or #resource reference"
size="small"
class="mt-1 w-full"
/>
</div>
<div>
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Rel
</label>
<InputText
v-model="link.rel"
placeholder="Relationship (e.g. reference)"
size="small"
class="mt-1 w-full"
/>
</div>
<div>
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Text
</label>
<InputText
v-model="link.text"
placeholder="Display text"
size="small"
class="mt-1 w-full"
/>
</div>
</div>
<TertiaryButton
type="button"
class="px-1!"
:aria-label="`Remove link ${index + 1}`"
@click="remove(index)"
>
<BIconX />
</TertiaryButton>
Comment thread
gusfcarvalho marked this conversation as resolved.
</div>
</div>

<p v-if="!model.length" class="text-sm text-gray-600 dark:text-slate-400">
No links added.
</p>

<SecondaryButton size="small" type="button" @click="add">
Add Link
</SecondaryButton>
</div>
</template>

<script setup lang="ts">
import type { Link } from '@/oscal';
import InputText from '@/volt/InputText.vue';
import SecondaryButton from '@/volt/SecondaryButton.vue';
import TertiaryButton from '@/volt/TertiaryButton.vue';
import { BIconX } from 'bootstrap-icons-vue';

const model = defineModel<Link[]>({ required: true });

function add() {
model.value = [...model.value, { href: '' }];
}

function remove(index: number) {
model.value = model.value.filter((_, i) => i !== index);
}
</script>
100 changes: 100 additions & 0 deletions src/components/forms/PropsEditor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<template>
<div class="space-y-3">
<div
v-for="(prop, index) in model"
:key="index"
class="rounded-md border border-ccf-300 p-3 dark:border-slate-700"
>
<div class="flex items-start justify-between gap-2">
<div class="grid flex-1 grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Name
</label>
<InputText
v-model="prop.name"
placeholder="Property name"
size="small"
class="mt-1 w-full"
/>
</div>
<div>
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Value
</label>
<InputText
v-model="prop.value"
placeholder="Property value"
size="small"
class="mt-1 w-full"
/>
</div>
<div>
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Class
</label>
<InputText
v-model="prop.class"
placeholder="Optional class"
size="small"
class="mt-1 w-full"
/>
</div>
<div>
<label
class="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400"
>
Remarks
</label>
<InputText
v-model="prop.remarks"
placeholder="Optional remarks"
size="small"
class="mt-1 w-full"
/>
</div>
</div>
<TertiaryButton
type="button"
class="px-1!"
:aria-label="`Remove property ${index + 1}`"
@click="remove(index)"
>
<BIconX />
</TertiaryButton>
</div>
</div>

<p v-if="!model.length" class="text-sm text-gray-600 dark:text-slate-400">
No properties added.
</p>

<SecondaryButton size="small" type="button" @click="add">
Add Property
</SecondaryButton>
</div>
</template>

<script setup lang="ts">
import type { Property } from '@/oscal';
import InputText from '@/volt/InputText.vue';
import SecondaryButton from '@/volt/SecondaryButton.vue';
import TertiaryButton from '@/volt/TertiaryButton.vue';
import { BIconX } from 'bootstrap-icons-vue';

const model = defineModel<Property[]>({ required: true });

function add() {
model.value = [...model.value, { name: '', value: '' }];
}

function remove(index: number) {
model.value = model.value.filter((_, i) => i !== index);
}
</script>
21 changes: 21 additions & 0 deletions src/components/lineage/__tests__/nodeMeta.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })),
Expand Down
14 changes: 13 additions & 1 deletion src/components/lineage/nodeMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,22 @@ function idFromKey(key: string): string {
*/
export function nodeDetailRoute(
node: LineageNode,
sspId?: string | null,
): { name: string; params: Record<string, string> } | 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 } };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (node.nodeType === 'evidence') {
// The evidence key holds the *stream* uuid, not the record id — only
Expand Down
72 changes: 69 additions & 3 deletions src/components/risk/RiskCreateForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,49 @@
</select>
</div>

<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-slate-400 mb-1"
>
Likelihood
</label>
<select
v-model="formData.likelihood"
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-slate-800 dark:text-slate-300"
>
<option value="">Not set</option>
<option
v-for="level in riskLevelOptions"
:key="`likelihood-${level.value}`"
:value="level.value"
>
{{ level.label }}
</option>
</select>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-slate-400 mb-1"
>
Impact
</label>
<select
v-model="formData.impact"
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-slate-800 dark:text-slate-300"
>
<option value="">Not set</option>
<option
v-for="level in riskLevelOptions"
:key="`impact-${level.value}`"
:value="level.value"
>
{{ level.label }}
</option>
</select>
</div>
</div>

<div
class="rounded-md border border-ccf-300 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 p-4"
>
Expand Down Expand Up @@ -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();
Expand All @@ -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<RiskContext | null>(() => {
if (props.sspId) {
return {
Expand Down Expand Up @@ -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<RiskTemplate | null>(null);
const selectedTemplateName = ref('');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/composables/useLineage/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/composables/useLineage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading