Files
LinkDesk/frontend/src/components/task/SubmissionCard.vue
T
indigo 960753b3d6 Make note/submission edit-own and edit-others' permissions explicit
Split note:edit/note:delete and submission:edit/submission:delete into
four independent permissions each - edit_self/delete_self (acting on
your own note or submission) and edit_other/delete_other (acting on
someone else's). Previously "own" access was an unconditional, unrevokable
ownership check with no permission behind it, and a prior round had
accidentally granted coordinator submission:edit/delete by default
(inconsistent with notes, which were correctly own-only) - both are fixed
here: self-service now goes through a real, default-granted-to-everyone
permission, and acting on someone else's note/submission is an explicit
elevated grant that nobody gets by default.

The Role Management permission editor now shows "Edit Own / Delete Own /
Edit Others' / Delete Others'" as four clear, independently toggleable
options instead of one ambiguous "Edit"/"Delete" checkbox.

migrate_role_permissions.py renames the existing permission rows in place
(rather than leaving orphaned duplicates) and includes a one-time,
idempotent correction that revokes the earlier over-grant from coordinator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 21:17:47 +08:00

279 lines
9.6 KiB
Vue

<template>
<Card class="overflow-hidden hover:shadow-md transition-shadow">
<div class="flex gap-4 p-4">
<!-- Thumbnail -->
<div class="w-32 h-24 bg-muted rounded flex items-center justify-center flex-shrink-0 relative group cursor-pointer" @click="emit('view', submission)">
<img
v-if="thumbnailBlobUrl"
:src="thumbnailBlobUrl"
:alt="submission.file_name"
class="w-full h-full object-cover rounded"
/>
<div v-else class="flex flex-col items-center gap-1">
<FileIcon class="h-8 w-8 text-muted-foreground" />
<span class="text-xs text-muted-foreground">{{ getFileExtension(submission.file_name) }}</span>
</div>
<!-- Play icon for videos -->
<div v-if="submission.stream_url" class="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/50 transition-colors">
<Play class="h-8 w-8 text-white" />
</div>
</div>
<!-- Info -->
<div class="flex-1 space-y-2">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<h4 class="font-semibold">Version {{ submission.version_number }}</h4>
<Badge v-if="submission.latest_review" :variant="getReviewVariant(submission.latest_review.decision)">
{{ submission.latest_review.decision }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">{{ submission.file_name }}</p>
</div>
<Button
variant="ghost"
size="sm"
@click="handleDownload"
>
<Download class="h-4 w-4" />
</Button>
</div>
<div class="text-xs text-muted-foreground space-y-1">
<div class="flex items-center gap-2">
<Avatar class="h-5 w-5">
<AvatarImage
:src="`https://ui-avatars.com/api/?name=${submission.user_first_name} ${submission.user_last_name}`"
/>
<AvatarFallback class="text-[10px]">{{ getUserInitials(submission.user_first_name, submission.user_last_name) }}</AvatarFallback>
</Avatar>
<span>Submitted by {{ submission.user_first_name }} {{ submission.user_last_name }}</span>
</div>
<div>
{{ formatDateTime(submission.submitted_at) }}
</div>
</div>
<div v-if="!editing && submission.notes" class="text-sm bg-muted p-2 rounded">
<p class="font-semibold text-xs mb-1">Notes:</p>
<p class="line-clamp-2">{{ submission.notes }}</p>
</div>
<div v-if="editing" class="space-y-2">
<Textarea v-model="editNotes" rows="2" class="resize-none text-sm" placeholder="Notes about this submission..." />
<div class="flex gap-2">
<Button size="sm" @click="handleSaveNotes">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
</div>
</div>
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
<p class="text-xs text-muted-foreground mt-1">
By {{ submission.latest_review.reviewer_first_name }} {{ submission.latest_review.reviewer_last_name }}
</p>
</div>
<div v-if="!editing" class="flex gap-2">
<Button
variant="outline"
size="sm"
@click="emit('view', submission)"
>
<Eye class="h-4 w-4 mr-2" />
View Details
</Button>
<Button v-if="canEdit" variant="ghost" size="sm" @click="startEdit">
<Pencil class="h-4 w-4 mr-2" />
Edit
</Button>
<Button v-if="canDelete" variant="ghost" size="sm" @click="showDeleteDialog = true">
<Trash2 class="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Submission</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this submission? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { FileIcon, Download, Eye, Play, Pencil, Trash2 } from 'lucide-vue-next'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { taskService, type Submission } from '@/services/task'
import { apiClient } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/composables/usePermission'
import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
submission: Submission
}>()
const emit = defineEmits<{
view: [submission: Submission]
submissionUpdated: []
}>()
const { toast } = useToast()
const authStore = useAuthStore()
const { hasPermission } = usePermission()
const isOwnSubmission = computed(() => authStore.user?.id === props.submission.user_id)
const canEdit = computed(() => {
if (authStore.user?.is_admin) return true
return isOwnSubmission.value ? hasPermission('submission', 'edit_self') : hasPermission('submission', 'edit_other')
})
const canDelete = computed(() => {
if (authStore.user?.is_admin) return true
return isOwnSubmission.value ? hasPermission('submission', 'delete_self') : hasPermission('submission', 'delete_other')
})
const editing = ref(false)
const editNotes = ref('')
const showDeleteDialog = ref(false)
function startEdit() {
editing.value = true
editNotes.value = props.submission.notes || ''
}
async function handleSaveNotes() {
try {
await taskService.updateSubmission(props.taskId, props.submission.id, editNotes.value)
editing.value = false
emit('submissionUpdated')
toast({ title: 'Success', description: 'Submission updated successfully' })
} catch (error: any) {
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to update submission',
variant: 'destructive'
})
}
}
async function handleDelete() {
try {
await taskService.deleteSubmission(props.taskId, props.submission.id)
emit('submissionUpdated')
toast({ title: 'Success', description: 'Submission deleted successfully' })
} catch (error: any) {
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to delete submission',
variant: 'destructive'
})
} finally {
showDeleteDialog.value = false
}
}
const thumbnailBlobUrl = ref<string | null>(null)
function getFileExtension(filename: string): string {
const parts = filename.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE'
}
function formatDateTime(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
function getReviewVariant(decision: string): 'default' | 'destructive' {
return decision === 'approved' ? 'default' : 'destructive'
}
function getReviewBorderClass(decision: string): string {
return decision === 'approved' ? 'border-green-500' : 'border-destructive'
}
function handleDownload() {
if (props.submission.download_url) {
const url = getThumbnailUrl(props.submission.download_url)
window.open(url, '_blank')
}
}
function getUserInitials(firstName: string, lastName: string) {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
async function loadThumbnail() {
if (!props.submission.thumbnail_url) {
thumbnailBlobUrl.value = null
return
}
try {
const response = await apiClient.get(props.submission.thumbnail_url, {
responseType: 'blob'
})
// Revoke old blob URL if it exists
if (thumbnailBlobUrl.value) {
URL.revokeObjectURL(thumbnailBlobUrl.value)
}
// Create new blob URL
thumbnailBlobUrl.value = URL.createObjectURL(response.data)
} catch (error) {
console.error('Failed to load thumbnail:', error)
thumbnailBlobUrl.value = null
}
}
onMounted(() => {
loadThumbnail()
})
watch(() => props.submission.id, () => {
loadThumbnail()
})
function getThumbnailUrl(url: string | null | undefined) {
if (!url) return ''
if (url.startsWith('http')) return url
// Remove any leading slashes and backend prefix
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/').replace(/^\/+/, '')
return `http://localhost:8000/${cleanUrl}`
}
</script>