Files
AstralRinth/apps/frontend/src/components/ui/moderation/MaliciousSummaryModal.vue
Calum H. a869086ce9 polish(frontend): technical review QA (#5097)
* feat: filtering + sorting alignment

* polish: malicious summary modal changes

* feat: better filter row using floating panel

* fix: re-enable request

* fix: lint

* polish: jump back to files tab qol

* feat: scroll to top of next card when done

* fix: show lock icon on preview msg

* feat: download no _blank

* feat: show also marked in notif

* feat: auto expand if only one class in the file

* feat: proper page titles

* fix: text-contrast typo

* fix: lint

* feat: QA changes

* feat: individual report page + more qa

* fix: back btn

* fix: broken import

* feat: quick reply msgs

* fix: in other queue filter

* fix: caching threads wrongly

* fix: flag filter

* feat: toggle enabled by default

* fix: dont make btns opacity 50

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-20 19:56:24 +00:00

178 lines
4.9 KiB
Vue

<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
export type UnsafeFile = {
file: Labrinth.TechReview.Internal.FileReport & { version_id: string }
projectName: string
projectId: string
userId: string
username: string
}
const props = defineProps<{
unsafeFiles: UnsafeFile[]
}>()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const versionDataCache = ref<
Map<
string,
{
files: Map<string, string>
loading: boolean
error?: string
}
>
>(new Map())
async function fetchVersionHashes(versionIds: string[]) {
const uniqueIds = [...new Set(versionIds)]
for (const versionId of uniqueIds) {
if (versionDataCache.value.has(versionId)) continue
versionDataCache.value.set(versionId, { files: new Map(), loading: true })
try {
// TODO: switch to api-client once truman's vers stuff is merged
const version = (await useBaseFetch(`version/${versionId}`)) as {
files: Array<{
id?: string
filename: string
hashes: { sha512: string; sha1: string }
}>
}
const filesMap = new Map<string, string>()
for (const file of version.files) {
if (file.id) {
filesMap.set(file.id, file.hashes.sha512)
}
}
versionDataCache.value.set(versionId, { files: filesMap, loading: false })
} catch (error) {
console.error(`Failed to fetch version ${versionId}:`, error)
versionDataCache.value.set(versionId, {
files: new Map(),
loading: false,
error: 'Failed',
})
}
}
}
function getFileHash(versionId: string, fileId: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileId)
}
function isHashLoading(versionId: string): boolean {
return versionDataCache.value.get(versionId)?.loading ?? false
}
function show() {
const versionIds = props.unsafeFiles.map((f) => f.file.version_id)
fetchVersionHashes(versionIds)
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
async function copy(text: string) {
await navigator.clipboard.writeText(text)
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modalRef"
header="Malicious file(s) summary"
:close-on-click-outside="false"
:close-on-esc="false"
:closable="false"
>
<div class="markdown-body inset-0">
<div v-if="unsafeFiles.length > 0" class="mb-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-tertiary text-sm font-medium">Project:</span>
<CopyCode :text="unsafeFiles[0].projectName" />
<CopyCode :text="unsafeFiles[0].projectId" />
</div>
<div class="flex items-center gap-2">
<span class="text-tertiary text-sm font-medium">User:</span>
<CopyCode :text="unsafeFiles[0].username" />
<CopyCode :text="unsafeFiles[0].userId" />
</div>
</div>
<table v-if="unsafeFiles.length > 0" class="w-full text-sm">
<thead>
<tr class="text-tertiary text-left text-xs font-medium">
<th class="pb-2">Hash</th>
<th class="pb-2">Version ID</th>
<th class="pb-2">File Name</th>
<th class="pb-2">CDN Link</th>
<th class="pb-2">Download</th>
</tr>
</thead>
<tbody>
<tr v-for="item in unsafeFiles" :key="item.file.file_id">
<td class="py-1 pr-2">
<LoaderCircleIcon
v-if="isHashLoading(item.file.version_id)"
class="size-4 animate-spin text-secondary"
/>
<ButtonStyled
v-else-if="getFileHash(item.file.version_id, item.file.file_id)"
size="small"
type="standard"
>
<button @click="copy(getFileHash(item.file.version_id, item.file.file_id)!)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
</ButtonStyled>
<span v-else class="text-tertiary italic">N/A</span>
</td>
<td class="py-1 pr-2">
<CopyCode :text="item.file.version_id" />
</td>
<td class="py-1 pr-2">
<CopyCode :text="item.file.file_name" />
</td>
<td class="py-1 pr-2">
<ButtonStyled size="small" type="standard">
<button @click="copy(item.file.download_url)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
</ButtonStyled>
</td>
<td class="py-1">
<ButtonStyled circular size="small">
<a :href="item.file.download_url" :download="item.file.file_name" target="_blank">
<DownloadIcon />
</a>
</ButtonStyled>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-sm italic text-secondary">No files currently marked as malicious.</p>
<div class="flex justify-end">
<ButtonStyled>
<button @click="hide">
<XIcon class="size-4" />
Close
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>