qa: tech review 3 (#5250)
* fix: only collapse if pending -> pass/fail, not pass <-> fail * feat: wrap in full details block * feat: left badge * feat: in mod queue -> in project queue * fix: hash on malic modal * feat: remove return to queue on indiv page * fix: truncate in middle * feat: bulk actions * fix: reactivity problem * feat: project page dropdown option * feat: show metadata if exists * fix: lint * fix: qa problems * feat: debug logging for malicious summary modal * fix: lint * qa: go back on bulk * fix: reactive sets/maps -> refs * fix: lint
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
|
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
|
||||||
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
|
import { ButtonStyled, CopyCode, NewModal, useDebugLogger } from '@modrinth/ui'
|
||||||
import { ref, useTemplateRef } from 'vue'
|
import { ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
export type UnsafeFile = {
|
export type UnsafeFile = {
|
||||||
@@ -16,6 +16,8 @@ const props = defineProps<{
|
|||||||
unsafeFiles: UnsafeFile[]
|
unsafeFiles: UnsafeFile[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const debug = useDebugLogger('MaliciousSummaryModal')
|
||||||
|
|
||||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||||
|
|
||||||
const versionDataCache = ref<
|
const versionDataCache = ref<
|
||||||
@@ -36,7 +38,7 @@ async function fetchVersionHashes(versionIds: string[]) {
|
|||||||
versionDataCache.value.set(versionId, { files: new Map(), loading: true })
|
versionDataCache.value.set(versionId, { files: new Map(), loading: true })
|
||||||
try {
|
try {
|
||||||
// TODO: switch to api-client once truman's vers stuff is merged
|
// TODO: switch to api-client once truman's vers stuff is merged
|
||||||
const version = (await useBaseFetch(`version/${versionId}`)) as {
|
const version = (await useBaseFetch(`version/${versionId}`, { apiVersion: 3 })) as {
|
||||||
files: Array<{
|
files: Array<{
|
||||||
id?: string
|
id?: string
|
||||||
filename: string
|
filename: string
|
||||||
@@ -44,6 +46,11 @@ async function fetchVersionHashes(versionIds: string[]) {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
const filesMap = new Map<string, string>()
|
const filesMap = new Map<string, string>()
|
||||||
|
debug('Full version response:', version)
|
||||||
|
debug(
|
||||||
|
'Version files:',
|
||||||
|
version.files.map((f) => ({ id: f.id, filename: f.filename })),
|
||||||
|
)
|
||||||
for (const file of version.files) {
|
for (const file of version.files) {
|
||||||
if (file.id) {
|
if (file.id) {
|
||||||
filesMap.set(file.id, file.hashes.sha512)
|
filesMap.set(file.id, file.hashes.sha512)
|
||||||
@@ -62,7 +69,9 @@ async function fetchVersionHashes(versionIds: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFileHash(versionId: string, fileId: string): string | undefined {
|
function getFileHash(versionId: string, fileId: string): string | undefined {
|
||||||
return versionDataCache.value.get(versionId)?.files.get(fileId)
|
const hash = versionDataCache.value.get(versionId)?.files.get(fileId)
|
||||||
|
debug('getFileHash:', { versionId, fileId, found: !!hash })
|
||||||
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHashLoading(versionId: string): boolean {
|
function isHashLoading(versionId: string): boolean {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
BugIcon,
|
BugIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
|
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +36,7 @@ import {
|
|||||||
type User,
|
type User,
|
||||||
} from '@modrinth/utils'
|
} from '@modrinth/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||||
@@ -159,8 +161,8 @@ const client = injectModrinthClient()
|
|||||||
|
|
||||||
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
||||||
|
|
||||||
const detailDecisions = ref<Map<string, 'safe' | 'malware'>>(new Map())
|
const detailDecisions = reactive<Map<string, 'safe' | 'malware'>>(new Map())
|
||||||
const updatingDetails = ref<Set<string>>(new Set())
|
const updatingDetails = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
function getFileHighestSeverity(
|
function getFileHighestSeverity(
|
||||||
file: FlattenedFileReport,
|
file: FlattenedFileReport,
|
||||||
@@ -252,6 +254,16 @@ function getSeverityBadgeColor(severity: Labrinth.TechReview.Internal.DelphiSeve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateMiddle(str: string, maxLength: number = 120): string {
|
||||||
|
if (str.length <= maxLength) return str
|
||||||
|
const separator = '...'
|
||||||
|
const sepLen = separator.length
|
||||||
|
const charsToShow = maxLength - sepLen
|
||||||
|
const frontChars = Math.ceil(charsToShow / 3)
|
||||||
|
const backChars = Math.floor((charsToShow * 2) / 3)
|
||||||
|
return str.slice(0, frontChars) + separator + str.slice(-backChars)
|
||||||
|
}
|
||||||
|
|
||||||
const severityColor = computed(() => {
|
const severityColor = computed(() => {
|
||||||
switch (highestSeverity.value) {
|
switch (highestSeverity.value) {
|
||||||
case 'severe':
|
case 'severe':
|
||||||
@@ -305,9 +317,9 @@ function backToFileList() {
|
|||||||
async function copyToClipboard(code: string, detailId: string) {
|
async function copyToClipboard(code: string, detailId: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code)
|
await navigator.clipboard.writeText(code)
|
||||||
showCopyFeedback.value.set(detailId, true)
|
showCopyFeedback.set(detailId, true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showCopyFeedback.value.delete(detailId)
|
showCopyFeedback.delete(detailId)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy code:', error)
|
console.error('Failed to copy code:', error)
|
||||||
@@ -318,7 +330,7 @@ function getDetailDecision(
|
|||||||
detailId: string,
|
detailId: string,
|
||||||
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||||
): 'safe' | 'malware' | 'pending' {
|
): 'safe' | 'malware' | 'pending' {
|
||||||
const localDecision = detailDecisions.value.get(detailId)
|
const localDecision = detailDecisions.get(detailId)
|
||||||
if (localDecision) return localDecision
|
if (localDecision) return localDecision
|
||||||
if (backendStatus === 'safe') return 'safe'
|
if (backendStatus === 'safe') return 'safe'
|
||||||
if (backendStatus === 'unsafe') return 'malware'
|
if (backendStatus === 'unsafe') return 'malware'
|
||||||
@@ -329,9 +341,7 @@ function isPreReviewed(
|
|||||||
detailId: string,
|
detailId: string,
|
||||||
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (backendStatus === 'safe' || backendStatus === 'unsafe') && !detailDecisions.has(detailId)
|
||||||
(backendStatus === 'safe' || backendStatus === 'unsafe') && !detailDecisions.value.has(detailId)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMarkedFlagsCount(flags: ClassGroup['flags']): number {
|
function getMarkedFlagsCount(flags: ClassGroup['flags']): number {
|
||||||
@@ -357,8 +367,82 @@ function getFileMarkedCount(file: FlattenedFileReport): number {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remainingUnmarkedCount = computed(() => {
|
||||||
|
if (!selectedFile.value) return 0
|
||||||
|
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBatchUpdating = ref(false)
|
||||||
|
|
||||||
|
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||||
|
if (!selectedFile.value || isBatchUpdating.value) return
|
||||||
|
|
||||||
|
const detailIds: string[] = []
|
||||||
|
for (const issue of selectedFile.value.issues) {
|
||||||
|
for (const detail of issue.details) {
|
||||||
|
const detailWithStatus = detail as typeof detail & {
|
||||||
|
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||||
|
}
|
||||||
|
if (getDetailDecision(detailWithStatus.id, detailWithStatus.status) === 'pending') {
|
||||||
|
detailIds.push(detail.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailIds.length === 0) return
|
||||||
|
|
||||||
|
isBatchUpdating.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
detailIds.map((detailId) =>
|
||||||
|
client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const decision = verdict === 'safe' ? 'safe' : 'malware'
|
||||||
|
for (const detailId of detailIds) {
|
||||||
|
detailDecisions.set(detailId, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: `Marked ${detailIds.length} traces as ${verdict}`,
|
||||||
|
text: `All remaining traces have been marked as ${verdict === 'safe' ? 'false positives' : 'malicious'}.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Jump back to Files tab when all flags in the current file are marked
|
||||||
|
if (selectedFile.value) {
|
||||||
|
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||||
|
const totalCount = getFileDetailCount(selectedFile.value)
|
||||||
|
if (markedCount === totalCount) {
|
||||||
|
backToFileList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to batch update:', error)
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Batch update failed',
|
||||||
|
text: 'An error occurred while updating traces.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isBatchUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
|
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
|
||||||
updatingDetails.value.add(detailId)
|
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
|
||||||
|
outer: for (const report of props.item.reports) {
|
||||||
|
for (const issue of report.issues) {
|
||||||
|
const detail = issue.details.find((d) => d.id === detailId)
|
||||||
|
if (detail) {
|
||||||
|
priorDecision = getDetailDecision(detail.id, detail.status)
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatingDetails.add(detailId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict })
|
await client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict })
|
||||||
@@ -383,7 +467,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
for (const detail of issue.details) {
|
for (const detail of issue.details) {
|
||||||
if (detail.key === detailKey) {
|
if (detail.key === detailKey) {
|
||||||
detailDecisions.value.set(detail.id, decision)
|
detailDecisions.set(detail.id, decision)
|
||||||
if (detail.id !== detailId) {
|
if (detail.id !== detailId) {
|
||||||
otherMatchedCount++
|
otherMatchedCount++
|
||||||
}
|
}
|
||||||
@@ -392,14 +476,17 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
detailDecisions.value.set(detailId, decision)
|
detailDecisions.set(detailId, decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const classGroup of groupedByClass.value) {
|
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
||||||
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
if (priorDecision === 'pending') {
|
||||||
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
for (const classGroup of groupedByClass.value) {
|
||||||
expandedClasses.value.delete(classGroup.filePath)
|
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
||||||
break
|
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||||
|
expandedClasses.delete(classGroup.filePath)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,12 +525,12 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
text: 'An error occurred while updating the issue status.',
|
text: 'An error occurred while updating the issue status.',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
updatingDetails.value.delete(detailId)
|
updatingDetails.delete(detailId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedClasses = ref<Set<string>>(new Set())
|
const expandedClasses = reactive<Set<string>>(new Set())
|
||||||
const showCopyFeedback = ref<Map<string, boolean>>(new Map())
|
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
interface ClassGroup {
|
interface ClassGroup {
|
||||||
filePath: string
|
filePath: string
|
||||||
@@ -503,7 +590,7 @@ watch(
|
|||||||
groupedByClass,
|
groupedByClass,
|
||||||
(classes) => {
|
(classes) => {
|
||||||
if (classes.length === 1) {
|
if (classes.length === 1) {
|
||||||
expandedClasses.value.add(classes[0].filePath)
|
expandedClasses.add(classes[0].filePath)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -522,10 +609,10 @@ function getHighestSeverityInClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleClass(filePath: string) {
|
function toggleClass(filePath: string) {
|
||||||
if (expandedClasses.value.has(filePath)) {
|
if (expandedClasses.has(filePath)) {
|
||||||
expandedClasses.value.delete(filePath)
|
expandedClasses.delete(filePath)
|
||||||
} else {
|
} else {
|
||||||
expandedClasses.value.add(filePath)
|
expandedClasses.add(filePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,6 +708,7 @@ const reviewSummaryPreview = computed(() => {
|
|||||||
|
|
||||||
const timestamp = dayjs().utc().format('MMMM D, YYYY [at] h:mm A [UTC]')
|
const timestamp = dayjs().utc().format('MMMM D, YYYY [at] h:mm A [UTC]')
|
||||||
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`
|
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`
|
||||||
|
markdown += `<details>\n<summary>File Details (${totalSafe} safe, ${totalUnsafe} unsafe)</summary>\n\n`
|
||||||
|
|
||||||
for (const [, fileData] of fileDecisions) {
|
for (const [, fileData] of fileDecisions) {
|
||||||
if (fileData.decisions.length === 0) continue
|
if (fileData.decisions.length === 0) continue
|
||||||
@@ -643,6 +731,7 @@ const reviewSummaryPreview = computed(() => {
|
|||||||
markdown += `\n</details>\n\n`
|
markdown += `\n</details>\n\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markdown += `</details>\n\n`
|
||||||
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`
|
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`
|
||||||
|
|
||||||
return markdown
|
return markdown
|
||||||
@@ -919,11 +1008,12 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
|
v-tooltip="file.file_name"
|
||||||
class="font-medium text-contrast"
|
class="font-medium text-contrast"
|
||||||
:class="{ 'cursor-pointer hover:underline': getFileDetailCount(file) > 0 }"
|
:class="{ 'cursor-pointer hover:underline': getFileDetailCount(file) > 0 }"
|
||||||
@click="getFileDetailCount(file) > 0 && viewFileFlags(file)"
|
@click="getFileDetailCount(file) > 0 && viewFileFlags(file)"
|
||||||
>
|
>
|
||||||
{{ file.file_name }}
|
{{ truncateMiddle(file.file_name, 50) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
|
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
|
||||||
<span class="text-sm font-medium text-secondary">{{
|
<span class="text-sm font-medium text-secondary">{{
|
||||||
@@ -989,11 +1079,31 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="currentTab === 'File' && selectedFile">
|
<template v-else-if="currentTab === 'File' && selectedFile">
|
||||||
|
<div
|
||||||
|
v-if="remainingUnmarkedCount > 0"
|
||||||
|
class="flex gap-2 border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<ButtonStyled color="brand" :disabled="isBatchUpdating">
|
||||||
|
<button @click="batchMarkRemaining('safe')">
|
||||||
|
<CheckCircleIcon class="size-5" />
|
||||||
|
Remaining safe ({{ remainingUnmarkedCount }})
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="red" :disabled="isBatchUpdating">
|
||||||
|
<button @click="batchMarkRemaining('unsafe')">
|
||||||
|
<TriangleAlertIcon class="size-5" />
|
||||||
|
Remaining malware ({{ remainingUnmarkedCount }})
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(classItem, idx) in groupedByClass"
|
v-for="(classItem, idx) in groupedByClass"
|
||||||
:key="classItem.filePath"
|
:key="classItem.filePath"
|
||||||
class="border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2"
|
class="border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2"
|
||||||
:class="{ 'rounded-bl-2xl rounded-br-2xl': idx === groupedByClass.length - 1 }"
|
:class="{
|
||||||
|
'rounded-bl-2xl rounded-br-2xl':
|
||||||
|
idx === groupedByClass.length - 1 && remainingUnmarkedCount === 0,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
|
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
|
||||||
@@ -1009,7 +1119,9 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|
||||||
<span class="font-mono font-semibold">{{ classItem.filePath }}</span>
|
<span v-tooltip="classItem.filePath" class="font-mono font-semibold">{{
|
||||||
|
truncateMiddle(classItem.filePath)
|
||||||
|
}}</span>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-full border-solid px-2.5 py-1"
|
class="rounded-full border-solid px-2.5 py-1"
|
||||||
@@ -1054,66 +1166,87 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
<div
|
<div
|
||||||
v-for="flag in classItem.flags"
|
v-for="flag in classItem.flags"
|
||||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||||
class="grid grid-cols-[1fr_auto_auto] items-center rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
||||||
>
|
>
|
||||||
<span
|
<div class="grid grid-cols-[1fr_auto] items-center">
|
||||||
class="text-base font-semibold text-contrast"
|
|
||||||
:class="{
|
|
||||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
|
||||||
}"
|
|
||||||
>{{ flag.issueType.replace(/_/g, ' ') }}</span
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex w-20 justify-center"
|
|
||||||
:class="{
|
|
||||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="rounded-full border-solid px-2.5 py-1"
|
class="flex items-center gap-2"
|
||||||
:class="getSeverityBadgeColor(flag.detail.severity)"
|
:class="{
|
||||||
|
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium">{{
|
<span class="text-base font-semibold text-contrast">{{
|
||||||
capitalizeString(flag.detail.severity)
|
flag.issueType.replace(/_/g, ' ')
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<div
|
||||||
|
class="rounded-full border-solid px-2.5 py-1"
|
||||||
|
:class="getSeverityBadgeColor(flag.detail.severity)"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
capitalizeString(flag.detail.severity)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-40 items-center justify-center gap-2">
|
||||||
|
<ButtonStyled
|
||||||
|
color="brand"
|
||||||
|
:type="
|
||||||
|
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
|
||||||
|
? undefined
|
||||||
|
: 'outlined'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="!border-[1px]"
|
||||||
|
:disabled="updatingDetails.has(flag.detail.id)"
|
||||||
|
@click="updateDetailStatus(flag.detail.id, 'safe')"
|
||||||
|
>
|
||||||
|
Pass
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
|
||||||
|
<ButtonStyled
|
||||||
|
color="red"
|
||||||
|
:type="
|
||||||
|
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
|
||||||
|
? undefined
|
||||||
|
: 'outlined'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="!border-[1px]"
|
||||||
|
:disabled="updatingDetails.has(flag.detail.id)"
|
||||||
|
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
|
||||||
|
>
|
||||||
|
Fail
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div class="flex w-40 items-center justify-center gap-2">
|
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
|
||||||
<ButtonStyled
|
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
|
||||||
color="brand"
|
>
|
||||||
:type="
|
<div
|
||||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
|
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
|
||||||
? undefined
|
a.localeCompare(b),
|
||||||
: 'outlined'
|
)"
|
||||||
"
|
:key="key"
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<button
|
<span class="text-secondary">{{ key }}:</span>
|
||||||
class="!border-[1px]"
|
<a
|
||||||
:disabled="updatingDetails.has(flag.detail.id)"
|
v-if="typeof value === 'string' && value.startsWith('http')"
|
||||||
@click="updateDetailStatus(flag.detail.id, 'safe')"
|
:href="value"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-brand-blue hover:underline"
|
||||||
>
|
>
|
||||||
Pass
|
{{ value }}
|
||||||
</button>
|
</a>
|
||||||
</ButtonStyled>
|
<span v-else class="font-mono text-contrast">{{ value }}</span>
|
||||||
|
</div>
|
||||||
<ButtonStyled
|
|
||||||
color="red"
|
|
||||||
:type="
|
|
||||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
|
|
||||||
? undefined
|
|
||||||
: 'outlined'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="!border-[1px]"
|
|
||||||
:disabled="updatingDetails.has(flag.detail.id)"
|
|
||||||
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
|
|
||||||
>
|
|
||||||
Fail
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -686,6 +686,17 @@
|
|||||||
tags.staffRoles.includes(auth.user.role) &&
|
tags.staffRoles.includes(auth.user.role) &&
|
||||||
!showModerationChecklist,
|
!showModerationChecklist,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tech-review',
|
||||||
|
link: `/moderation/technical-review/${project.id}`,
|
||||||
|
color: 'orange',
|
||||||
|
hoverOnly: true,
|
||||||
|
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'report',
|
id: 'report',
|
||||||
action: () =>
|
action: () =>
|
||||||
@@ -708,6 +719,7 @@
|
|||||||
<template #moderation-checklist>
|
<template #moderation-checklist>
|
||||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
|
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
|
||||||
<template #report>
|
<template #report>
|
||||||
<ReportIcon aria-hidden="true" />
|
<ReportIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.reportButton) }}
|
{{ formatMessage(commonMessages.reportButton) }}
|
||||||
@@ -942,6 +954,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
ReportIcon,
|
ReportIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
|
ScanEyeIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerPlusIcon,
|
ServerPlusIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import MaliciousSummaryModal, {
|
|||||||
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
|
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@@ -82,27 +81,27 @@ if (import.meta.client) {
|
|||||||
clearExpiredCache()
|
clearExpiredCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingIssues = ref<Set<string>>(new Set())
|
const loadingIssues = reactive<Set<string>>(new Set())
|
||||||
const decompiledSources = ref<Map<string, string>>(new Map())
|
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||||
|
|
||||||
async function loadIssueSource(issueId: string): Promise<void> {
|
async function loadIssueSource(issueId: string): Promise<void> {
|
||||||
if (loadingIssues.value.has(issueId)) return
|
if (loadingIssues.has(issueId)) return
|
||||||
|
|
||||||
loadingIssues.value.add(issueId)
|
loadingIssues.add(issueId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||||
|
|
||||||
for (const detail of issueData.details) {
|
for (const detail of issueData.details) {
|
||||||
if (detail.decompiled_source) {
|
if (detail.decompiled_source) {
|
||||||
decompiledSources.value.set(detail.id, detail.decompiled_source)
|
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||||
setCachedSource(detail.id, detail.decompiled_source)
|
setCachedSource(detail.id, detail.decompiled_source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load issue source:', error)
|
console.error('Failed to load issue source:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loadingIssues.value.delete(issueId)
|
loadingIssues.delete(issueId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,10 +112,10 @@ function tryLoadCachedSourcesForFile(reportId: string): void {
|
|||||||
if (report) {
|
if (report) {
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
for (const detail of issue.details) {
|
for (const detail of issue.details) {
|
||||||
if (!decompiledSources.value.has(detail.id)) {
|
if (!decompiledSources.has(detail.id)) {
|
||||||
const cached = getCachedSource(detail.id)
|
const cached = getCachedSource(detail.id)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
decompiledSources.value.set(detail.id, cached)
|
decompiledSources.set(detail.id, cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,7 @@ function handleLoadFileSources(reportId: string): void {
|
|||||||
const report = reviewItem.value.reports.find((r) => r.id === reportId)
|
const report = reviewItem.value.reports.find((r) => r.id === reportId)
|
||||||
if (report) {
|
if (report) {
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
const hasUncached = issue.details.some((d) => !decompiledSources.value.has(d.id))
|
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
|
||||||
if (hasUncached) {
|
if (hasUncached) {
|
||||||
loadIssueSource(issue.id)
|
loadIssueSource(issue.id)
|
||||||
}
|
}
|
||||||
@@ -244,7 +243,6 @@ const reviewItem = computed(() => {
|
|||||||
|
|
||||||
function handleMarkComplete(_projectId: string) {
|
function handleMarkComplete(_projectId: string) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] })
|
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] })
|
||||||
router.push('/moderation/technical-review')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
|
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick, reactive } from 'vue'
|
||||||
|
|
||||||
import MaliciousSummaryModal, {
|
import MaliciousSummaryModal, {
|
||||||
type UnsafeFile,
|
type UnsafeFile,
|
||||||
@@ -103,27 +103,27 @@ function clearExpiredCache(): void {
|
|||||||
|
|
||||||
clearExpiredCache()
|
clearExpiredCache()
|
||||||
|
|
||||||
const loadingIssues = ref<Set<string>>(new Set())
|
const loadingIssues = reactive<Set<string>>(new Set())
|
||||||
const decompiledSources = ref<Map<string, string>>(new Map())
|
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||||
|
|
||||||
async function loadIssueSource(issueId: string): Promise<void> {
|
async function loadIssueSource(issueId: string): Promise<void> {
|
||||||
if (loadingIssues.value.has(issueId)) return
|
if (loadingIssues.has(issueId)) return
|
||||||
|
|
||||||
loadingIssues.value.add(issueId)
|
loadingIssues.add(issueId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
|
||||||
|
|
||||||
for (const detail of issueData.details) {
|
for (const detail of issueData.details) {
|
||||||
if (detail.decompiled_source) {
|
if (detail.decompiled_source) {
|
||||||
decompiledSources.value.set(detail.id, detail.decompiled_source)
|
decompiledSources.set(detail.id, detail.decompiled_source)
|
||||||
setCachedSource(detail.id, detail.decompiled_source)
|
setCachedSource(detail.id, detail.decompiled_source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load issue source:', error)
|
console.error('Failed to load issue source:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loadingIssues.value.delete(issueId)
|
loadingIssues.delete(issueId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +133,10 @@ function tryLoadCachedSourcesForFile(reportId: string): void {
|
|||||||
if (report) {
|
if (report) {
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
for (const detail of issue.details) {
|
for (const detail of issue.details) {
|
||||||
if (!decompiledSources.value.has(detail.id)) {
|
if (!decompiledSources.has(detail.id)) {
|
||||||
const cached = getCachedSource(detail.id)
|
const cached = getCachedSource(detail.id)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
decompiledSources.value.set(detail.id, cached)
|
decompiledSources.set(detail.id, cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ function handleLoadFileSources(reportId: string): void {
|
|||||||
const report = review.reports.find((r) => r.id === reportId)
|
const report = review.reports.find((r) => r.id === reportId)
|
||||||
if (report) {
|
if (report) {
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
const hasUncached = issue.details.some((d) => !decompiledSources.value.has(d.id))
|
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
|
||||||
if (hasUncached) {
|
if (hasUncached) {
|
||||||
loadIssueSource(issue.id)
|
loadIssueSource(issue.id)
|
||||||
}
|
}
|
||||||
@@ -470,19 +470,23 @@ function handleMarkComplete(projectId: string) {
|
|||||||
|
|
||||||
// Scroll to the next card after Vue updates the DOM
|
// Scroll to the next card after Vue updates the DOM
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const targetIndex = currentIndex
|
// Get the project ID at the same position (next project after removal)
|
||||||
if (targetIndex >= 0 && cardRefs.value[targetIndex]) {
|
const nextItem = paginatedItems.value[currentIndex]
|
||||||
cardRefs.value[targetIndex].scrollIntoView({
|
if (nextItem) {
|
||||||
behavior: 'smooth',
|
const nextCard = cardRefs.get(nextItem.project.id)
|
||||||
block: 'start',
|
if (nextCard) {
|
||||||
})
|
nextCard.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
|
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
|
||||||
const currentUnsafeFiles = ref<UnsafeFile[]>([])
|
const currentUnsafeFiles = ref<UnsafeFile[]>([])
|
||||||
const cardRefs = ref<HTMLElement[]>([])
|
const cardRefs = reactive<Map<string, HTMLElement>>(new Map())
|
||||||
|
|
||||||
function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
|
function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
|
||||||
currentUnsafeFiles.value = unsafeFiles
|
currentUnsafeFiles.value = unsafeFiles
|
||||||
@@ -576,7 +580,7 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
|
|||||||
<template #panel>
|
<template #panel>
|
||||||
<div class="flex min-w-64 flex-col gap-3">
|
<div class="flex min-w-64 flex-col gap-3">
|
||||||
<label class="flex cursor-pointer items-center justify-between gap-2 text-sm">
|
<label class="flex cursor-pointer items-center justify-between gap-2 text-sm">
|
||||||
<span class="whitespace-nowrap font-semibold">In mod queue</span>
|
<span class="whitespace-nowrap font-semibold">In project queue</span>
|
||||||
<Toggle v-model="inOtherQueueFilter" />
|
<Toggle v-model="inOtherQueueFilter" />
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -619,11 +623,15 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
|
|||||||
No projects in queue.
|
No projects in queue.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(item, idx) in paginatedItems"
|
v-for="item in paginatedItems"
|
||||||
:key="item.project.id ?? idx"
|
:key="item.project.id"
|
||||||
:ref="
|
:ref="
|
||||||
(el) => {
|
(el) => {
|
||||||
if (el) cardRefs[idx] = el as HTMLElement
|
if (el) {
|
||||||
|
cardRefs.set(item.project.id, el as HTMLElement)
|
||||||
|
} else {
|
||||||
|
cardRefs.delete(item.project.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user