2026-01-25 02:19:49 +01:00
|
|
|
<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';
|
2026-01-25 02:19:49 +01:00
|
|
|
|
|
|
|
|
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" />
|
2026-01-25 02:19:49 +01:00
|
|
|
</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>
|