From 5ff9f950a11dfce081e82cb7aefc17b6104e9358 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Feb 2026 18:03:46 +0100 Subject: [PATCH] Implement complete feature & security overhaul (21 items, 3 phases) Phase 1 - Security & Data Integrity: - Atomic member ID generation via PostgreSQL sequence (018) - Rate limiting on signup, input sanitization (XSS prevention) - Onboarding photo upload, document upload validation (magic bytes, MIME, size) - RLS fix for admin role assignment without self-escalation (019) - Email notification preferences enforcement - Audit logging across all admin/board mutation actions - CSV export for membership, payments, and events reports - Member approval workflow with email notifications (020) Phase 2 - Functionality & Monitoring: - Directory privacy settings (022) with board-level filtering - Document full-text search with PostgreSQL tsvector/GIN index (023) - Cron job monitoring dashboard with manual trigger (024) - Settings audit log tab - Bulk email broadcast with recipient filtering and personalization (025) Phase 3 - Feature Completeness: - Event type filtering on events page - RSVP deadline control for event organizers (021) Also includes Kong CORS configuration fix. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 24 +- src/lib/components/layout/Sidebar.svelte | 8 +- src/lib/server/csv.ts | 40 +++ src/lib/server/dues.ts | 59 +++- src/lib/server/email.ts | 14 +- src/lib/server/event-reminders.ts | 17 +- src/lib/server/member-profile.ts | 19 +- src/lib/server/notification-preferences.ts | 99 +++++++ src/lib/server/registration.ts | 30 +- src/lib/server/sanitize.ts | 61 ++++ src/lib/server/storage.ts | 65 +++- src/lib/types/database.ts | 3 + .../(app)/admin/bulk-email/+page.server.ts | 170 +++++++++++ .../(app)/admin/bulk-email/+page.svelte | 236 +++++++++++++++ .../admin/cron-monitoring/+page.server.ts | 143 +++++++++ .../(app)/admin/cron-monitoring/+page.svelte | 241 +++++++++++++++ .../(app)/admin/members/+page.server.ts | 30 ++ .../(app)/admin/notifications/+page.server.ts | 5 +- .../(app)/admin/settings/+page.server.ts | 13 +- src/routes/(app)/admin/settings/+page.svelte | 85 +++++- .../(app)/board/approvals/+page.server.ts | 161 ++++++++++ src/routes/(app)/board/approvals/+page.svelte | 222 ++++++++++++++ .../(app)/board/documents/+page.server.ts | 17 ++ .../board/events/[id]/edit/+page.server.ts | 26 +- .../(app)/board/events/[id]/edit/+page.svelte | 59 +++- .../(app)/board/members/+page.server.ts | 23 +- .../(app)/board/reports/+page.server.ts | 14 +- src/routes/(app)/board/reports/+page.svelte | 40 +-- .../board/reports/export/[type]/+server.ts | 139 +++++++++ src/routes/(app)/documents/+page.server.ts | 19 +- src/routes/(app)/documents/+page.svelte | 35 ++- src/routes/(app)/events/+page.server.ts | 23 +- src/routes/(app)/events/+page.svelte | 76 ++++- src/routes/(app)/events/[id]/+page.server.ts | 37 ++- src/routes/(app)/events/[id]/+page.svelte | 34 +++ src/routes/(app)/settings/+page.server.ts | 42 +++ src/routes/(app)/settings/+page.svelte | 95 ++++++ src/routes/join/+page.server.ts | 39 ++- supabase/docker/kong.yml | 280 ++++++++++++++++++ .../018_atomic_member_id_generation.sql | 66 +++++ .../019_fix_admin_role_assignment.sql | 42 +++ .../020_approval_email_templates.sql | 33 +++ supabase/migrations/021_rsvp_deadlines.sql | 9 + supabase/migrations/022_directory_privacy.sql | 14 + supabase/migrations/023_document_search.sql | 38 +++ .../migrations/024_cron_execution_logs.sql | 45 +++ supabase/migrations/025_bulk_emails.sql | 44 +++ 47 files changed, 2857 insertions(+), 177 deletions(-) create mode 100644 src/lib/server/csv.ts create mode 100644 src/lib/server/notification-preferences.ts create mode 100644 src/lib/server/sanitize.ts create mode 100644 src/routes/(app)/admin/bulk-email/+page.server.ts create mode 100644 src/routes/(app)/admin/bulk-email/+page.svelte create mode 100644 src/routes/(app)/admin/cron-monitoring/+page.server.ts create mode 100644 src/routes/(app)/admin/cron-monitoring/+page.svelte create mode 100644 src/routes/(app)/board/approvals/+page.server.ts create mode 100644 src/routes/(app)/board/approvals/+page.svelte create mode 100644 src/routes/(app)/board/reports/export/[type]/+server.ts create mode 100644 supabase/migrations/018_atomic_member_id_generation.sql create mode 100644 supabase/migrations/019_fix_admin_role_assignment.sql create mode 100644 supabase/migrations/020_approval_email_templates.sql create mode 100644 supabase/migrations/021_rsvp_deadlines.sql create mode 100644 supabase/migrations/022_directory_privacy.sql create mode 100644 supabase/migrations/023_document_search.sql create mode 100644 supabase/migrations/024_cron_execution_logs.sql create mode 100644 supabase/migrations/025_bulk_emails.sql diff --git a/package-lock.json b/package-lock.json index 4146dc5..4b4d780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,29 +8,31 @@ "name": "monacousa-portal-2026", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-s3": "^3.700.0", - "@aws-sdk/s3-request-presigner": "^3.700.0", + "@aws-sdk/client-s3": "^3.971.0", + "@aws-sdk/s3-request-presigner": "^3.971.0", + "@internationalized/date": "^3.7.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.90.1", "@sveltejs/adapter-node": "^5.5.1", "flag-icons": "^7.4.0", - "nodemailer": "^6.9.0" + "libphonenumber-js": "^1.12.8", + "nodemailer": "^6.10.0" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", - "@sveltejs/kit": "^2.49.1", + "@sveltejs/kit": "^2.50.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.18", "bits-ui": "^2.15.4", "clsx": "^2.1.1", "lucide-svelte": "^0.562.0", - "svelte": "^5.45.6", + "svelte": "^5.47.0", "svelte-check": "^4.3.4", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.3.1" } }, "node_modules/@aws-crypto/crc32": { @@ -1514,9 +1516,7 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", - "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2945,9 +2945,7 @@ "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", - "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -3630,6 +3628,12 @@ "node": ">=6" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.36", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", + "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index ff62bcd..375f588 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -15,7 +15,10 @@ FolderOpen, DollarSign, CalendarPlus, - Bell + Bell, + UserCheck, + Timer, + Send } from 'lucide-svelte'; import type { MemberWithDues } from '$lib/types/database'; @@ -49,6 +52,7 @@ const boardNav: NavItem[] = [ { href: '/board/members', label: 'Members', icon: Users }, + { href: '/board/approvals', label: 'Approvals', icon: UserCheck }, { href: '/board/dues', label: 'Dues Management', icon: DollarSign }, { href: '/board/events', label: 'Manage Events', icon: CalendarPlus }, { href: '/board/documents', label: 'Documents', icon: FileText } @@ -57,6 +61,8 @@ const adminNav: NavItem[] = [ { href: '/admin/members', label: 'User Management', icon: Shield }, { href: '/admin/notifications', label: 'Notifications', icon: Bell }, + { href: '/admin/bulk-email', label: 'Bulk Email', icon: Send }, + { href: '/admin/cron-monitoring', label: 'Cron Jobs', icon: Timer }, { href: '/admin/settings', label: 'Settings', icon: Settings } ]; diff --git a/src/lib/server/csv.ts b/src/lib/server/csv.ts new file mode 100644 index 0000000..7c02b5e --- /dev/null +++ b/src/lib/server/csv.ts @@ -0,0 +1,40 @@ +/** + * CSV generation utilities for board reports. + */ + +/** + * Escape a CSV field value. + * Wraps in quotes if the value contains commas, quotes, or newlines. + */ +export function escapeCSVField(value: unknown): string { + if (value === null || value === undefined) return ''; + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; +} + +/** + * Convert an array of objects to CSV string. + */ +export function arrayToCSV( + data: Record[], + columns: { key: string; header: string }[] +): string { + const header = columns.map((c) => escapeCSVField(c.header)).join(','); + const rows = data.map((row) => columns.map((c) => escapeCSVField(row[c.key])).join(',')); + return '\ufeff' + header + '\n' + rows.join('\n'); +} + +/** + * Create a CSV Response with proper headers for browser download. + */ +export function csvResponse(csvContent: string, filename: string): Response { + return new Response(csvContent, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); +} diff --git a/src/lib/server/dues.ts b/src/lib/server/dues.ts index 6fedd1f..090bbc7 100644 --- a/src/lib/server/dues.ts +++ b/src/lib/server/dues.ts @@ -5,6 +5,8 @@ import { supabaseAdmin } from './supabase'; import { sendTemplatedEmail } from './email'; +import { sendWithPreferenceCheck } from './notification-preferences'; +import { logAudit } from './audit'; import type { MemberWithDues } from '$lib/types/database'; // ============================================ @@ -245,15 +247,17 @@ export async function sendDuesReminder( portal_url: `${baseUrl}/payments` }; - // Send email - const result = await sendTemplatedEmail(templateKey, member.email, variables, { - recipientId: member.id, - recipientName: `${member.first_name} ${member.last_name}`, - baseUrl - }); + // Send email (respecting notification preferences) + const result = await sendWithPreferenceCheck(member.id, 'dues_reminder', () => + sendTemplatedEmail(templateKey, member.email, variables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + baseUrl + }) + ); if (!result.success) { - return { success: false, error: result.error }; + return { success: false, error: 'error' in result ? result.error : undefined }; } // Log the reminder @@ -321,6 +325,19 @@ export async function sendBulkReminders( } } + if (result.sent > 0) { + logAudit({ + action: 'email.send', + resourceType: 'dues_reminder', + details: { + reminder_type: reminderType, + total_sent: result.sent, + total_errors: result.errors.length, + bulk_operation: true + } + }); + } + return result; } @@ -384,6 +401,18 @@ export async function processGracePeriodExpirations( }); } + if (processed.length > 0) { + logAudit({ + action: 'member.status_change', + resourceType: 'member', + details: { + operation: 'grace_period_expiration', + members_inactivated: processed.length, + member_ids: processed.map(m => m.id) + } + }); + } + return { processed: processed.length, members: processed }; } @@ -788,15 +817,17 @@ export async function sendOnboardingReminder( portal_url: `${baseUrl}/payments` }; - // Send email - const result = await sendTemplatedEmail(reminderType, member.email, variables, { - recipientId: member.id, - recipientName: `${member.first_name} ${member.last_name}`, - baseUrl - }); + // Send email (respecting notification preferences) + const result = await sendWithPreferenceCheck(member.id, 'dues_reminder', () => + sendTemplatedEmail(reminderType, member.email, variables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + baseUrl + }) + ); if (!result.success) { - return { success: false, error: result.error }; + return { success: false, error: 'error' in result ? result.error : undefined }; } // Log the reminder diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index f04ffe3..2ee1b54 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -1,6 +1,7 @@ import nodemailer from 'nodemailer'; import type { Transporter } from 'nodemailer'; import { supabaseAdmin } from './supabase'; +import { escapeHtml } from './sanitize'; export interface SmtpConfig { host: string; @@ -405,19 +406,6 @@ export function wrapInMonacoTemplate(options: { `; } -/** - * Escape HTML special characters to prevent XSS in email templates. - * Used to sanitize user-provided content before template substitution. - */ -export function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - /** * Strip HTML tags from a string to create plain text version */ diff --git a/src/lib/server/event-reminders.ts b/src/lib/server/event-reminders.ts index 33fe494..faf582b 100644 --- a/src/lib/server/event-reminders.ts +++ b/src/lib/server/event-reminders.ts @@ -5,6 +5,7 @@ import { supabaseAdmin } from './supabase'; import { sendTemplatedEmail } from './email'; +import { sendWithPreferenceCheck } from './notification-preferences'; // ============================================ // TYPES @@ -212,15 +213,17 @@ export async function sendEventReminder( portal_url: `${baseUrl}/events/${reminder.event_id}` }; - // Send email - const result = await sendTemplatedEmail('event_reminder_24hr', reminder.email, variables, { - recipientId: reminder.member_id, - recipientName: `${reminder.first_name} ${reminder.last_name}`, - baseUrl - }); + // Send email (respecting notification preferences) + const result = await sendWithPreferenceCheck(reminder.member_id, 'event_reminder', () => + sendTemplatedEmail('event_reminder_24hr', reminder.email, variables, { + recipientId: reminder.member_id, + recipientName: `${reminder.first_name} ${reminder.last_name}`, + baseUrl + }) + ); if (!result.success) { - return { success: false, error: result.error }; + return { success: false, error: 'error' in result ? result.error : undefined }; } // Log the reminder diff --git a/src/lib/server/member-profile.ts b/src/lib/server/member-profile.ts index c6d7fda..c311669 100644 --- a/src/lib/server/member-profile.ts +++ b/src/lib/server/member-profile.ts @@ -6,6 +6,7 @@ import { uploadAvatar, deleteAvatar, isS3Enabled } from '$lib/server/storage'; import { supabaseAdmin } from '$lib/server/supabase'; import type { SupabaseClient } from '@supabase/supabase-js'; +import { sanitizeText } from '$lib/server/sanitize'; // ──────────────────────────────────────────────────────────────── // Types @@ -247,21 +248,27 @@ export async function handleProfileUpdate( } } + // Sanitize text fields before storage + const firstName = sanitizeText(data.first_name, 100); + const lastName = sanitizeText(data.last_name, 100); + const phone = data.phone ? sanitizeText(data.phone, 50) : data.phone; + const address = data.address ? sanitizeText(data.address, 500) : data.address; + // Build the update payload const updatePayload: Record = { - first_name: data.first_name, - last_name: data.last_name, + first_name: firstName, + last_name: lastName, nationality: data.nationality || [], updated_at: new Date().toISOString() }; // Only include optional fields if provided or if they are required if (requireAllFields) { - updatePayload.phone = data.phone; - updatePayload.address = data.address; + updatePayload.phone = phone; + updatePayload.address = address; } else { - updatePayload.phone = data.phone || null; - updatePayload.address = data.address || null; + updatePayload.phone = phone || null; + updatePayload.address = address || null; } const client = useAdmin ? supabaseAdmin : options?.supabase; diff --git a/src/lib/server/notification-preferences.ts b/src/lib/server/notification-preferences.ts new file mode 100644 index 0000000..33be8a1 --- /dev/null +++ b/src/lib/server/notification-preferences.ts @@ -0,0 +1,99 @@ +import { supabaseAdmin } from './supabase'; + +/** + * Notification types that map to user_notification_preferences columns. + * The column names follow the pattern: email_{notification_type} + */ +export type NotificationType = + | 'event_rsvp_confirmation' + | 'event_reminder' + | 'event_updates' + | 'waitlist_promotion' + | 'dues_reminder' + | 'payment_confirmation' + | 'membership_updates' + | 'announcements' + | 'newsletter'; + +/** Map from notification type to the corresponding column in user_notification_preferences */ +const COLUMN_MAP: Record = { + event_rsvp_confirmation: 'email_event_rsvp_confirmation', + event_reminder: 'email_event_reminder', + event_updates: 'email_event_updates', + waitlist_promotion: 'email_waitlist_promotion', + dues_reminder: 'email_dues_reminder', + payment_confirmation: 'email_payment_confirmation', + membership_updates: 'email_membership_updates', + announcements: 'email_announcements', + newsletter: 'email_newsletter' +}; + +/** Critical notification types that are ALWAYS sent regardless of preferences */ +const CRITICAL_TYPES = new Set([ + 'password_reset', + 'account_verification', + 'member_approved', + 'member_rejected', + 'welcome' +]); + +/** + * Check if a member has email notifications enabled for a given type. + * Returns true if enabled or if no preference record exists (opt-out model). + * Always returns true for critical notification types. + */ +export async function hasEmailNotificationEnabled( + memberId: string, + notificationType: string +): Promise { + // Critical notifications always go through + if (CRITICAL_TYPES.has(notificationType)) { + return true; + } + + // Look up the column name for this notification type + const column = COLUMN_MAP[notificationType as NotificationType]; + if (!column) { + // Unknown type - default to sending + return true; + } + + try { + const { data } = await supabaseAdmin + .from('user_notification_preferences') + .select(column) + .eq('member_id', memberId) + .single(); + + // If no preference exists, default to enabled (opt-out model) + if (!data) return true; + + return (data as Record)[column] !== false; + } catch { + // On error, default to sending (fail open for notifications) + return true; + } +} + +/** + * Send email only if user's notification preferences allow it. + * Wraps any email sending function with a preference check. + * + * @param memberId - The member to check preferences for + * @param notificationType - The type of notification + * @param sendFn - The function to call to send the email + * @returns The result of sendFn, or { success: true, skipped: true } if preferences disabled + */ +export async function sendWithPreferenceCheck( + memberId: string, + notificationType: string, + sendFn: () => Promise +): Promise { + const enabled = await hasEmailNotificationEnabled(memberId, notificationType); + + if (!enabled) { + return { success: true, skipped: true }; + } + + return sendFn(); +} diff --git a/src/lib/server/registration.ts b/src/lib/server/registration.ts index 0d0dac6..cb2a483 100644 --- a/src/lib/server/registration.ts +++ b/src/lib/server/registration.ts @@ -37,7 +37,7 @@ export interface CreateMemberResult { * * Handles: * - Looking up the default/pending membership status and type - * - Generating a sequential MUSA-YYYY-XXXX member ID + * - Member ID auto-generated by database trigger (atomic sequence) * - Inserting the member record * * @param data Core registration data. @@ -50,11 +50,10 @@ export async function createMemberRecord( options?: { /** Look up status by name instead of is_default. Defaults to undefined (uses is_default). */ statusName?: string; - /** Whether to generate a MUSA-YYYY-XXXX member ID. Defaults to true. */ + /** @deprecated Member ID now auto-generated by database trigger. */ generateMemberId?: boolean; } ): Promise { - const generateMemberId = options?.generateMemberId ?? true; const statusName = options?.statusName; // Look up the membership status @@ -92,17 +91,8 @@ export async function createMemberRecord( return { success: false, error: 'System configuration error. Please contact support.' }; } - // Generate member ID if requested - let memberId: string | undefined; - if (generateMemberId) { - const year = new Date().getFullYear(); - const { count } = await supabase - .from('members') - .select('*', { count: 'exact', head: true }); - - const memberNumber = String((count || 0) + 1).padStart(4, '0'); - memberId = `MUSA-${year}-${memberNumber}`; - } + // Member ID will be auto-generated by database trigger (generate_member_id) + // See migration 018_atomic_member_id_generation.sql // Create the member profile const insertPayload: Record = { @@ -119,18 +109,20 @@ export async function createMemberRecord( membership_type_id: typeData.id }; - if (memberId) { - insertPayload.member_id = memberId; - } + // member_id is auto-generated by trigger, no need to set it - const { error: memberError } = await supabase.from('members').insert(insertPayload); + const { error: memberError, data: insertedMember } = await supabase + .from('members') + .insert(insertPayload) + .select('member_id') + .single(); if (memberError) { console.error('Failed to create member profile:', memberError); return { success: false, error: 'Failed to create member profile. Please try again or contact support.' }; } - return { success: true, memberId }; + return { success: true, memberId: insertedMember?.member_id }; } /** diff --git a/src/lib/server/sanitize.ts b/src/lib/server/sanitize.ts new file mode 100644 index 0000000..f4c4918 --- /dev/null +++ b/src/lib/server/sanitize.ts @@ -0,0 +1,61 @@ +/** + * Centralized input sanitization utilities for preventing XSS attacks. + * Used at server-side input boundaries before storing or rendering user content. + */ + +/** + * Escape HTML special characters to prevent XSS. + * Use for plain text fields that should never contain HTML. + */ +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Sanitize rich text by stripping dangerous HTML tags while preserving safe formatting. + * Allows: p, br, strong, em, ul, ol, li, h1-h6, a (with href only), blockquote, code, pre + * Strips: script, style, iframe, object, embed, form, input, on* attributes + */ +export function sanitizeRichText(html: string): string { + // Remove script tags and their contents + let cleaned = html.replace(/)<[^<]*)*<\/script>/gi, ''); + // Remove style tags and their contents + cleaned = cleaned.replace(/)<[^<]*)*<\/style>/gi, ''); + // Remove iframe, object, embed, form, input tags + cleaned = cleaned.replace(/<\/?(?:iframe|object|embed|form|input|textarea|select|button)\b[^>]*>/gi, ''); + // Remove all on* event handlers (onclick, onerror, onload, etc.) + cleaned = cleaned.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + // Remove javascript: protocol from href/src attributes + cleaned = cleaned.replace(/(href|src|action)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '$1=""'); + // Remove data: protocol (except data:image for inline images) + cleaned = cleaned.replace(/(href|src|action)\s*=\s*(?:"data:(?!image)[^"]*"|'data:(?!image)[^']*')/gi, '$1=""'); + return cleaned; +} + +/** + * Sanitize a URL to prevent javascript: and data: protocol injection. + * Returns the URL if safe, or empty string if potentially malicious. + */ +export function sanitizeUrl(url: string): string { + const trimmed = url.trim(); + // Block javascript: protocol (case-insensitive, handles whitespace/encoding tricks) + if (/^\s*javascript\s*:/i.test(trimmed)) return ''; + // Block data: protocol (except data:image) + if (/^\s*data\s*:(?!image\/)/i.test(trimmed)) return ''; + // Block vbscript: protocol + if (/^\s*vbscript\s*:/i.test(trimmed)) return ''; + return trimmed; +} + +/** + * Sanitize a plain text string for safe storage. + * Trims whitespace and limits length. + */ +export function sanitizeText(str: string, maxLength: number = 10000): string { + return str.trim().substring(0, maxLength); +} diff --git a/src/lib/server/storage.ts b/src/lib/server/storage.ts index 487f957..dde11b6 100644 --- a/src/lib/server/storage.ts +++ b/src/lib/server/storage.ts @@ -28,6 +28,62 @@ const MAGIC_BYTES: Record = { ] }; +/** + * MIME type to expected file extensions mapping. + * Used to validate that file content matches the declared extension. + */ +const MIME_TO_EXTENSIONS: Record = { + 'application/pdf': ['pdf'], + 'application/msword': ['doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'], + 'application/vnd.ms-excel': ['xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'], + 'application/vnd.ms-powerpoint': ['ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'], + 'text/plain': ['txt', 'text', 'log'], + 'text/csv': ['csv'], + 'application/json': ['json'], + 'image/jpeg': ['jpg', 'jpeg'], + 'image/png': ['png'], + 'image/webp': ['webp'], + 'image/gif': ['gif'] +}; + +/** + * Sanitize a filename to prevent path traversal and other attacks. + * Removes directory separators, null bytes, and other dangerous characters. + */ +export function sanitizeFilename(filename: string): string { + return filename + // Remove null bytes + .replace(/\0/g, '') + // Remove directory separators + .replace(/[\/\\]/g, '_') + // Remove leading dots (hidden files / directory traversal) + .replace(/^\.+/, '') + // Replace non-safe characters + .replace(/[^a-zA-Z0-9._-]/g, '_') + // Collapse multiple underscores + .replace(/_+/g, '_') + // Limit length + .substring(0, 255) + // Ensure not empty + || 'unnamed_file'; +} + +/** + * Validate that file extension matches the declared MIME type. + */ +export function validateExtensionMatchesMime(filename: string, mimeType: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase(); + if (!ext) return false; + + const allowedExtensions = MIME_TO_EXTENSIONS[mimeType]; + if (!allowedExtensions) return true; // Unknown MIME type - allow + + return allowedExtensions.includes(ext); +} + /** * Validate that a file's magic bytes match the declared MIME type. * Returns true if the magic bytes match, or if the type has no known magic byte signature @@ -594,7 +650,7 @@ export async function listFiles( export function generateUniqueFilename(originalName: string): string { const timestamp = Date.now(); const randomStr = Math.random().toString(36).substring(2, 8); - const safeName = originalName.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50); + const safeName = sanitizeFilename(originalName).substring(0, 50); const ext = safeName.split('.').pop() || ''; const nameWithoutExt = safeName.replace(`.${ext}`, ''); return `${timestamp}-${randomStr}-${nameWithoutExt}.${ext}`; @@ -792,6 +848,11 @@ export async function uploadDocument( }; } + // Validate file extension matches declared MIME type + if (!validateExtensionMatchesMime(file.name, file.type)) { + return { success: false, error: 'File extension does not match file type. Please ensure the file has the correct extension.' }; + } + // Validate file size (max 50MB) const maxSize = 50 * 1024 * 1024; if (file.size > maxSize) { @@ -801,7 +862,7 @@ export async function uploadDocument( // Generate unique storage path const timestamp = Date.now(); const randomStr = Math.random().toString(36).substring(2, 8); - const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50); + const safeName = sanitizeFilename(file.name).substring(0, 50); const path = options?.folder ? `${options.folder}/${timestamp}-${randomStr}-${safeName}` : `${timestamp}-${randomStr}-${safeName}`; // Convert to ArrayBuffer diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts index 6535339..d3b80fd 100644 --- a/src/lib/types/database.ts +++ b/src/lib/types/database.ts @@ -25,6 +25,7 @@ export type Database = { member_since: string; avatar_url: string | null; notes: string | null; + directory_privacy: { show_email: boolean; show_phone: boolean; show_address: boolean; show_nationality: boolean } | null; created_at: string; updated_at: string; }; @@ -44,6 +45,7 @@ export type Database = { member_since?: string; avatar_url?: string | null; notes?: string | null; + directory_privacy?: { show_email: boolean; show_phone: boolean; show_address: boolean; show_nationality: boolean } | null; created_at?: string; updated_at?: string; }; @@ -63,6 +65,7 @@ export type Database = { member_since?: string; avatar_url?: string | null; notes?: string | null; + directory_privacy?: { show_email: boolean; show_phone: boolean; show_address: boolean; show_nationality: boolean } | null; created_at?: string; updated_at?: string; }; diff --git a/src/routes/(app)/admin/bulk-email/+page.server.ts b/src/routes/(app)/admin/bulk-email/+page.server.ts new file mode 100644 index 0000000..39d3214 --- /dev/null +++ b/src/routes/(app)/admin/bulk-email/+page.server.ts @@ -0,0 +1,170 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { sendEmail, wrapInMonacoTemplate } from '$lib/server/email'; +import { escapeHtml } from '$lib/server/sanitize'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + // Load past bulk emails + const { data: broadcasts } = await supabaseAdmin + .from('bulk_emails') + .select('*') + .order('created_at', { ascending: false }) + .limit(20); + + // Get recipient counts for filter preview + const { data: members } = await supabaseAdmin + .from('members') + .select('id, role, status') + .not('email', 'is', null); + + const recipientCounts = { + all: members?.length || 0, + active: members?.filter((m: any) => m.status === 'active').length || 0, + board: members?.filter((m: any) => m.role === 'board' || m.role === 'admin').length || 0, + admin: members?.filter((m: any) => m.role === 'admin').length || 0 + }; + + return { + broadcasts: broadcasts || [], + recipientCounts + }; +}; + +export const actions: Actions = { + send: async ({ request, locals }) => { + const { member, session } = await locals.safeGetSession(); + if (!member || !session) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const subject = (formData.get('subject') as string)?.trim(); + const body = (formData.get('body') as string)?.trim(); + const target = (formData.get('target') as string) || 'all'; + + if (!subject || subject.length < 3) { + return fail(400, { error: 'Subject must be at least 3 characters' }); + } + + if (!body || body.length < 10) { + return fail(400, { error: 'Email body must be at least 10 characters' }); + } + + // Build recipient query + let query = supabaseAdmin + .from('members') + .select('id, email, first_name, last_name, role, status'); + + switch (target) { + case 'active': + query = query.eq('status', 'active'); + break; + case 'board': + query = query.in('role', ['board', 'admin']); + break; + case 'admin': + query = query.eq('role', 'admin'); + break; + default: + // All members with email + break; + } + + const { data: recipients } = await query.not('email', 'is', null); + + if (!recipients || recipients.length === 0) { + return fail(400, { error: 'No recipients match the selected filter' }); + } + + // Create bulk email record + const { data: broadcast, error: createError } = await supabaseAdmin + .from('bulk_emails') + .insert({ + subject: escapeHtml(subject), + body, + recipient_filter: { target }, + total_recipients: recipients.length, + status: 'sending', + sent_by: session.user.id, + sent_by_name: `${member.first_name} ${member.last_name}`, + sent_at: new Date().toISOString() + }) + .select() + .single(); + + if (createError || !broadcast) { + return fail(500, { error: 'Failed to create broadcast record' }); + } + + // Send emails in batches + let sentCount = 0; + let failedCount = 0; + + // Convert newlines in body to
for HTML display + const htmlBody = body.replace(/\n/g, '
'); + + for (const recipient of recipients) { + // Personalize the email content + const personalizedBody = htmlBody + .replace(/\{\{first_name\}\}/g, escapeHtml(recipient.first_name || '')) + .replace(/\{\{last_name\}\}/g, escapeHtml(recipient.last_name || '')) + .replace(/\{\{email\}\}/g, escapeHtml(recipient.email || '')); + + const emailContent = ` +

Hi ${escapeHtml(recipient.first_name || 'Member')},

+
${personalizedBody}
+ `; + + const result = await sendEmail({ + to: recipient.email, + subject, + html: wrapInMonacoTemplate({ + title: subject, + content: emailContent + }), + recipientId: recipient.id, + recipientName: `${recipient.first_name} ${recipient.last_name}`, + emailType: 'announcement' + }); + + if (result.success) { + sentCount++; + } else { + failedCount++; + } + } + + // Update broadcast record with results + await supabaseAdmin + .from('bulk_emails') + .update({ + sent_count: sentCount, + failed_count: failedCount, + status: failedCount === recipients.length ? 'failed' : 'completed', + completed_at: new Date().toISOString() + }) + .eq('id', broadcast.id); + + // Audit log + logAudit({ + action: 'email.send', + resourceType: 'settings', + resourceId: broadcast.id, + userId: session.user.id, + userEmail: member.email, + details: { + subject, + target, + totalRecipients: recipients.length, + sent: sentCount, + failed: failedCount + } + }); + + return { + success: `Broadcast sent! ${sentCount} delivered, ${failedCount} failed out of ${recipients.length} recipients.` + }; + } +}; diff --git a/src/routes/(app)/admin/bulk-email/+page.svelte b/src/routes/(app)/admin/bulk-email/+page.svelte new file mode 100644 index 0000000..45b3e19 --- /dev/null +++ b/src/routes/(app)/admin/bulk-email/+page.svelte @@ -0,0 +1,236 @@ + + + + Bulk Email | Monaco USA + + +
+
+

Bulk Email Broadcast

+

Send announcements and newsletters to members

+
+ + {#if form?.error} +
+
+ + {form.error} +
+
+ {/if} + + {#if form?.success} +
+
+ + {form.success} +
+
+ {/if} + + +
+

+ + Compose Broadcast +

+ +
{ + isSending = true; + showConfirm = false; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSending = false; + }; + }} + class="space-y-4" + > + +
+ + +

+ This will send to {currentRecipientCount} recipients. + Email preferences will be respected (members can opt out of announcements). +

+
+ + +
+ + +
+ + +
+ + +

+ Plain text with line breaks. Use {'{{first_name}}'} and {'{{last_name}}'} for personalization. +

+
+ + +
+ {#if !showConfirm} + + {:else} +
+ +

+ Send to {currentRecipientCount} recipients? This cannot be undone. +

+ + +
+ {/if} +
+
+
+ + +
+

Broadcast History

+ + {#if broadcasts.length === 0} +
+ +

No broadcasts sent yet.

+
+ {:else} +
+ + + + + + + + + + + + {#each broadcasts as broadcast} + {@const statusBadge = getStatusBadge(broadcast.status)} + + + + + + + + {/each} + +
SubjectStatusRecipientsSent ByDate
+ {broadcast.subject} + + + {statusBadge.label} + + + {broadcast.sent_count}/{broadcast.total_recipients} + {#if broadcast.failed_count > 0} + ({broadcast.failed_count} failed) + {/if} + + {broadcast.sent_by_name || 'Unknown'} + + {broadcast.sent_at ? formatDate(broadcast.sent_at) : formatDate(broadcast.created_at)} +
+
+ {/if} +
+
diff --git a/src/routes/(app)/admin/cron-monitoring/+page.server.ts b/src/routes/(app)/admin/cron-monitoring/+page.server.ts new file mode 100644 index 0000000..e00b318 --- /dev/null +++ b/src/routes/(app)/admin/cron-monitoring/+page.server.ts @@ -0,0 +1,143 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { env } from '$env/dynamic/private'; + +export const load: PageServerLoad = async () => { + // Load recent cron execution logs + const { data: logs } = await supabaseAdmin + .from('cron_execution_logs') + .select('*') + .order('started_at', { ascending: false }) + .limit(50); + + return { + cronLogs: logs || [] + }; +}; + +export const actions: Actions = { + runDuesReminders: async ({ url }) => { + const cronSecret = env.CRON_SECRET; + if (!cronSecret) { + return fail(500, { error: 'CRON_SECRET not configured' }); + } + + // Log the execution start + const { data: logEntry } = await supabaseAdmin + .from('cron_execution_logs') + .insert({ + job_name: 'dues-reminders', + status: 'running', + triggered_by: 'admin-manual' + }) + .select() + .single(); + + try { + const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org'; + const response = await fetch(`${baseUrl}/api/cron/dues-reminders`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${cronSecret}` + } + }); + + const result = await response.json(); + + // Update log entry + if (logEntry) { + await supabaseAdmin + .from('cron_execution_logs') + .update({ + status: response.ok ? 'completed' : 'failed', + completed_at: new Date().toISOString(), + duration_ms: Date.now() - new Date(logEntry.started_at).getTime(), + result, + error_message: response.ok ? null : result.error + }) + .eq('id', logEntry.id); + } + + if (!response.ok) { + return fail(500, { error: result.error || 'Cron job failed' }); + } + + return { success: `Dues reminders completed. Sent: ${result.summary?.totalRemindersSent || 0}` }; + } catch (error) { + if (logEntry) { + await supabaseAdmin + .from('cron_execution_logs') + .update({ + status: 'failed', + completed_at: new Date().toISOString(), + duration_ms: Date.now() - new Date(logEntry.started_at).getTime(), + error_message: error instanceof Error ? error.message : 'Unknown error' + }) + .eq('id', logEntry.id); + } + return fail(500, { error: error instanceof Error ? error.message : 'Failed to run cron job' }); + } + }, + + runEventReminders: async ({ url }) => { + const cronSecret = env.CRON_SECRET; + if (!cronSecret) { + return fail(500, { error: 'CRON_SECRET not configured' }); + } + + const { data: logEntry } = await supabaseAdmin + .from('cron_execution_logs') + .insert({ + job_name: 'event-reminders', + status: 'running', + triggered_by: 'admin-manual' + }) + .select() + .single(); + + try { + const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org'; + const response = await fetch(`${baseUrl}/api/cron/event-reminders`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${cronSecret}` + } + }); + + const result = await response.json(); + + if (logEntry) { + await supabaseAdmin + .from('cron_execution_logs') + .update({ + status: response.ok ? 'completed' : 'failed', + completed_at: new Date().toISOString(), + duration_ms: Date.now() - new Date(logEntry.started_at).getTime(), + result, + error_message: response.ok ? null : result.error + }) + .eq('id', logEntry.id); + } + + if (!response.ok) { + return fail(500, { error: result.error || 'Cron job failed' }); + } + + return { success: `Event reminders completed. Sent: ${result.sent || 0}` }; + } catch (error) { + if (logEntry) { + await supabaseAdmin + .from('cron_execution_logs') + .update({ + status: 'failed', + completed_at: new Date().toISOString(), + duration_ms: Date.now() - new Date(logEntry.started_at).getTime(), + error_message: error instanceof Error ? error.message : 'Unknown error' + }) + .eq('id', logEntry.id); + } + return fail(500, { error: error instanceof Error ? error.message : 'Failed to run cron job' }); + } + } +}; diff --git a/src/routes/(app)/admin/cron-monitoring/+page.svelte b/src/routes/(app)/admin/cron-monitoring/+page.svelte new file mode 100644 index 0000000..a944fc5 --- /dev/null +++ b/src/routes/(app)/admin/cron-monitoring/+page.svelte @@ -0,0 +1,241 @@ + + + + Cron Monitoring | Monaco USA + + +
+
+

Cron Job Monitoring

+

Monitor and manually trigger scheduled tasks

+
+ + {#if form?.error} +
+
+ + {form.error} +
+
+ {/if} + + {#if form?.success} +
+
+ + {form.success} +
+
+ {/if} + + +
+ {#each cronJobs as job} + {@const lastRun = getLastRun(job.id)} + {@const lastStatus = lastRun ? getStatusBadge(lastRun.status) : null} +
+
+
+
+ +
+
+

{job.name}

+

{job.description}

+
+
+
+ +
+
+ Schedule: + {job.schedule} +
+
+ Last Run: + {#if lastRun} + {formatDate(lastRun.started_at)} + {:else} + Never + {/if} +
+ {#if lastStatus} + {@const StatusIcon = lastStatus.icon} +
+ Status: + + + {lastStatus.label} + +
+ {/if} + {#if lastRun?.duration_ms} +
+ Duration: + {formatDuration(lastRun.duration_ms)} +
+ {/if} +
+ +
+
{ + runningJob = job.id; + return async ({ update }) => { + await invalidateAll(); + await update(); + runningJob = null; + }; + }} + > + +
+
+
+ {/each} +
+ + +
+

Execution History

+ + {#if cronLogs.length === 0} +
+ +

No cron executions recorded yet.

+

Run a job above or wait for scheduled execution.

+
+ {:else} +
+ + + + + + + + + + + + + {#each cronLogs as log} + {@const statusBadge = getStatusBadge(log.status)} + {@const BadgeIcon = statusBadge.icon} + + + + + + + + + {/each} + +
JobStatusStartedDurationTriggered ByDetails
+ {log.job_name} + + + + {statusBadge.label} + + + {formatDate(log.started_at)} + + {formatDuration(log.duration_ms)} + + + {log.triggered_by === 'admin-manual' ? 'Manual' : 'Scheduled'} + + + {#if log.error_message} + {log.error_message} + {:else if log.result?.summary} + + Sent: {log.result.summary.totalRemindersSent || 0}, + Errors: {log.result.summary.totalErrors || 0} + + {:else if log.result?.sent !== undefined} + Sent: {log.result.sent} + {:else} + - + {/if} +
+
+ {/if} +
+
diff --git a/src/routes/(app)/admin/members/+page.server.ts b/src/routes/(app)/admin/members/+page.server.ts index a56b1ad..1cac1ec 100644 --- a/src/routes/(app)/admin/members/+page.server.ts +++ b/src/routes/(app)/admin/members/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { supabaseAdmin } from '$lib/server/supabase'; import { sendEmail } from '$lib/server/email'; +import { logMemberAction } from '$lib/server/audit'; export const load: PageServerLoad = async ({ locals, url }) => { const searchQuery = url.searchParams.get('search') || ''; @@ -64,6 +65,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { export const actions: Actions = { updateRole: async ({ request, locals }) => { + const { session } = await locals.safeGetSession(); const formData = await request.formData(); const memberId = formData.get('member_id') as string; const newRole = formData.get('role') as string; @@ -76,6 +78,11 @@ export const actions: Actions = { return fail(400, { error: 'Invalid role' }); } + // Prevent admin from changing their own role (belt-and-suspenders with RLS) + if (session?.user && memberId === session.user.id) { + return fail(403, { error: 'Cannot change your own role' }); + } + const { error } = await locals.supabase .from('members') .update({ role: newRole, updated_at: new Date().toISOString() }) @@ -86,10 +93,19 @@ export const actions: Actions = { return fail(500, { error: 'Failed to update role' }); } + if (session?.user) { + logMemberAction('role_change', + { id: session.user.id, email: session.user.email! }, + { id: memberId }, + { new_role: newRole } + ); + } + return { success: 'Role updated successfully!' }; }, updateStatus: async ({ request, locals }) => { + const { session } = await locals.safeGetSession(); const formData = await request.formData(); const memberId = formData.get('member_id') as string; const statusId = formData.get('status_id') as string; @@ -111,6 +127,14 @@ export const actions: Actions = { return fail(500, { error: 'Failed to update status' }); } + if (session?.user) { + logMemberAction('status_change', + { id: session.user.id, email: session.user.email! }, + { id: memberId }, + { new_status_id: statusId } + ); + } + return { success: 'Status updated successfully!' }; }, @@ -174,6 +198,12 @@ export const actions: Actions = { // Member is already deleted, just log this } + logMemberAction('delete', + { id: member.id, email: member.email! }, + { id: memberId }, + { deleted_by_admin: true } + ); + return { success: 'Member deleted successfully!' }; }, diff --git a/src/routes/(app)/admin/notifications/+page.server.ts b/src/routes/(app)/admin/notifications/+page.server.ts index a82bb9f..c70aeec 100644 --- a/src/routes/(app)/admin/notifications/+page.server.ts +++ b/src/routes/(app)/admin/notifications/+page.server.ts @@ -1,6 +1,7 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { supabaseAdmin } from '$lib/server/supabase'; +import { escapeHtml } from '$lib/server/sanitize'; export const load: PageServerLoad = async ({ locals }) => { const { member } = await locals.safeGetSession(); @@ -46,8 +47,8 @@ export const actions: Actions = { const formData = await request.formData(); const type = formData.get('type') as string; - const title = formData.get('title') as string; - const message = formData.get('message') as string; + const title = escapeHtml(formData.get('title') as string); + const message = escapeHtml(formData.get('message') as string); const link = (formData.get('link') as string) || null; const recipientType = formData.get('recipient_type') as string; const recipientId = formData.get('recipient_id') as string; diff --git a/src/routes/(app)/admin/settings/+page.server.ts b/src/routes/(app)/admin/settings/+page.server.ts index e49b5d0..86e9970 100644 --- a/src/routes/(app)/admin/settings/+page.server.ts +++ b/src/routes/(app)/admin/settings/+page.server.ts @@ -4,6 +4,7 @@ import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email'; import { testS3Connection, clearS3ClientCache } from '$lib/server/storage'; import { supabaseAdmin } from '$lib/server/supabase'; import * as poste from '$lib/server/poste'; +import { logSettingsUpdate, getRecentAuditLogs } from '$lib/server/audit'; export const load: PageServerLoad = async ({ locals }) => { // Load all configurable data @@ -32,13 +33,17 @@ export const load: PageServerLoad = async ({ locals }) => { settings[setting.category][setting.setting_key] = setting.setting_value; } + // Load recent settings audit logs + const { logs: auditLogs } = await getRecentAuditLogs(50, { resourceType: 'settings' }); + return { membershipStatuses: membershipStatuses || [], membershipTypes: membershipTypes || [], eventTypes: eventTypes || [], documentCategories: documentCategories || [], settings, - emailTemplates: emailTemplates || [] + emailTemplates: emailTemplates || [], + auditLogs }; }; @@ -317,6 +322,12 @@ export const actions: Actions = { clearS3ClientCache(); } + logSettingsUpdate( + { id: member.id, email: member.email! }, + category, + { changed_settings: settingsToUpdate.map(s => s.key) } + ); + return { success: 'Settings updated successfully!' }; } catch (err) { console.error('Unexpected error updating settings:', err); diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte index de8a85e..961b263 100644 --- a/src/routes/(app)/admin/settings/+page.svelte +++ b/src/routes/(app)/admin/settings/+page.svelte @@ -22,7 +22,8 @@ Key, Copy, Power, - PowerOff + PowerOff, + ClipboardList } from 'lucide-svelte'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; @@ -30,9 +31,9 @@ import { invalidateAll } from '$app/navigation'; let { data, form } = $props(); - const { membershipStatuses, membershipTypes, eventTypes, documentCategories, settings, emailTemplates } = data; + const { membershipStatuses, membershipTypes, eventTypes, documentCategories, settings, emailTemplates, auditLogs } = data; - type Tab = 'membership' | 'dues' | 'events' | 'documents' | 'email' | 'poste' | 'storage' | 'system'; + type Tab = 'membership' | 'dues' | 'events' | 'documents' | 'email' | 'poste' | 'storage' | 'system' | 'audit'; let activeTab = $state('membership'); let showAddStatusModal = $state(false); @@ -83,7 +84,8 @@ { id: 'email', label: 'Email', icon: Mail }, { id: 'poste', label: 'Email Accounts', icon: Inbox }, { id: 'storage', label: 'Storage', icon: HardDrive }, - { id: 'system', label: 'System', icon: Cog } + { id: 'system', label: 'System', icon: Cog }, + { id: 'audit', label: 'Audit Log', icon: ClipboardList } ]; // Load mailboxes function @@ -1344,6 +1346,81 @@ {/if} + + {#if activeTab === 'audit'} +
+
+

+ + Recent Settings Changes +

+

+ Audit trail of recent configuration changes made by administrators. +

+
+ + {#if auditLogs && auditLogs.length > 0} +
+ + + + + + + + + + + {#each auditLogs as log} + + + + + + + {/each} + +
TimestampUserActionDetails
+ {new Date(log.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + + {new Date(log.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })} + + + {log.user_email || 'System'} + + + {log.action} + + + {#if log.details} + {#if typeof log.details === 'object'} + {#if log.details.category} + {log.details.category}: + {/if} + {#if log.details.changes} + {#each Object.entries(log.details.changes) as [key, value]} + {key}={value} + {/each} + {:else} + {JSON.stringify(log.details).slice(0, 100)} + {/if} + {:else} + {log.details} + {/if} + {:else} + - + {/if} +
+
+ {:else} +
+ +

No settings changes recorded yet.

+

Changes to system settings will appear here.

+
+ {/if} +
+ {/if} diff --git a/src/routes/(app)/board/approvals/+page.server.ts b/src/routes/(app)/board/approvals/+page.server.ts new file mode 100644 index 0000000..16ab30d --- /dev/null +++ b/src/routes/(app)/board/approvals/+page.server.ts @@ -0,0 +1,161 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { sendTemplatedEmail } from '$lib/server/email'; +import { logMemberAction } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + const { session } = await locals.safeGetSession(); + if (!session) return { pendingMembers: [] }; + + // Get pending status ID + const { data: pendingStatus } = await locals.supabase + .from('membership_statuses') + .select('id') + .eq('name', 'pending') + .single(); + + if (!pendingStatus) return { pendingMembers: [] }; + + // Load pending members + const { data: pendingMembers } = await locals.supabase + .from('members') + .select(` + id, + member_id, + first_name, + last_name, + email, + phone, + nationality, + address, + date_of_birth, + created_at + `) + .eq('membership_status_id', pendingStatus.id) + .order('created_at', { ascending: true }); + + return { + pendingMembers: pendingMembers || [] + }; +}; + +export const actions: Actions = { + approve: async ({ request, locals }) => { + const { session } = await locals.safeGetSession(); + if (!session) return fail(401, { error: 'Not authenticated' }); + + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + + if (!memberId) return fail(400, { error: 'Missing member ID' }); + + // Get active status ID + const { data: activeStatus } = await supabaseAdmin + .from('membership_statuses') + .select('id') + .eq('name', 'active') + .single(); + + if (!activeStatus) return fail(500, { error: 'Active status not found' }); + + // Update member status to active + const { error } = await supabaseAdmin + .from('members') + .update({ + membership_status_id: activeStatus.id, + approved_at: new Date().toISOString(), + approved_by: session.user.id + }) + .eq('id', memberId); + + if (error) return fail(500, { error: 'Failed to approve member' }); + + // Get member info for email + const { data: member } = await supabaseAdmin + .from('members') + .select('first_name, email, member_id') + .eq('id', memberId) + .single(); + + // Send approval email + if (member) { + sendTemplatedEmail('member_approved', member.email, { + first_name: member.first_name, + member_id: member.member_id || '' + }, { + recipientId: memberId, + recipientName: member.first_name + }); + } + + // Audit log + logMemberAction('status_change', + { id: session.user.id, email: session.user.email! }, + { id: memberId, email: member?.email }, + { old_status: 'pending', new_status: 'active', action: 'approved' } + ); + + return { success: true, approved: member?.first_name }; + }, + + reject: async ({ request, locals }) => { + const { session } = await locals.safeGetSession(); + if (!session) return fail(401, { error: 'Not authenticated' }); + + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + const reason = formData.get('reason') as string; + + if (!memberId) return fail(400, { error: 'Missing member ID' }); + + // Get inactive status ID + const { data: inactiveStatus } = await supabaseAdmin + .from('membership_statuses') + .select('id') + .eq('name', 'inactive') + .single(); + + if (!inactiveStatus) return fail(500, { error: 'Inactive status not found' }); + + // Update member status + const { error } = await supabaseAdmin + .from('members') + .update({ + membership_status_id: inactiveStatus.id, + rejected_at: new Date().toISOString(), + rejected_by: session.user.id, + rejection_reason: reason || null + }) + .eq('id', memberId); + + if (error) return fail(500, { error: 'Failed to reject member' }); + + // Get member info for email + const { data: member } = await supabaseAdmin + .from('members') + .select('first_name, email') + .eq('id', memberId) + .single(); + + // Send rejection email + if (member) { + sendTemplatedEmail('member_rejected', member.email, { + first_name: member.first_name, + reason: reason || '' + }, { + recipientId: memberId, + recipientName: member.first_name + }); + } + + // Audit log + logMemberAction('status_change', + { id: session.user.id, email: session.user.email! }, + { id: memberId, email: member?.email }, + { old_status: 'pending', new_status: 'inactive', action: 'rejected', reason } + ); + + return { success: true, rejected: member?.first_name }; + } +}; diff --git a/src/routes/(app)/board/approvals/+page.svelte b/src/routes/(app)/board/approvals/+page.svelte new file mode 100644 index 0000000..abd61ab --- /dev/null +++ b/src/routes/(app)/board/approvals/+page.svelte @@ -0,0 +1,222 @@ + + + + Member Approvals | Monaco USA + + +
+
+
+
+

Member Approvals

+ {#if pendingCount > 0} + + {pendingCount} + + {/if} +
+

Review and approve pending membership applications

+
+
+ + {#if form?.success && form?.approved} +
+

+ {form.approved} has been approved and notified by email. +

+
+ {/if} + + {#if form?.success && form?.rejected} +
+

+ {form.rejected}'s application has been rejected. +

+
+ {/if} + + {#if form?.error} +
+

{form.error}

+
+ {/if} + + {#if pendingCount === 0} +
+ +
+ {:else} +
+ {#each data.pendingMembers as member} +
+
+ +
+
+
+ {member.first_name?.[0]}{member.last_name?.[0]} +
+
+

+ {member.first_name} {member.last_name} +

+

{member.member_id || 'ID pending'}

+
+
+ +
+ + {#if member.phone} +
+ + {member.phone} +
+ {/if} + {#if member.nationality && member.nationality.length > 0} +
+ +
+ {#each member.nationality as code} + + {/each} +
+
+ {/if} + {#if member.address} +
+ + {member.address} +
+ {/if} +
+ + Applied: {formatDate(member.created_at)} +
+
+
+ + +
+ {#if rejectingId === member.id} + +
{ + submitting = true; + return async ({ update }) => { + submitting = false; + rejectingId = null; + rejectReason = ''; + await update(); + }; + }} + class="flex flex-col gap-2" + > + + +
+ + +
+
+ {:else} +
{ + submitting = true; + return async ({ update }) => { + submitting = false; + await update(); + }; + }} + > + + +
+ + {/if} +
+
+
+ {/each} +
+ {/if} +
diff --git a/src/routes/(app)/board/documents/+page.server.ts b/src/routes/(app)/board/documents/+page.server.ts index f1bb830..fa20066 100644 --- a/src/routes/(app)/board/documents/+page.server.ts +++ b/src/routes/(app)/board/documents/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { uploadDocument, deleteDocument, getSignedUrl, isS3Enabled, getActiveDocumentUrl } from '$lib/server/storage'; import { supabaseAdmin } from '$lib/server/supabase'; +import { logDocumentAction } from '$lib/server/audit'; export const load: PageServerLoad = async ({ locals, url }) => { const folderId = url.searchParams.get('folder'); @@ -282,6 +283,10 @@ export const actions: Actions = { return fail(400, { error: 'Title is required' }); } + if (title.length > 200) { + return fail(400, { error: 'Title must be 200 characters or less' }); + } + // Upload using dual-storage document service (uploads to both S3 and Supabase Storage) const uploadResult = await uploadDocument(file); @@ -354,6 +359,12 @@ export const actions: Actions = { return fail(500, { error: 'Failed to delete document' }); } + logDocumentAction('delete', + { id: member.id, email: member.email! }, + { id: documentId, title: doc?.storage_path || documentId }, + { storage_path: doc?.storage_path } + ); + // Delete from ALL storage backends (both S3 and Supabase Storage) if (doc?.storage_path) { // Use the storage_path directly @@ -416,6 +427,12 @@ export const actions: Actions = { return fail(500, { error: 'Failed to update document' }); } + logDocumentAction('visibility_change', + { id: member.id, email: member.email! }, + { id: documentId }, + { new_visibility: visibility } + ); + return { success: 'Visibility updated!' }; }, diff --git a/src/routes/(app)/board/events/[id]/edit/+page.server.ts b/src/routes/(app)/board/events/[id]/edit/+page.server.ts index 9702e43..ba1d95b 100644 --- a/src/routes/(app)/board/events/[id]/edit/+page.server.ts +++ b/src/routes/(app)/board/events/[id]/edit/+page.server.ts @@ -1,5 +1,7 @@ import { fail, error, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; +import { escapeHtml, sanitizeRichText } from '$lib/server/sanitize'; +import { logEventAction } from '$lib/server/audit'; export const load: PageServerLoad = async ({ locals, params }) => { const { member } = await locals.safeGetSession(); @@ -42,8 +44,8 @@ export const actions: Actions = { const formData = await request.formData(); - const title = formData.get('title') as string; - const description = formData.get('description') as string; + const title = escapeHtml(formData.get('title') as string); + const description = sanitizeRichText(formData.get('description') as string || ''); const eventTypeId = formData.get('event_type_id') as string; const startDate = formData.get('start_date') as string; const startTime = formData.get('start_time') as string; @@ -58,6 +60,9 @@ export const actions: Actions = { const nonMemberPrice = formData.get('non_member_price') as string; const visibility = formData.get('visibility') as string; const status = formData.get('status') as string; + const rsvpDeadlineEnabled = formData.get('rsvp_deadline_enabled') === 'true'; + const rsvpDeadlineDate = formData.get('rsvp_deadline_date') as string; + const rsvpDeadlineTime = formData.get('rsvp_deadline_time') as string; // Validation if (!title || !startDate || !startTime || !endDate || !endTime) { @@ -90,6 +95,10 @@ export const actions: Actions = { non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0, visibility: visibility || 'members', status: status || 'published', + rsvp_deadline_enabled: rsvpDeadlineEnabled, + rsvp_deadline: rsvpDeadlineEnabled && rsvpDeadlineDate && rsvpDeadlineTime + ? `${rsvpDeadlineDate}T${rsvpDeadlineTime}:00` + : null, updated_at: new Date().toISOString() }) .eq('id', params.id); @@ -99,6 +108,13 @@ export const actions: Actions = { return fail(500, { error: 'Failed to update event. Please try again.' }); } + logEventAction( + status === 'cancelled' ? 'cancel' : 'update', + { id: member.id, email: member.email! }, + { id: params.id, title }, + { status } + ); + return { success: 'Event updated successfully!' }; }, @@ -119,6 +135,12 @@ export const actions: Actions = { return fail(500, { error: 'Failed to delete event. Please try again.' }); } + logEventAction('delete', + { id: member.id, email: member.email! }, + { id: params.id }, + { deleted_by_admin: true } + ); + throw redirect(303, '/board/events?deleted=true'); } }; diff --git a/src/routes/(app)/board/events/[id]/edit/+page.svelte b/src/routes/(app)/board/events/[id]/edit/+page.svelte index 6551aa1..97699cb 100644 --- a/src/routes/(app)/board/events/[id]/edit/+page.svelte +++ b/src/routes/(app)/board/events/[id]/edit/+page.svelte @@ -1,5 +1,5 @@