forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { SEVERITY_LEVELS } from '#shared/types'
3import { SEVERITY_COLORS } from '#shared/utils/severity'
4
5const props = defineProps<{
6 packageName: string
7 version: string
8}>()
9
10const { data: vulnTree, status } = useDependencyAnalysis(
11 () => props.packageName,
12 () => props.version,
13)
14
15const isExpanded = shallowRef(false)
16const showAllPackages = shallowRef(false)
17const showAllVulnerabilities = shallowRef(false)
18
19const hasVulnerabilities = computed(
20 () => vulnTree.value && vulnTree.value.vulnerablePackages.length > 0,
21)
22
23// Banner - amber for better light mode contrast
24const bannerColor = 'border-amber-600/40 bg-amber-500/10 text-amber-800 dark:text-amber-400'
25
26const severityLabels = computed(() => ({
27 critical: $t('package.vulnerabilities.severity.critical'),
28 high: $t('package.vulnerabilities.severity.high'),
29 moderate: $t('package.vulnerabilities.severity.moderate'),
30 low: $t('package.vulnerabilities.severity.low'),
31}))
32
33function getPackageSeverityLabel(severity: Exclude<OsvSeverityLevel, 'unknown'>) {
34 return severityLabels.value[severity]
35}
36
37const summaryText = computed(() => {
38 if (!vulnTree.value) return ''
39 const { totalCounts } = vulnTree.value
40 return SEVERITY_LEVELS.filter(s => totalCounts[s] > 0)
41 .map(s => `${totalCounts[s]} ${getPackageSeverityLabel(s)}`)
42 .join(', ')
43})
44
45// Styling for each depth level - using accessible colors for both themes
46const depthStyles = {
47 root: {
48 bg: 'bg-amber-500/5 border-is-2 border-is-amber-600',
49 text: 'text-fg',
50 },
51 direct: {
52 bg: 'bg-amber-500/5 border-is-2 border-is-amber-500',
53 text: 'text-fg-muted',
54 },
55 transitive: {
56 bg: 'bg-amber-500/5 border-is-2 border-is-amber-400',
57 text: 'text-fg-muted',
58 },
59} as const
60
61// Helper to get depth style with fallback
62function getDepthStyle(depth: string | undefined) {
63 if (depth && depth in depthStyles) {
64 return depthStyles[depth as keyof typeof depthStyles]
65 }
66 return depthStyles.transitive
67}
68</script>
69
70<template>
71 <section
72 v-if="status === 'success' && hasVulnerabilities"
73 aria-labelledby="vuln-tree-heading"
74 class="relative"
75 >
76 <!-- Collapsible vulnerability banner -->
77 <div role="alert" class="rounded-lg border overflow-hidden" :class="bannerColor">
78 <!-- Header -->
79 <button
80 type="button"
81 class="w-full flex items-center justify-between gap-3 px-4 py-3 text-start transition-colors duration-200 hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/70"
82 :aria-expanded="isExpanded"
83 aria-controls="vuln-tree-details"
84 @click="isExpanded = !isExpanded"
85 >
86 <span class="flex items-center gap-2 min-w-0">
87 <span class="i-lucide:circle-alert w-4 h-4 shrink-0" aria-hidden="true" />
88 <span class="font-mono text-sm font-medium truncate">
89 {{
90 $t(
91 'package.vulnerabilities.tree_found',
92 {
93 vulns: vulnTree!.totalCounts.total,
94 packages: vulnTree!.vulnerablePackages.length,
95 total: vulnTree!.totalPackages,
96 },
97 vulnTree!.totalCounts.total,
98 )
99 }}
100 </span>
101 </span>
102 <span class="flex items-center gap-2 shrink-0">
103 <span class="text-xs op-90 dark:op-80 hidden sm:inline">{{ summaryText }}</span>
104 <span
105 class="i-lucide:chevron-down w-4 h-4 transition-transform duration-200"
106 :class="{ 'rotate-180': isExpanded }"
107 aria-hidden="true"
108 />
109 </span>
110 </button>
111
112 <!-- Expandable details -->
113 <div v-show="isExpanded" id="vuln-tree-details" class="border-t border-border bg-bg-subtle">
114 <ul class="divide-y divide-border list-none m-0 p-0">
115 <li
116 v-for="pkg in vulnTree!.vulnerablePackages.slice(0, showAllPackages ? undefined : 5)"
117 :key="`${pkg.name}@${pkg.version}`"
118 class="px-4 py-3"
119 :class="getDepthStyle(pkg.depth).bg"
120 >
121 <div class="flex items-center justify-between gap-2 mb-2">
122 <div class="flex items-center gap-2 min-w-0 relative">
123 <!-- Path badge - click to show tree popup -->
124 <DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />
125
126 <NuxtLink
127 :to="packageRoute(pkg.name, pkg.version)"
128 class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0"
129 :class="getDepthStyle(pkg.depth).text"
130 >
131 {{ pkg.name }}@{{ pkg.version }}
132 </NuxtLink>
133 </div>
134 <div class="flex items-center gap-1 shrink-0">
135 <span
136 v-for="s in SEVERITY_LEVELS.filter(s => pkg.counts[s] > 0)"
137 :key="s"
138 class="px-1.5 py-0.5 text-3xs font-mono rounded border"
139 :class="SEVERITY_COLORS[s]"
140 >
141 {{ pkg.counts[s] }} {{ getPackageSeverityLabel(s) }}
142 </span>
143 </div>
144 </div>
145 <!-- Show first 2 vulnerabilities -->
146 <ul class="space-y-1 list-none m-0 p-0">
147 <li
148 v-for="vuln in pkg.vulnerabilities.slice(0, showAllVulnerabilities ? undefined : 2)"
149 :key="vuln.id"
150 class="flex items-center gap-2 text-xs text-fg-muted"
151 >
152 <a
153 :href="vuln.url"
154 target="_blank"
155 rel="noopener noreferrer"
156 class="font-mono hover:underline shrink-0"
157 >
158 {{ vuln.id }}
159 </a>
160 <span class="truncate w-0 flex-1">{{ vuln.summary }}</span>
161 <NuxtLink
162 v-if="vuln.fixedIn"
163 :to="packageRoute(pkg.name, vuln.fixedIn)"
164 class="shrink-0 font-mono text-emerald-600 dark:text-emerald-400 hover:underline"
165 :title="$t('package.vulnerabilities.fixed_in_title', { version: vuln.fixedIn })"
166 >
167 → {{ vuln.fixedIn }}
168 </NuxtLink>
169 </li>
170 <li
171 v-if="pkg.vulnerabilities.length > 2 && !showAllVulnerabilities"
172 class="text-xs text-fg-subtle"
173 >
174 <button type="button" @click="showAllVulnerabilities = true">
175 {{
176 $t('package.vulnerabilities.more', { count: pkg.vulnerabilities.length - 2 })
177 }}
178 </button>
179 </li>
180 </ul>
181 </li>
182 </ul>
183
184 <button
185 v-if="vulnTree!.vulnerablePackages.length > 5 && !showAllPackages"
186 type="button"
187 class="w-full px-4 py-2 text-xs font-mono text-fg-muted hover:text-fg border-t border-border transition-colors duration-200"
188 @click="showAllPackages = true"
189 >
190 {{
191 $t('package.vulnerabilities.show_all_packages', {
192 count: vulnTree!.vulnerablePackages.length,
193 })
194 }}
195 </button>
196
197 <!-- Warning if some queries failed -->
198 <div
199 v-if="vulnTree!.failedQueries"
200 class="px-4 py-2 text-xs text-fg-subtle border-t border-border flex items-center gap-2"
201 >
202 <span class="i-lucide:circle-alert w-3 h-3" aria-hidden="true" />
203 <span>{{ $t('package.vulnerabilities.packages_failed', vulnTree!.failedQueries) }}</span>
204 </div>
205 </div>
206 </div>
207 </section>
208
209 <!-- Loading state - hidden (loading indicator shown in stats banner) -->
210
211 <!-- No vulnerabilities found - don't show anything (count is shown in stats banner) -->
212
213 <!-- Error state - subtle, not alarming -->
214 <section v-else-if="status === 'error'" aria-labelledby="vuln-tree-error">
215 <div class="rounded-lg border border-border bg-bg-subtle px-4 py-3">
216 <div class="flex items-center gap-2">
217 <span class="i-lucide:circle-alert w-4 h-4 text-fg-subtle" aria-hidden="true" />
218 <span class="text-sm text-fg-muted">
219 {{ $t('package.vulnerabilities.scan_failed') }}
220 </span>
221 </div>
222 </div>
223 </section>
224</template>