monacousa-portal/src/lib/components/documents/DocumentPreviewModal.svelte

248 lines
7.1 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { X, Download, ZoomIn, ZoomOut, RotateCw, Maximize2, FileText, Image, File } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation 28 items across 7 batches. 36 files changed (9 new, 27 modified). 1061 insertions, 406 deletions. == Batch 1: Critical Security Fixes == 1.1 — Fix open redirect in /auth/callback - src/routes/auth/callback/+server.ts: url.searchParams.get('next') was used directly in redirect(303, next). Attacker could set next=https://evil.com. Now wrapped through sanitizeRedirectUrl() which rejects protocol/host, //, javascript: prefixes; falls back to /dashboard. 1.2 — Fix open redirect in /login - src/routes/(auth)/login/+page.server.ts: redirectTo param used without validation in both load() and form action. Applied sanitizeRedirectUrl() to both locations. 1.3 — Fix RLS self-role-escalation - supabase/migrations/017_fix_rls_role_escalation.sql (NEW): "Users can update own profile" policy had USING(auth.uid()=id) but no WITH CHECK clause — users could SET role='admin' on their own row. Added WITH CHECK constraining role to current value. - deploy/init.sql: updated to match migration 017. 1.4 — Remove hardcoded secrets from docker-compose.yml - docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback. == Batch 2: Critical & High Bugs == 2.1 — Fix deleteAvatar wrong argument type - src/routes/(app)/settings/+page.server.ts: was passing supabase client object as second arg to deleteAvatar(memberId, avatarPath). Changed to pass member.avatar_url instead. 2.2 — Fix event.start_time typo -> event.start_datetime - src/routes/(app)/board/events/[id]/attendees/+page.server.ts: referenced event.start_time (doesn't exist on type). Caused "Invalid Date" in invitation/roll-call emails. Replaced both occurrences with event.start_datetime. 2.3 — Fix landing page CTA buttons missing href - src/routes/+page.svelte: Sign In and Join Us buttons had no href attribute — completely non-functional for visitors. Added href="/login" and href="/join" respectively. 2.4 — Fix auth pages logo inconsistency - src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter in colored box replaced with actual Monaco USA logo image (MONACOUSA-Flags_376x376.png) matching login/layout. 2.5 — Fix currency USD -> EUR everywhere - src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale to fr-MC. - src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale to fr-MC. - src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale to fr-MC. == Batch 3: High Security Fixes == 3.1 — Sanitize HTML in email template rendering - src/lib/server/email.ts: added escapeHtml() utility that escapes &, <, >, ", '. Applied to all template variable values in sendTemplatedEmail() before substitution. URL-type keys (logo_url, site_url) exempted. Prevents XSS in emails. 3.2 — Add file upload MIME type validation - src/lib/server/storage.ts: added MAGIC_BYTES constant and validateFileMagicBytes() function checking PNG (89504E47), JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638) magic bytes against declared MIME. Applied in uploadAvatar and uploadDocument before storing. 3.3 — Docker container hardening - docker-compose.yml portal service: added security_opt [no-new-privileges:true], read_only: true with tmpfs for /tmp, deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile already had USER sveltekit (non-root). 3.4 — Restrict board endpoints data exposure - src/routes/(app)/board/members/+page.server.ts: replaced .select('*') with explicit column list returning only fields the board UI actually displays. Removed sensitive columns. == Batch 4: Shared Utilities == 4.1 — Extract getVisibleLevels to shared utility - src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role) returning appropriate visibility levels per role. - Replaced 4 duplicate definitions in: src/routes/(app)/dashboard/+page.server.ts src/routes/(app)/documents/+page.server.ts src/routes/(app)/events/+page.server.ts src/routes/(app)/events/[id]/+page.server.ts 4.3 — Fix N+1 query in getReminderEffectiveness - src/lib/server/dues.ts: rewrote loop executing individual DB queries per reminder into single batch query with IN filter. Maps results in JS instead of N+1 round-trips. == Batch 5: Shared UI Components == 5.1 — Create reusable EmptyState component - src/lib/components/ui/empty-state.svelte (NEW): accepts icon, title, description props and optional children snippet. Consistent muted-text centered layout matching design system. - Applied in DocumentPreviewModal and NotificationCenter. 5.2 — Move LoadingSpinner to shared ui/ - src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from auth/ to ui/ for general use. Original kept for compatibility. - src/lib/components/ui/index.ts: added barrel exports for EmptyState and LoadingSpinner. == Batch 6: UX Standardization == 6.4 — Add skip-to-content link - src/routes/(app)/+layout.svelte: added visually-hidden-until- focused skip link as first focusable element: <a href="#main-content" class="sr-only focus:not-sr-only ..."> Added id="main-content" to <main> element. 6.5 — Add navigation loading indicator - src/routes/(app)/+layout.svelte: imported SvelteKit $navigating store. Shows thin animated progress bar at page top during transitions. CSS-only animation, no external dependencies. == Batch 7: Code Consolidation == 7.1 — Consolidate profile/settings pages - src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate(). Supports admin mode (supabaseAdmin) and user mode (scoped client). - src/routes/(app)/profile/+page.server.ts: simplified from ~167 to ~88 lines using shared helpers. - src/routes/(app)/settings/+page.server.ts: simplified from ~219 to ~106 lines using shared helpers. 7.2 — Consolidate registration flows - src/lib/server/registration.ts (NEW, 201 lines): shared helpers createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail(). - src/routes/(auth)/signup/+page.server.ts: simplified from ~167 to ~85 lines using shared helpers. - src/routes/join/+page.server.ts: simplified from ~209 to ~117 lines using shared helpers. 7.3 — Create status badge utility - src/lib/utils/status-badges.ts (NEW, 55 lines): centralized STATUS_MAP for all status types (membership, dues, payment, RSVP, event, roles). Exports getStatusConfig(), getStatusBadgeClasses(), getStatusLabel(). 7.4 — Create rate limiting utility - src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory Map-based rate limiter with TTL cleanup. Exports checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit(). - Applied to login: 5 attempts per 15 min by email. - Applied to forgot-password: 3 attempts per 15 min by email. - src/routes/(auth)/login/+page.server.ts: added rate limit check before signInWithPassword, reset on success. - src/routes/(auth)/forgot-password/+page.server.ts: added rate limit check before resetPasswordForEmail. == New Files (9) == src/lib/server/auth-utils.ts src/lib/server/visibility.ts src/lib/server/member-profile.ts src/lib/server/registration.ts src/lib/server/rate-limit.ts src/lib/server/email.ts (escapeHtml addition) src/lib/server/storage.ts (validateFileMagicBytes addition) src/lib/utils/status-badges.ts src/lib/components/ui/empty-state.svelte src/lib/components/ui/LoadingSpinner.svelte supabase/migrations/017_fix_rls_role_escalation.sql Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
import { LoadingSpinner } from '$lib/components/ui';
interface Document {
id: string;
title: string;
file_name: string;
file_path: string;
mime_type: string;
file_size: number;
}
let { document, previewUrl, onClose }: {
document: Document;
previewUrl: string;
onClose: () => void;
} = $props();
let zoom = $state(100);
let rotation = $state(0);
let isLoading = $state(true);
let loadError = $state(false);
let textContent = $state<string | null>(null);
const isPdf = $derived(document.mime_type === 'application/pdf');
const isImage = $derived(document.mime_type.startsWith('image/'));
const isText = $derived(
document.mime_type.startsWith('text/') ||
['application/json', 'application/javascript', 'text/csv'].includes(document.mime_type)
);
const isOffice = $derived(
document.mime_type.includes('word') ||
document.mime_type.includes('excel') ||
document.mime_type.includes('spreadsheet') ||
document.mime_type.includes('powerpoint') ||
document.mime_type.includes('presentation')
);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
function handleZoomIn() {
zoom = Math.min(zoom + 25, 300);
}
function handleZoomOut() {
zoom = Math.max(zoom - 25, 25);
}
function handleRotate() {
rotation = (rotation + 90) % 360;
}
function handleDownload() {
const link = document.createElement('a');
link.href = previewUrl;
link.download = document.file_name;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Load text content for text files
$effect(() => {
if (isText && previewUrl) {
fetch(previewUrl)
.then(res => res.text())
.then(text => {
textContent = text;
isLoading = false;
})
.catch(() => {
loadError = true;
isLoading = false;
});
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Modal Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="preview-title"
>
<!-- Modal Container -->
<div class="relative flex h-full w-full max-w-6xl flex-col rounded-2xl bg-slate-900/95 shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-700/50 px-6 py-4">
<div class="flex items-center gap-3">
{#if isPdf}
<FileText class="h-6 w-6 text-red-400" />
{:else if isImage}
<Image class="h-6 w-6 text-blue-400" />
{:else}
<File class="h-6 w-6 text-slate-400" />
{/if}
<div>
<h2 id="preview-title" class="text-lg font-semibold text-white">{document.title}</h2>
<p class="text-sm text-slate-400">{document.file_name} · {formatFileSize(document.file_size)}</p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Zoom Controls (for images) -->
{#if isImage}
<div class="flex items-center gap-1 rounded-lg bg-slate-800 px-2 py-1">
<button
onclick={handleZoomOut}
class="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white"
aria-label="Zoom out"
>
<ZoomOut class="h-4 w-4" />
</button>
<span class="min-w-[3rem] text-center text-sm text-slate-300">{zoom}%</span>
<button
onclick={handleZoomIn}
class="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white"
aria-label="Zoom in"
>
<ZoomIn class="h-4 w-4" />
</button>
<button
onclick={handleRotate}
class="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white"
aria-label="Rotate"
>
<RotateCw class="h-4 w-4" />
</button>
</div>
{/if}
<!-- Download Button -->
<Button
variant="outline"
size="sm"
onclick={handleDownload}
class="border-slate-600 bg-slate-800 text-white hover:bg-slate-700"
>
<Download class="mr-2 h-4 w-4" />
Download
</Button>
<!-- Close Button -->
<button
onclick={onClose}
class="rounded-lg p-2 text-slate-400 hover:bg-slate-800 hover:text-white"
aria-label="Close preview"
>
<X class="h-6 w-6" />
</button>
</div>
</div>
<!-- Content Area -->
<div class="relative flex-1 overflow-auto p-4">
{#if isLoading && !isImage && !isPdf}
<div class="flex h-full items-center justify-center">
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation 28 items across 7 batches. 36 files changed (9 new, 27 modified). 1061 insertions, 406 deletions. == Batch 1: Critical Security Fixes == 1.1 — Fix open redirect in /auth/callback - src/routes/auth/callback/+server.ts: url.searchParams.get('next') was used directly in redirect(303, next). Attacker could set next=https://evil.com. Now wrapped through sanitizeRedirectUrl() which rejects protocol/host, //, javascript: prefixes; falls back to /dashboard. 1.2 — Fix open redirect in /login - src/routes/(auth)/login/+page.server.ts: redirectTo param used without validation in both load() and form action. Applied sanitizeRedirectUrl() to both locations. 1.3 — Fix RLS self-role-escalation - supabase/migrations/017_fix_rls_role_escalation.sql (NEW): "Users can update own profile" policy had USING(auth.uid()=id) but no WITH CHECK clause — users could SET role='admin' on their own row. Added WITH CHECK constraining role to current value. - deploy/init.sql: updated to match migration 017. 1.4 — Remove hardcoded secrets from docker-compose.yml - docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback. == Batch 2: Critical & High Bugs == 2.1 — Fix deleteAvatar wrong argument type - src/routes/(app)/settings/+page.server.ts: was passing supabase client object as second arg to deleteAvatar(memberId, avatarPath). Changed to pass member.avatar_url instead. 2.2 — Fix event.start_time typo -> event.start_datetime - src/routes/(app)/board/events/[id]/attendees/+page.server.ts: referenced event.start_time (doesn't exist on type). Caused "Invalid Date" in invitation/roll-call emails. Replaced both occurrences with event.start_datetime. 2.3 — Fix landing page CTA buttons missing href - src/routes/+page.svelte: Sign In and Join Us buttons had no href attribute — completely non-functional for visitors. Added href="/login" and href="/join" respectively. 2.4 — Fix auth pages logo inconsistency - src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter in colored box replaced with actual Monaco USA logo image (MONACOUSA-Flags_376x376.png) matching login/layout. 2.5 — Fix currency USD -> EUR everywhere - src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale to fr-MC. - src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale to fr-MC. - src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale to fr-MC. == Batch 3: High Security Fixes == 3.1 — Sanitize HTML in email template rendering - src/lib/server/email.ts: added escapeHtml() utility that escapes &, <, >, ", '. Applied to all template variable values in sendTemplatedEmail() before substitution. URL-type keys (logo_url, site_url) exempted. Prevents XSS in emails. 3.2 — Add file upload MIME type validation - src/lib/server/storage.ts: added MAGIC_BYTES constant and validateFileMagicBytes() function checking PNG (89504E47), JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638) magic bytes against declared MIME. Applied in uploadAvatar and uploadDocument before storing. 3.3 — Docker container hardening - docker-compose.yml portal service: added security_opt [no-new-privileges:true], read_only: true with tmpfs for /tmp, deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile already had USER sveltekit (non-root). 3.4 — Restrict board endpoints data exposure - src/routes/(app)/board/members/+page.server.ts: replaced .select('*') with explicit column list returning only fields the board UI actually displays. Removed sensitive columns. == Batch 4: Shared Utilities == 4.1 — Extract getVisibleLevels to shared utility - src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role) returning appropriate visibility levels per role. - Replaced 4 duplicate definitions in: src/routes/(app)/dashboard/+page.server.ts src/routes/(app)/documents/+page.server.ts src/routes/(app)/events/+page.server.ts src/routes/(app)/events/[id]/+page.server.ts 4.3 — Fix N+1 query in getReminderEffectiveness - src/lib/server/dues.ts: rewrote loop executing individual DB queries per reminder into single batch query with IN filter. Maps results in JS instead of N+1 round-trips. == Batch 5: Shared UI Components == 5.1 — Create reusable EmptyState component - src/lib/components/ui/empty-state.svelte (NEW): accepts icon, title, description props and optional children snippet. Consistent muted-text centered layout matching design system. - Applied in DocumentPreviewModal and NotificationCenter. 5.2 — Move LoadingSpinner to shared ui/ - src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from auth/ to ui/ for general use. Original kept for compatibility. - src/lib/components/ui/index.ts: added barrel exports for EmptyState and LoadingSpinner. == Batch 6: UX Standardization == 6.4 — Add skip-to-content link - src/routes/(app)/+layout.svelte: added visually-hidden-until- focused skip link as first focusable element: <a href="#main-content" class="sr-only focus:not-sr-only ..."> Added id="main-content" to <main> element. 6.5 — Add navigation loading indicator - src/routes/(app)/+layout.svelte: imported SvelteKit $navigating store. Shows thin animated progress bar at page top during transitions. CSS-only animation, no external dependencies. == Batch 7: Code Consolidation == 7.1 — Consolidate profile/settings pages - src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate(). Supports admin mode (supabaseAdmin) and user mode (scoped client). - src/routes/(app)/profile/+page.server.ts: simplified from ~167 to ~88 lines using shared helpers. - src/routes/(app)/settings/+page.server.ts: simplified from ~219 to ~106 lines using shared helpers. 7.2 — Consolidate registration flows - src/lib/server/registration.ts (NEW, 201 lines): shared helpers createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail(). - src/routes/(auth)/signup/+page.server.ts: simplified from ~167 to ~85 lines using shared helpers. - src/routes/join/+page.server.ts: simplified from ~209 to ~117 lines using shared helpers. 7.3 — Create status badge utility - src/lib/utils/status-badges.ts (NEW, 55 lines): centralized STATUS_MAP for all status types (membership, dues, payment, RSVP, event, roles). Exports getStatusConfig(), getStatusBadgeClasses(), getStatusLabel(). 7.4 — Create rate limiting utility - src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory Map-based rate limiter with TTL cleanup. Exports checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit(). - Applied to login: 5 attempts per 15 min by email. - Applied to forgot-password: 3 attempts per 15 min by email. - src/routes/(auth)/login/+page.server.ts: added rate limit check before signInWithPassword, reset on success. - src/routes/(auth)/forgot-password/+page.server.ts: added rate limit check before resetPasswordForEmail. == New Files (9) == src/lib/server/auth-utils.ts src/lib/server/visibility.ts src/lib/server/member-profile.ts src/lib/server/registration.ts src/lib/server/rate-limit.ts src/lib/server/email.ts (escapeHtml addition) src/lib/server/storage.ts (validateFileMagicBytes addition) src/lib/utils/status-badges.ts src/lib/components/ui/empty-state.svelte src/lib/components/ui/LoadingSpinner.svelte supabase/migrations/017_fix_rls_role_escalation.sql Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
<LoadingSpinner size="lg" class="text-monaco-600" />
</div>
{:else if loadError}
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
<File class="h-16 w-16" />
<p>Unable to load preview</p>
<Button
variant="outline"
onclick={handleDownload}
class="border-slate-600 bg-slate-800 text-white hover:bg-slate-700"
>
<Download class="mr-2 h-4 w-4" />
Download File
</Button>
</div>
{:else if isPdf}
<!-- PDF Preview -->
<iframe
src={previewUrl}
class="h-full w-full rounded-lg bg-white"
title={document.title}
onload={() => isLoading = false}
onerror={() => { loadError = true; isLoading = false; }}
></iframe>
{:else if isImage}
<!-- Image Preview -->
<div class="flex h-full items-center justify-center overflow-auto">
<img
src={previewUrl}
alt={document.title}
class="max-h-full max-w-full object-contain transition-transform duration-200"
style="transform: scale({zoom / 100}) rotate({rotation}deg);"
onload={() => isLoading = false}
onerror={() => { loadError = true; isLoading = false; }}
/>
</div>
{:else if isText && textContent !== null}
<!-- Text Preview -->
<div class="h-full overflow-auto rounded-lg bg-slate-950 p-4">
<pre class="whitespace-pre-wrap font-mono text-sm text-slate-300">{textContent}</pre>
</div>
{:else if isOffice}
<!-- Office Documents - Offer Download -->
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
<File class="h-16 w-16" />
<p class="text-lg">Office documents cannot be previewed directly</p>
<p class="text-sm">Download the file to view it in Microsoft Office or compatible application</p>
<Button
variant="monaco"
onclick={handleDownload}
>
<Download class="mr-2 h-4 w-4" />
Download {document.file_name}
</Button>
</div>
{:else}
<!-- Unsupported file type -->
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
<File class="h-16 w-16" />
<p class="text-lg">Preview not available for this file type</p>
<Button
variant="monaco"
onclick={handleDownload}
>
<Download class="mr-2 h-4 w-4" />
Download File
</Button>
</div>
{/if}
</div>
</div>
</div>