Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup
Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -519,7 +519,7 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{project.files?.length ?? 0}</TableCell>
|
||||
<TableCell>{project._count?.files ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -186,8 +186,8 @@ function ProgramRounds({ program }: { program: any }) {
|
||||
<CardContent>
|
||||
{rounds.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{/* Desktop: Table header */}
|
||||
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<div>Order</div>
|
||||
<div>Round</div>
|
||||
<div>Status</div>
|
||||
@@ -207,7 +207,7 @@ function ProgramRounds({ program }: { program: any }) {
|
||||
items={rounds.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2 lg:space-y-1">
|
||||
{rounds.map((round, index) => (
|
||||
<SortableRoundRow
|
||||
key={round.id}
|
||||
@@ -378,157 +378,229 @@ function SortableRoundRow({
|
||||
)
|
||||
}
|
||||
|
||||
const actionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Round
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Judge Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{round.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
|
||||
}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'CLOSED' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Round
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
const deleteDialog = (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{round.name}"? This will
|
||||
remove {round._count?.projects || 0} project assignments,{' '}
|
||||
{round._count?.assignments || 0} reviewer assignments, and all evaluations
|
||||
in this round. The projects themselves will remain in the program. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ id: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteRound.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5 rounded-lg border bg-card transition-all',
|
||||
'rounded-lg border bg-card transition-all',
|
||||
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
|
||||
isReordering && !isDragging && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Order number with drag handle */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||
disabled={isReordering}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{index}
|
||||
</span>
|
||||
{/* Desktop: Table row layout */}
|
||||
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
|
||||
{/* Order number with drag handle */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||
disabled={isReordering}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Round name */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>{getStatusBadge()}</div>
|
||||
|
||||
{/* Voting window */}
|
||||
<div>{getVotingWindow()}</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Assignments */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
{actionsMenu}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round name */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>{getStatusBadge()}</div>
|
||||
|
||||
{/* Voting window */}
|
||||
<div>{getVotingWindow()}</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Assignments */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Round
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Judge Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{round.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
|
||||
}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'CLOSED' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
{/* Mobile/Tablet: Card layout */}
|
||||
<div className="lg:hidden p-4">
|
||||
{/* Top row: drag handle, order, name, status badge, actions */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-1 pt-0.5">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||
disabled={isReordering}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Round
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{getStatusBadge()}
|
||||
{actionsMenu}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{round.name}"? This will
|
||||
remove {round._count?.projects || 0} project assignments,{' '}
|
||||
{round._count?.assignments || 0} reviewer assignments, and all evaluations
|
||||
in this round. The projects themselves will remain in the program. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ id: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteRound.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* Details row */}
|
||||
<div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Voting Window</p>
|
||||
<div className="mt-0.5">{getVotingWindow()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reviewers</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteDialog}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -548,7 +620,8 @@ function RoundsListSkeleton() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="flex justify-between items-center py-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
@@ -560,6 +633,26 @@ function RoundsListSkeleton() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile/Tablet skeleton */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
<div className="ml-10 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
@@ -8,14 +6,12 @@ export default function PublicLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-background ${inter.className}`}>
|
||||
<div className="min-h-screen bg-background font-sans">
|
||||
{/* Simple header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-white">M</span>
|
||||
</div>
|
||||
<Logo variant="small" />
|
||||
<span className="font-semibold">Monaco Ocean Protection Challenge</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -73,15 +73,17 @@ export default function ProfileSettingsPage() {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Populate form when user data loads
|
||||
if (user && !profileLoaded) {
|
||||
setName(user.name || '')
|
||||
const meta = (user.metadataJson as Record<string, unknown>) || {}
|
||||
setBio((meta.bio as string) || '')
|
||||
setPhoneNumber(user.phoneNumber || '')
|
||||
setNotificationPreference(user.notificationPreference || 'EMAIL')
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
setProfileLoaded(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (user && !profileLoaded) {
|
||||
setName(user.name || '')
|
||||
const meta = (user.metadataJson as Record<string, unknown>) || {}
|
||||
setBio((meta.bio as string) || '')
|
||||
setPhoneNumber(user.phoneNumber || '')
|
||||
setNotificationPreference(user.notificationPreference || 'EMAIL')
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
setProfileLoaded(true)
|
||||
}
|
||||
}, [user, profileLoaded])
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
@@ -23,6 +24,15 @@ function validateNewPassword(password: string): string | null {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
|
||||
|
||||
@@ -50,6 +60,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only change their own email password
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only change your own email password.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
|
||||
|
||||
@@ -30,6 +40,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only check their own email credentials
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only verify your own email credentials.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
|
||||
@@ -26,7 +26,6 @@ export async function GET() {
|
||||
services: {
|
||||
database: 'disconnected',
|
||||
},
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
@@ -12,25 +13,32 @@ export default function Error({
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error)
|
||||
console.error('Application error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
|
||||
<AlertTriangle className="h-16 w-16 text-destructive/50" />
|
||||
<h1 className="text-2xl font-semibold">Something went wrong</h1>
|
||||
<p className="max-w-md text-muted-foreground">
|
||||
An unexpected error occurred. Please try again or contact support if the
|
||||
problem persists.
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<AlertTriangle className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-display font-bold text-brand-blue">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
An unexpected error occurred. Please try again or return to the
|
||||
dashboard.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground">Error ID: {error.digest}</p>
|
||||
<p className="mt-2 text-tiny text-muted-foreground/60">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Refresh Page
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button size="lg" onClick={() => reset()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
--secondary-foreground: 198 85% 18%;
|
||||
|
||||
--muted: 30 6% 96%;
|
||||
--muted-foreground: 30 8% 45%;
|
||||
--muted-foreground: 30 8% 38%;
|
||||
|
||||
/* Accent - MOPC Teal */
|
||||
--accent: 194 25% 44%;
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="light">
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileQuestion } from 'lucide-react'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
|
||||
<FileQuestion className="h-16 w-16 text-muted-foreground/50" />
|
||||
<h1 className="text-2xl font-semibold">Page Not Found</h1>
|
||||
<p className="text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-white">
|
||||
<div className="container-app flex h-16 items-center">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/images/MOPC-blue-long.png"
|
||||
alt="MOPC - Monaco Ocean Protection Challenge"
|
||||
width={140}
|
||||
height={45}
|
||||
className="h-10 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<p className="text-[8rem] font-bold leading-none tracking-tight text-brand-blue/10 sm:text-[12rem]">
|
||||
404
|
||||
</p>
|
||||
<h1 className="-mt-4 text-display font-bold text-brand-blue sm:-mt-8">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border bg-brand-blue py-6 text-white">
|
||||
<div className="container-app text-center">
|
||||
<p className="text-small">
|
||||
© {new Date().getFullYear()} Monaco Ocean Protection Challenge.
|
||||
All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user