feat: mentor workspace files end-to-end with secure presign
Adds generateMentorObjectKey helper producing <projectName>/mentorship/<timestamp>-<file>. Replaces the client-supplied bucket/objectKey on workspaceUploadFile with an HMAC-signed upload token that binds bucket, objectKey, uploader, and a 1h expiry — paths can no longer be forged from the client. Adds workspaceGetUploadUrl, workspaceGetFiles, workspaceGetFileDownloadUrl, workspaceDeleteFile procedures with mentor-or-team-member auth. Builds <WorkspaceFilesPanel> and wires it into the mentor workspace Files tab and the applicant /applicant/mentor page. Replaces the file-promotion-panel mock array with a real workspaceGetFiles query. Tests cover token sign/verify (5), key construction (5), and end-to-end procedure flow including auth + tampered tokens (7). Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1 Plan: docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
193
src/components/mentor/workspace-files-panel.tsx
Normal file
193
src/components/mentor/workspace-files-panel.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface Props {
|
||||
mentorAssignmentId: string
|
||||
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
|
||||
asApplicant?: boolean
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
|
||||
{ mentorAssignmentId },
|
||||
{ enabled: !!mentorAssignmentId }
|
||||
)
|
||||
|
||||
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
|
||||
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
||||
setDescription('')
|
||||
toast.success('File uploaded')
|
||||
},
|
||||
})
|
||||
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
|
||||
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
||||
toast.success('File deleted')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
setUploading(true)
|
||||
try {
|
||||
const { uploadUrl, uploadToken } = await presign.mutateAsync({
|
||||
mentorAssignmentId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
})
|
||||
const putRes = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
})
|
||||
if (!putRes.ok) throw new Error(`Upload failed: HTTP ${putRes.status}`)
|
||||
await recordUpload.mutateAsync({ uploadToken, description: description || undefined })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (mentorFileId: string) => {
|
||||
try {
|
||||
const { url } = await downloadMutation.mutateAsync({ mentorFileId })
|
||||
window.open(url, '_blank')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Download failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Workspace Files</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Files</CardTitle>
|
||||
<CardDescription>
|
||||
{asApplicant
|
||||
? 'Files shared with your mentor in this workspace.'
|
||||
: 'Files you and the team have shared in this workspace.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description for the next upload"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
disabled={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{uploading ? 'Uploading…' : 'Upload file'}
|
||||
</Button>
|
||||
<input ref={inputRef} type="file" hidden onChange={handleFileSelected} />
|
||||
</div>
|
||||
|
||||
{files && files.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mx-auto mb-2 opacity-40" />
|
||||
No files in this workspace yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y">
|
||||
{(files ?? []).map((f) => (
|
||||
<li key={f.id} className="flex items-center gap-3 py-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{f.fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '}
|
||||
{formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })}
|
||||
{f._count.comments > 0 && (
|
||||
<span className="ml-2 inline-flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{f._count.comments}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{f.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this file?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the file from MinIO and the workspace. Comments on the file are deleted with it.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate({ mentorFileId: f.id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user