Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
Build and Push Docker Image / build (push) Successful in 2m8s Details

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>
This commit is contained in:
Matt 2026-02-06 07:54:10 +01:00
parent 9b119302d3
commit 19eb2be85f
36 changed files with 1059 additions and 404 deletions

View File

@ -691,7 +691,11 @@ CREATE POLICY "Members viewable by authenticated users"
CREATE POLICY "Users can update own profile"
ON public.members FOR UPDATE
TO authenticated
USING (auth.uid() = id);
USING (auth.uid() = id)
WITH CHECK (
auth.uid() = id
AND role = (SELECT m.role FROM public.members m WHERE m.id = auth.uid())
);
CREATE POLICY "Admins can insert members"
ON public.members FOR INSERT

View File

@ -178,7 +178,7 @@ services:
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
@ -299,6 +299,16 @@ services:
condition: service_healthy
networks:
- monacousa-network
security_opt:
- "no-new-privileges:true"
read_only: true
tmpfs:
- "/tmp"
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
# ============================================
# Networks
# ============================================

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { X, Download, ZoomIn, ZoomOut, RotateCw, Maximize2, FileText, Image, File } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { LoadingSpinner } from '$lib/components/ui';
interface Document {
id: string;
@ -172,7 +173,7 @@
<div class="relative flex-1 overflow-auto p-4">
{#if isLoading && !isImage && !isPdf}
<div class="flex h-full items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-monaco-600 border-t-transparent"></div>
<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">

View File

@ -2,6 +2,7 @@
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { LoadingSpinner } from '$lib/components/ui';
interface Notification {
id: string;
@ -183,7 +184,7 @@
<div class="max-h-96 overflow-y-auto">
{#if loading}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-monaco-600 border-t-transparent"></div>
<LoadingSpinner size="lg" class="text-monaco-600" />
</div>
{:else if error}
<div class="px-4 py-8 text-center">

View File

@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
size?: 'sm' | 'md' | 'lg';
class?: string;
}
let { size = 'md', class: className = '' }: Props = $props();
const sizes = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6'
};
</script>
<svg
class="animate-spin {sizes[size]} {className}"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>

View File

@ -0,0 +1,30 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
icon?: Component;
title: string;
description?: string;
class?: string;
children?: import('svelte').Snippet;
}
let { icon: Icon, title, description, class: className = '', children }: Props = $props();
</script>
<div class="flex flex-col items-center justify-center py-12 text-center {className}">
{#if Icon}
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100">
<Icon class="h-6 w-6 text-slate-400" />
</div>
{/if}
<h3 class="text-sm font-medium text-slate-900">{title}</h3>
{#if description}
<p class="mt-1 text-sm text-slate-500">{description}</p>
{/if}
{#if children}
<div class="mt-4">
{@render children()}
</div>
{/if}
</div>

View File

@ -17,3 +17,5 @@ export { default as NationalitySelect } from './NationalitySelect.svelte';
export { default as CountryFlag } from './CountryFlag.svelte';
export { default as PhoneInput } from './PhoneInput.svelte';
export { default as CountrySelect } from './CountrySelect.svelte';
export { default as EmptyState } from './empty-state.svelte';
export { default as LoadingSpinner } from './LoadingSpinner.svelte';

View File

@ -0,0 +1,52 @@
/**
* Authentication utility functions
*/
/**
* Sanitize a redirect URL to prevent open redirect attacks.
* Only allows relative paths starting with '/'.
* Rejects protocol-relative URLs, absolute URLs, and javascript: URIs.
*/
export function sanitizeRedirectUrl(url: string | null | undefined): string {
const fallback = '/dashboard';
if (!url || typeof url !== 'string') {
return fallback;
}
// Trim whitespace
const trimmed = url.trim();
// Reject empty strings
if (!trimmed) {
return fallback;
}
// Reject protocol-relative URLs (//evil.com)
if (trimmed.startsWith('//')) {
return fallback;
}
// Reject absolute URLs with protocols
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(trimmed)) {
return fallback;
}
// Must start with a single forward slash (relative path)
if (!trimmed.startsWith('/')) {
return fallback;
}
// Reject paths that could be interpreted as protocol-relative after decoding
try {
const decoded = decodeURIComponent(trimmed);
if (decoded.startsWith('//') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(decoded)) {
return fallback;
}
} catch {
// If decoding fails, reject the URL
return fallback;
}
return trimmed;
}

View File

@ -585,6 +585,40 @@ export async function getReminderEffectiveness(): Promise<{
};
}
// Collect unique member IDs and earliest sent_at per member
const memberIds = [...new Set(reminders.map((r) => r.member_id))];
// Find the earliest sent_at across all reminders to bound the payment query
const earliestSentAt = reminders.reduce((earliest, r) => {
const d = new Date(r.sent_at);
return d < earliest ? d : earliest;
}, new Date(reminders[0].sent_at));
// Find the latest possible payment date (latest sent_at + 30 days)
const latestSentAt = reminders.reduce((latest, r) => {
const d = new Date(r.sent_at);
return d > latest ? d : latest;
}, new Date(reminders[0].sent_at));
const latestPaymentDate = new Date(latestSentAt.getTime() + 30 * 24 * 60 * 60 * 1000);
// Fetch all relevant payments in a single batch query
const { data: allPayments } = await supabaseAdmin
.from('dues_payments')
.select('member_id, payment_date')
.in('member_id', memberIds)
.gte('payment_date', earliestSentAt.toISOString().split('T')[0])
.lte('payment_date', latestPaymentDate.toISOString().split('T')[0]);
// Index payments by member_id for fast lookup
const paymentsByMember = new Map<string, { payment_date: string }[]>();
if (allPayments) {
for (const p of allPayments) {
const existing = paymentsByMember.get(p.member_id) || [];
existing.push(p);
paymentsByMember.set(p.member_id, existing);
}
}
let paidWithin7Days = 0;
let paidWithin30Days = 0;
@ -593,17 +627,16 @@ export async function getReminderEffectiveness(): Promise<{
const sevenDaysLater = new Date(sentDate.getTime() + 7 * 24 * 60 * 60 * 1000);
const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000);
// Check if member paid within windows
const { data: payments } = await supabaseAdmin
.from('dues_payments')
.select('payment_date')
.eq('member_id', reminder.member_id)
.gte('payment_date', sentDate.toISOString().split('T')[0])
.lte('payment_date', thirtyDaysLater.toISOString().split('T')[0])
.limit(1);
const memberPayments = paymentsByMember.get(reminder.member_id) || [];
if (payments && payments.length > 0) {
const paymentDate = new Date(payments[0].payment_date);
// Find a payment within the 30-day window after the reminder
const matchingPayment = memberPayments.find((p) => {
const paymentDate = new Date(p.payment_date);
return paymentDate >= sentDate && paymentDate <= thirtyDaysLater;
});
if (matchingPayment) {
const paymentDate = new Date(matchingPayment.payment_date);
if (paymentDate <= sevenDaysLater) {
paidWithin7Days++;
}

View File

@ -202,6 +202,16 @@ export async function sendTemplatedEmail(
...variables
};
// Keys that contain URLs should not be escaped
const urlKeys = new Set(['logo_url', 'site_url']);
// Escape all non-URL variable values to prevent XSS
for (const [key, value] of Object.entries(allVariables)) {
if (!urlKeys.has(key)) {
allVariables[key] = escapeHtml(value);
}
}
// Replace variables in subject and body
let subject = template.subject;
let bodyContent = template.body_html;
@ -395,6 +405,19 @@ export function wrapInMonacoTemplate(options: {
</html>`;
}
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Strip HTML tags from a string to create plain text version
*/

View File

@ -0,0 +1,283 @@
/**
* Shared member profile helpers used by both the profile and settings pages.
* Consolidates avatar upload/removal and profile update logic.
*/
import { uploadAvatar, deleteAvatar, isS3Enabled } from '$lib/server/storage';
import { supabaseAdmin } from '$lib/server/supabase';
import type { SupabaseClient } from '@supabase/supabase-js';
// ────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────
export interface ProfileUpdateData {
first_name: string;
last_name: string;
phone?: string;
address?: string;
nationality?: string[];
}
export interface AvatarUploadResult {
success: boolean;
error?: string;
successMessage?: string;
}
interface MemberAvatarInfo {
id: string;
avatar_url?: string | null;
avatar_path?: string | null;
}
// ────────────────────────────────────────────────────────────────
// Avatar Upload
// ────────────────────────────────────────────────────────────────
/**
* Handle avatar upload for a member.
*
* Supports two modes depending on the caller:
* - **Admin mode** (useAdmin = true, default for profile page): uses supabaseAdmin
* to bypass RLS and stores all URL variants (local, S3, path).
* - **User mode** (useAdmin = false, default for settings page): passes the user's
* supabase client to the storage layer and stores only the public URL.
*/
export async function handleAvatarUpload(
member: MemberAvatarInfo,
file: File,
options?: {
/** Use supabaseAdmin instead of a user-scoped client. Defaults to true. */
useAdmin?: boolean;
/** User-scoped Supabase client, required when useAdmin is false. */
supabase?: SupabaseClient;
}
): Promise<AvatarUploadResult> {
const useAdmin = options?.useAdmin ?? true;
const supabase = options?.supabase;
// Validate file basics
if (!file || !file.size) {
return { success: false, error: 'Please select an image to upload' };
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return { success: false, error: 'Please upload a valid image (JPEG, PNG, WebP, or GIF)' };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: 'Image must be less than 5MB' };
}
// Delete any existing avatar first
if (member.avatar_path) {
await deleteAvatar(member.id, member.avatar_path);
} else {
await deleteAvatar(member.id);
}
// Upload the new avatar
const result = useAdmin
? await uploadAvatar(member.id, file)
: await uploadAvatar(member.id, file, supabase);
if (!result.success) {
return { success: false, error: result.error || 'Failed to upload avatar' };
}
// Update the member record
const client = useAdmin ? supabaseAdmin : supabase!;
if (useAdmin) {
// Admin mode: store all URL variants for dual-backend storage
const s3Active = await isS3Enabled();
const activeUrl = s3Active ? result.s3Url : result.localUrl;
const { error: updateError } = await client
.from('members')
.update({
avatar_url: activeUrl || result.publicUrl,
avatar_url_local: result.localUrl || null,
avatar_url_s3: result.s3Url || null,
avatar_path: result.path,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to update avatar URL:', updateError);
return { success: false, error: 'Failed to update profile with new avatar' };
}
} else {
// User mode: store only the public URL
const { error: updateError } = await client
.from('members')
.update({
avatar_url: result.publicUrl,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to update avatar URL:', updateError);
return { success: false, error: 'Failed to update profile with new avatar' };
}
}
return {
success: true,
successMessage: useAdmin ? 'Avatar uploaded successfully!' : 'Profile picture updated successfully!'
};
}
// ────────────────────────────────────────────────────────────────
// Avatar Removal
// ────────────────────────────────────────────────────────────────
/**
* Handle avatar removal for a member.
*
* Same admin/user mode distinction as handleAvatarUpload.
*/
export async function handleAvatarRemoval(
member: MemberAvatarInfo,
options?: {
useAdmin?: boolean;
supabase?: SupabaseClient;
}
): Promise<AvatarUploadResult> {
const useAdmin = options?.useAdmin ?? true;
const supabase = options?.supabase;
// Delete the avatar from storage backends
if (member.avatar_path) {
await deleteAvatar(member.id, member.avatar_path);
} else if (member.avatar_url) {
await deleteAvatar(member.id, member.avatar_url);
} else {
await deleteAvatar(member.id);
}
// Clear avatar fields in the member record
const client = useAdmin ? supabaseAdmin : supabase!;
if (useAdmin) {
const { error: updateError } = await client
.from('members')
.update({
avatar_url: null,
avatar_url_local: null,
avatar_url_s3: null,
avatar_path: null,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to remove avatar URL:', updateError);
return { success: false, error: 'Failed to update profile' };
}
} else {
const { error: updateError } = await client
.from('members')
.update({
avatar_url: null,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to remove avatar URL:', updateError);
return { success: false, error: 'Failed to update profile' };
}
}
return {
success: true,
successMessage: useAdmin ? 'Avatar removed successfully!' : 'Profile picture removed!'
};
}
// ────────────────────────────────────────────────────────────────
// Profile Update
// ────────────────────────────────────────────────────────────────
/**
* Validate and update a member's profile fields.
*
* @param memberId The member's UUID.
* @param data The profile fields to update.
* @param options Controls which Supabase client is used and which fields are required.
*/
export async function handleProfileUpdate(
memberId: string,
data: ProfileUpdateData,
options?: {
/** Use supabaseAdmin instead of a user-scoped client. Defaults to true. */
useAdmin?: boolean;
/** User-scoped Supabase client, required when useAdmin is false. */
supabase?: SupabaseClient;
/** Whether phone, address, and nationality are required. Defaults to false. */
requireAllFields?: boolean;
}
): Promise<{ success: boolean; error?: string }> {
const useAdmin = options?.useAdmin ?? true;
const requireAllFields = options?.requireAllFields ?? false;
// Validate required fields
if (!data.first_name || data.first_name.length < 2) {
return { success: false, error: 'First name must be at least 2 characters' };
}
if (!data.last_name || data.last_name.length < 2) {
return { success: false, error: 'Last name must be at least 2 characters' };
}
if (requireAllFields) {
if (!data.phone) {
return { success: false, error: 'Phone number is required' };
}
if (!data.address || data.address.length < 10) {
return { success: false, error: 'Please enter a complete address' };
}
if (!data.nationality || data.nationality.length === 0) {
return { success: false, error: 'Please select at least one nationality' };
}
}
// Build the update payload
const updatePayload: Record<string, unknown> = {
first_name: data.first_name,
last_name: data.last_name,
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;
} else {
updatePayload.phone = data.phone || null;
updatePayload.address = data.address || null;
}
const client = useAdmin ? supabaseAdmin : options?.supabase;
if (!client) {
return { success: false, error: 'Internal error: no database client available' };
}
const { error } = await client
.from('members')
.update(updatePayload)
.eq('id', memberId);
if (error) {
console.error('Failed to update profile:', error);
return { success: false, error: 'Failed to update profile. Please try again.' };
}
return { success: true };
}

View File

@ -0,0 +1,73 @@
/**
* Simple in-memory rate limiter for auth endpoints.
* Uses a Map with TTL-based cleanup.
*/
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
// Cleanup stale entries periodically
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
function ensureCleanup() {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now > entry.resetAt) {
store.delete(key);
}
}
// Stop interval if store is empty
if (store.size === 0 && cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}, 60_000); // Clean up every minute
}
/**
* Check if a request is allowed under the rate limit.
* @param key - Unique identifier (e.g., IP address, email, or combination)
* @param maxAttempts - Maximum attempts allowed within the window
* @param windowMs - Time window in milliseconds
* @returns Object with `allowed` boolean and optional `retryAfterMs`
*/
export function checkRateLimit(
key: string,
maxAttempts: number,
windowMs: number
): { allowed: boolean; retryAfterMs?: number } {
ensureCleanup();
const now = Date.now();
const entry = store.get(key);
// No existing entry or expired — allow and create new entry
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true };
}
// Within window — check count
if (entry.count < maxAttempts) {
entry.count++;
return { allowed: true };
}
// Rate limited
return {
allowed: false,
retryAfterMs: entry.resetAt - now
};
}
/**
* Reset rate limit for a key (e.g., after successful login)
*/
export function resetRateLimit(key: string): void {
store.delete(key);
}

View File

@ -0,0 +1,201 @@
/**
* Shared registration helpers used by both the signup and join pages.
* Consolidates member creation and welcome email logic.
*/
import { supabaseAdmin } from '$lib/server/supabase';
import { sendTemplatedEmail } from '$lib/server/email';
import type { SupabaseClient } from '@supabase/supabase-js';
// ────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────
export interface RegistrationData {
userId: string;
email: string;
firstName: string;
lastName: string;
phone?: string;
dateOfBirth?: string;
address?: string;
nationality?: string[];
}
export interface CreateMemberResult {
success: boolean;
error?: string;
memberId?: string;
}
// ────────────────────────────────────────────────────────────────
// Member Creation
// ────────────────────────────────────────────────────────────────
/**
* Create a member record in the database after auth user creation.
*
* Handles:
* - Looking up the default/pending membership status and type
* - Generating a sequential MUSA-YYYY-XXXX member ID
* - Inserting the member record
*
* @param data Core registration data.
* @param supabase The Supabase client to use for DB operations.
* @param options Additional options for how the member is created.
*/
export async function createMemberRecord(
data: RegistrationData,
supabase: SupabaseClient,
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. */
generateMemberId?: boolean;
}
): Promise<CreateMemberResult> {
const generateMemberId = options?.generateMemberId ?? true;
const statusName = options?.statusName;
// Look up the membership status
let statusQuery;
if (statusName) {
statusQuery = supabase
.from('membership_statuses')
.select('id')
.eq('name', statusName)
.single();
} else {
statusQuery = supabase
.from('membership_statuses')
.select('id')
.eq('is_default', true)
.single();
}
const { data: statusData, error: statusError } = await statusQuery;
if (statusError || !statusData?.id) {
console.error('No membership status found:', statusError);
return { success: false, error: 'System configuration error. Please contact support.' };
}
// Look up the default membership type
const { data: typeData, error: typeError } = await supabase
.from('membership_types')
.select('id')
.eq('is_default', true)
.single();
if (typeError || !typeData?.id) {
console.error('No default membership type found:', typeError);
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}`;
}
// Create the member profile
const insertPayload: Record<string, unknown> = {
id: data.userId,
first_name: data.firstName,
last_name: data.lastName,
email: data.email,
phone: data.phone || null,
date_of_birth: data.dateOfBirth || null,
address: data.address || null,
nationality: data.nationality || [],
role: 'member',
membership_status_id: statusData.id,
membership_type_id: typeData.id
};
if (memberId) {
insertPayload.member_id = memberId;
}
const { error: memberError } = await supabase.from('members').insert(insertPayload);
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 };
}
/**
* Clean up auth user on registration failure.
* Uses supabaseAdmin to ensure we can always delete the user.
*/
export async function cleanupAuthUser(userId: string): Promise<void> {
try {
await supabaseAdmin.auth.admin.deleteUser(userId);
} catch (deleteError) {
console.error('Failed to clean up auth user:', deleteError);
}
}
// ────────────────────────────────────────────────────────────────
// Welcome Email
// ────────────────────────────────────────────────────────────────
/**
* Send the onboarding welcome email with payment instructions.
*
* @param member Basic member info for the email template.
* @param paymentSettings Payment account details from app_settings.
* @param duesAmount The annual dues amount.
* @param paymentDeadline The payment deadline date.
*/
export async function sendWelcomeEmail(
member: {
id: string;
first_name: string;
email: string;
member_id?: string;
},
paymentSettings: Record<string, string>,
duesAmount: number,
paymentDeadline: Date
): Promise<{ success: boolean; error?: string }> {
try {
const result = await sendTemplatedEmail(
'onboarding_welcome',
member.email,
{
first_name: member.first_name,
member_id: member.member_id || 'N/A',
amount: `\u20AC${duesAmount}`,
payment_deadline: paymentDeadline.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}),
account_holder: paymentSettings.account_holder || 'Monaco USA',
bank_name: paymentSettings.bank_name || 'Credit Foncier de Monaco',
iban: paymentSettings.iban || 'Contact for details'
},
{
recipientId: member.id,
recipientName: member.first_name,
sentBy: 'system'
}
);
return result;
} catch (emailError) {
console.error('Failed to send welcome email:', emailError);
return { success: false, error: 'Failed to send welcome email' };
}
}

View File

@ -13,6 +13,49 @@ import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner';
export type StorageBucket = 'documents' | 'avatars' | 'event-images';
/**
* Magic byte signatures for common file types.
* Used to validate that file content matches the declared MIME type.
*/
const MAGIC_BYTES: Record<string, { offset: number; bytes: number[] }[]> = {
'image/png': [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47] }],
'image/jpeg': [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
'image/gif': [{ offset: 0, bytes: [0x47, 0x49, 0x46] }],
'application/pdf': [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
'image/webp': [
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } // WEBP
]
};
/**
* 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
* (e.g., office documents). Returns false if magic bytes are checked and don't match.
*/
export function validateFileMagicBytes(buffer: ArrayBuffer, declaredType: string): boolean {
const signatures = MAGIC_BYTES[declaredType];
if (!signatures) {
// No magic byte check for this type — allow it
return true;
}
const view = new Uint8Array(buffer);
for (const sig of signatures) {
if (view.length < sig.offset + sig.bytes.length) {
return false;
}
for (let i = 0; i < sig.bytes.length; i++) {
if (view[sig.offset + i] !== sig.bytes[i]) {
return false;
}
}
}
return true;
}
/**
* Generate a browser-accessible public URL for Supabase Storage
* This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
@ -585,6 +628,11 @@ export async function uploadAvatar(
// Convert to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Validate magic bytes match declared MIME type
if (!validateFileMagicBytes(arrayBuffer, file.type)) {
return { success: false, error: 'File content does not match declared type. The file may be corrupted or mislabeled.' };
}
// Check if S3 is enabled
const s3Enabled = await isS3Enabled();
@ -759,6 +807,11 @@ export async function uploadDocument(
// Convert to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Validate magic bytes match declared MIME type
if (!validateFileMagicBytes(arrayBuffer, file.type)) {
return { success: false, error: 'File content does not match declared type. The file may be corrupted or mislabeled.' };
}
// Check if S3 is enabled
const s3Enabled = await isS3Enabled();

View File

@ -0,0 +1,13 @@
/**
* Get the document/content visibility levels accessible to a given role.
*/
export function getVisibleLevels(role: string | undefined): string[] {
switch (role) {
case 'admin':
return ['public', 'members', 'board', 'admin'];
case 'board':
return ['public', 'members', 'board'];
default:
return ['public', 'members'];
}
}

View File

@ -0,0 +1,55 @@
/**
* Status badge color/class configuration
*/
export interface StatusConfig {
label: string;
classes: string;
}
const STATUS_MAP: Record<string, StatusConfig> = {
// Membership statuses
active: { label: 'Active', classes: 'bg-green-100 text-green-800' },
pending: { label: 'Pending', classes: 'bg-yellow-100 text-yellow-800' },
inactive: { label: 'Inactive', classes: 'bg-slate-100 text-slate-800' },
suspended: { label: 'Suspended', classes: 'bg-red-100 text-red-800' },
// Dues statuses
current: { label: 'Current', classes: 'bg-green-100 text-green-800' },
due_soon: { label: 'Due Soon', classes: 'bg-yellow-100 text-yellow-800' },
overdue: { label: 'Overdue', classes: 'bg-red-100 text-red-800' },
never_paid: { label: 'Never Paid', classes: 'bg-slate-100 text-slate-800' },
// Payment statuses
paid: { label: 'Paid', classes: 'bg-green-100 text-green-800' },
unpaid: { label: 'Unpaid', classes: 'bg-red-100 text-red-800' },
partial: { label: 'Partial', classes: 'bg-yellow-100 text-yellow-800' },
// RSVP statuses
confirmed: { label: 'Confirmed', classes: 'bg-green-100 text-green-800' },
declined: { label: 'Declined', classes: 'bg-red-100 text-red-800' },
maybe: { label: 'Maybe', classes: 'bg-yellow-100 text-yellow-800' },
waitlist: { label: 'Waitlist', classes: 'bg-blue-100 text-blue-800' },
cancelled: { label: 'Cancelled', classes: 'bg-slate-100 text-slate-800' },
// Event statuses
published: { label: 'Published', classes: 'bg-green-100 text-green-800' },
draft: { label: 'Draft', classes: 'bg-slate-100 text-slate-800' },
// Roles
admin: { label: 'Admin', classes: 'bg-purple-100 text-purple-800' },
board: { label: 'Board', classes: 'bg-blue-100 text-blue-800' },
member: { label: 'Member', classes: 'bg-slate-100 text-slate-800' }
};
export function getStatusConfig(status: string): StatusConfig {
return STATUS_MAP[status] || { label: status, classes: 'bg-slate-100 text-slate-800' };
}
export function getStatusBadgeClasses(status: string): string {
return getStatusConfig(status).classes;
}
export function getStatusLabel(status: string): string {
return getStatusConfig(status).label;
}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page, navigating } from '$app/stores';
import { Sidebar, Header, MobileNav, MobileMenu } from '$lib/components/layout';
import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte';
@ -46,6 +46,16 @@
</script>
<div class="flex h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
<a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-monaco-600 focus:shadow-lg focus:ring-2 focus:ring-monaco-500">
Skip to content
</a>
{#if $navigating}
<div class="fixed inset-x-0 top-0 z-50 h-0.5 bg-monaco-200">
<div class="h-full animate-pulse bg-monaco-600" style="width: 90%; animation: progress 1s ease-in-out infinite;"></div>
</div>
{/if}
<!-- Desktop Sidebar -->
<div class="hidden lg:block">
<Sidebar
@ -67,7 +77,7 @@
{/if}
<!-- Page Content -->
<main class="flex-1 overflow-y-auto pb-20 lg:pb-0">
<main id="main-content" class="flex-1 overflow-y-auto pb-20 lg:pb-0">
<div class="container mx-auto max-w-7xl p-4 lg:p-6">
{@render children()}
</div>

View File

@ -22,9 +22,9 @@
const revenueUp = Number(revenueTrend) >= 0;
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat('fr-MC', {
style: 'currency',
currency: 'USD'
currency: 'EUR'
}).format(amount);
}

View File

@ -343,13 +343,13 @@ export const actions: Actions = {
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
// Format event date and time
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
const eventDate = new Date(event.start_datetime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
const eventTime = new Date(event.start_time).toLocaleTimeString('en-US', {
const eventTime = new Date(event.start_datetime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
});

View File

@ -5,10 +5,10 @@ export const load: PageServerLoad = async ({ locals, url }) => {
const statusFilter = url.searchParams.get('status') || 'all';
const roleFilter = url.searchParams.get('role') || 'all';
// Build the query
// Build the query - select only needed columns to avoid exposing sensitive fields
let query = locals.supabase
.from('members_with_dues')
.select('*')
.select('id, member_id, first_name, last_name, email, phone, role, status_name, type_name, avatar_url, created_at, nationality, address')
.order('last_name', { ascending: true });
// Apply filters

View File

@ -33,9 +33,9 @@
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat('fr-MC', {
style: 'currency',
currency: 'USD'
currency: 'EUR'
}).format(amount);
}

View File

@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types';
import { getVisibleLevels } from '$lib/server/visibility';
export const load: PageServerLoad = async ({ locals, parent }) => {
const { member } = await parent();
@ -72,13 +73,3 @@ export const load: PageServerLoad = async ({ locals, parent }) => {
};
};
function getVisibleLevels(role: string | undefined): string[] {
switch (role) {
case 'admin':
return ['public', 'members', 'board', 'admin'];
case 'board':
return ['public', 'members', 'board'];
default:
return ['public', 'members'];
}
}

View File

@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { isS3Enabled } from '$lib/server/storage';
import { getVisibleLevels } from '$lib/server/visibility';
export const load: PageServerLoad = async ({ locals, parent }) => {
const { member } = await parent();
@ -37,13 +38,3 @@ export const load: PageServerLoad = async ({ locals, parent }) => {
};
};
function getVisibleLevels(role: string | undefined): string[] {
switch (role) {
case 'admin':
return ['public', 'members', 'board', 'admin'];
case 'board':
return ['public', 'members', 'board'];
default:
return ['public', 'members'];
}
}

View File

@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types';
import { getVisibleLevels } from '$lib/server/visibility';
export const load: PageServerLoad = async ({ locals, parent }) => {
const { member } = await parent();
@ -19,13 +20,3 @@ export const load: PageServerLoad = async ({ locals, parent }) => {
};
};
function getVisibleLevels(role: string | undefined): string[] {
switch (role) {
case 'admin':
return ['public', 'members', 'board', 'admin'];
case 'board':
return ['public', 'members', 'board'];
default:
return ['public', 'members'];
}
}

View File

@ -1,6 +1,7 @@
import { fail, error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { sendTemplatedEmail } from '$lib/server/email';
import { getVisibleLevels } from '$lib/server/visibility';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const { member } = await parent();
@ -302,13 +303,3 @@ async function promoteFromWaitlist(
}
}
function getVisibleLevels(role: string | undefined): string[] {
switch (role) {
case 'admin':
return ['public', 'members', 'board', 'admin'];
case 'board':
return ['public', 'members', 'board'];
default:
return ['public', 'members'];
}
}

View File

@ -1,7 +1,7 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { uploadAvatar, deleteAvatar, isS3Enabled, getActiveAvatarUrl } from '$lib/server/storage';
import { supabaseAdmin } from '$lib/server/supabase';
import { getActiveAvatarUrl } from '$lib/server/storage';
import { handleAvatarUpload, handleAvatarRemoval, handleProfileUpdate } from '$lib/server/member-profile';
export const load: PageServerLoad = async ({ parent }) => {
const { member } = await parent();
@ -29,50 +29,22 @@ export const actions: Actions = {
}
const formData = await request.formData();
const firstName = formData.get('first_name') as string;
const lastName = formData.get('last_name') as string;
const phone = formData.get('phone') as string;
const address = formData.get('address') as string;
const nationalityString = formData.get('nationality') as string;
// Validation
if (!firstName || firstName.length < 2) {
return fail(400, { error: 'First name must be at least 2 characters' });
}
const result = await handleProfileUpdate(
member.id,
{
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
phone: formData.get('phone') as string,
address: formData.get('address') as string,
nationality: nationalityString ? nationalityString.split(',').filter(Boolean) : []
},
{ useAdmin: true, requireAllFields: true }
);
if (!lastName || lastName.length < 2) {
return fail(400, { error: 'Last name must be at least 2 characters' });
}
if (!phone) {
return fail(400, { error: 'Phone number is required' });
}
if (!address || address.length < 10) {
return fail(400, { error: 'Please enter a complete address' });
}
const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
if (nationality.length === 0) {
return fail(400, { error: 'Please select at least one nationality' });
}
// Update member profile (use admin client to bypass RLS)
const { error } = await supabaseAdmin
.from('members')
.update({
first_name: firstName,
last_name: lastName,
phone,
address,
nationality,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (error) {
console.error('Failed to update profile:', error);
return fail(500, { error: 'Failed to update profile. Please try again.' });
if (!result.success) {
return fail(400, { error: result.error });
}
return { success: 'Profile updated successfully!' };
@ -88,46 +60,13 @@ export const actions: Actions = {
const formData = await request.formData();
const file = formData.get('avatar') as File;
if (!file || !file.size) {
return fail(400, { error: 'Please select an image to upload' });
}
// First delete any existing avatar from both storage backends
if (member.avatar_path) {
await deleteAvatar(member.id, member.avatar_path);
} else {
await deleteAvatar(member.id);
}
// Upload the avatar to appropriate storage (or both)
const result = await uploadAvatar(member.id, file);
const result = await handleAvatarUpload(member, file, { useAdmin: true });
if (!result.success) {
return fail(400, { error: result.error || 'Failed to upload avatar' });
return fail(400, { error: result.error });
}
// Determine active URL based on current S3 setting
const s3Active = await isS3Enabled();
const activeUrl = s3Active ? result.s3Url : result.localUrl;
// Update member record with all avatar URLs (use admin client to bypass RLS)
const { error: updateError } = await supabaseAdmin
.from('members')
.update({
avatar_url: activeUrl || result.publicUrl,
avatar_url_local: result.localUrl || null,
avatar_url_s3: result.s3Url || null,
avatar_path: result.path,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to update avatar URL:', updateError);
return fail(500, { error: 'Failed to update profile with new avatar' });
}
return { success: 'Avatar uploaded successfully!' };
return { success: result.successMessage };
},
removeAvatar: async ({ locals }) => {
@ -137,31 +76,12 @@ export const actions: Actions = {
return fail(401, { error: 'Not authenticated' });
}
// Delete the avatar from BOTH storage backends using the stored path
if (member.avatar_path) {
await deleteAvatar(member.id, member.avatar_path);
} else {
// Fallback: try to delete common extensions
await deleteAvatar(member.id);
const result = await handleAvatarRemoval(member, { useAdmin: true });
if (!result.success) {
return fail(500, { error: result.error });
}
// Update member record to clear all avatar URLs (use admin client to bypass RLS)
const { error: updateError } = await supabaseAdmin
.from('members')
.update({
avatar_url: null,
avatar_url_local: null,
avatar_url_s3: null,
avatar_path: null,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to remove avatar URL:', updateError);
return fail(500, { error: 'Failed to update profile' });
}
return { success: 'Avatar removed successfully!' };
return { success: result.successMessage };
}
};

View File

@ -1,7 +1,7 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { uploadAvatar, deleteAvatar } from '$lib/server/storage';
import { sendTemplatedEmail, wrapInMonacoTemplate, sendEmail } from '$lib/server/email';
import { handleAvatarUpload, handleAvatarRemoval, handleProfileUpdate } from '$lib/server/member-profile';
import { getMailbox, updateMailbox, type PosteConfig } from '$lib/server/poste';
export const load: PageServerLoad = async ({ parent, locals }) => {
@ -64,39 +64,22 @@ export const actions: Actions = {
}
const formData = await request.formData();
const firstName = formData.get('first_name') as string;
const lastName = formData.get('last_name') as string;
const phone = formData.get('phone') as string;
const address = formData.get('address') as string;
const nationalityString = formData.get('nationality') as string;
// Validation
if (!firstName || firstName.length < 2) {
return fail(400, { error: 'First name must be at least 2 characters' });
}
const result = await handleProfileUpdate(
member.id,
{
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
phone: formData.get('phone') as string,
address: formData.get('address') as string,
nationality: nationalityString ? nationalityString.split(',').filter(Boolean) : []
},
{ useAdmin: false, supabase: locals.supabase, requireAllFields: false }
);
if (!lastName || lastName.length < 2) {
return fail(400, { error: 'Last name must be at least 2 characters' });
}
const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
// Update member profile
const { error } = await locals.supabase
.from('members')
.update({
first_name: firstName,
last_name: lastName,
phone: phone || null,
address: address || null,
nationality,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (error) {
console.error('Failed to update profile:', error);
return fail(500, { error: 'Failed to update profile. Please try again.' });
if (!result.success) {
return fail(400, { error: result.error });
}
return { success: 'Profile updated successfully!' };
@ -112,43 +95,16 @@ export const actions: Actions = {
const formData = await request.formData();
const file = formData.get('avatar') as File;
if (!file || !file.size) {
return fail(400, { error: 'Please select an image to upload' });
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return fail(400, { error: 'Please upload a valid image (JPEG, PNG, WebP, or GIF)' });
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
return fail(400, { error: 'Image must be less than 5MB' });
}
// Upload the avatar - pass user's supabase client for RLS
const result = await uploadAvatar(member.id, file, locals.supabase);
const result = await handleAvatarUpload(member, file, {
useAdmin: false,
supabase: locals.supabase
});
if (!result.success) {
return fail(400, { error: result.error || 'Failed to upload avatar' });
return fail(400, { error: result.error });
}
// Update member record with avatar URL
const { error: updateError } = await locals.supabase
.from('members')
.update({
avatar_url: result.publicUrl,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to update avatar URL:', updateError);
return fail(500, { error: 'Failed to update profile with new avatar' });
}
return { success: 'Profile picture updated successfully!' };
return { success: result.successMessage };
},
removeAvatar: async ({ locals }) => {
@ -158,24 +114,16 @@ export const actions: Actions = {
return fail(401, { error: 'Not authenticated' });
}
// Delete the avatar from storage - pass user's supabase client for RLS
await deleteAvatar(member.id, locals.supabase);
const result = await handleAvatarRemoval(member, {
useAdmin: false,
supabase: locals.supabase
});
// Update member record to remove avatar URL
const { error: updateError } = await locals.supabase
.from('members')
.update({
avatar_url: null,
updated_at: new Date().toISOString()
})
.eq('id', member.id);
if (updateError) {
console.error('Failed to remove avatar URL:', updateError);
return fail(500, { error: 'Failed to update profile' });
if (!result.success) {
return fail(500, { error: result.error });
}
return { success: 'Profile picture removed!' };
return { success: result.successMessage };
},
updateNotifications: async ({ request, locals }) => {

View File

@ -1,5 +1,6 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { checkRateLimit } from '$lib/server/rate-limit';
export const load: PageServerLoad = async () => {
return {};
@ -17,6 +18,17 @@ export const actions: Actions = {
});
}
// Rate limit password reset attempts by email
const rateLimitKey = `forgot-password:${email}`;
const rateCheck = checkRateLimit(rateLimitKey, 3, 15 * 60 * 1000); // 3 attempts per 15 min
if (!rateCheck.allowed) {
const retryMinutes = Math.ceil((rateCheck.retryAfterMs || 0) / 60000);
return fail(429, {
error: `Too many reset attempts. Please try again in ${retryMinutes} minute${retryMinutes !== 1 ? 's' : ''}.`,
email
});
}
const { error } = await locals.supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${url.origin}/auth/reset-password`
});

View File

@ -1,5 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { sanitizeRedirectUrl } from '$lib/server/auth-utils';
import { checkRateLimit, resetRateLimit } from '$lib/server/rate-limit';
export const load: PageServerLoad = async ({ url, locals }) => {
const { session } = await locals.safeGetSession();
@ -29,7 +31,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
}
return {
redirectTo: url.searchParams.get('redirectTo') || '/dashboard',
redirectTo: sanitizeRedirectUrl(url.searchParams.get('redirectTo')),
urlError: errorMessage,
urlSuccess: successMessage
};
@ -40,7 +42,7 @@ export const actions: Actions = {
const formData = await request.formData();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const redirectTo = url.searchParams.get('redirectTo') || '/dashboard';
const redirectTo = sanitizeRedirectUrl(url.searchParams.get('redirectTo'));
if (!email || !password) {
return fail(400, {
@ -49,6 +51,17 @@ export const actions: Actions = {
});
}
// Rate limit login attempts by email
const rateLimitKey = `login:${email}`;
const rateCheck = checkRateLimit(rateLimitKey, 5, 15 * 60 * 1000); // 5 attempts per 15 min
if (!rateCheck.allowed) {
const retryMinutes = Math.ceil((rateCheck.retryAfterMs || 0) / 60000);
return fail(429, {
error: `Too many login attempts. Please try again in ${retryMinutes} minute${retryMinutes !== 1 ? 's' : ''}.`,
email
});
}
const { data, error } = await locals.supabase.auth.signInWithPassword({
email,
password
@ -93,6 +106,9 @@ export const actions: Actions = {
});
}
// Clear rate limit on successful login
resetRateLimit(rateLimitKey);
throw redirect(303, redirectTo);
}
};

View File

@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
import { createMemberRecord, cleanupAuthUser } from '$lib/server/registration';
export const load: PageServerLoad = async ({ locals }) => {
const { session } = await locals.safeGetSession();
@ -147,76 +147,27 @@ export const actions: Actions = {
});
}
// Get the default membership status (pending)
const { data: defaultStatus, error: statusError } = await locals.supabase
.from('membership_statuses')
.select('id')
.eq('is_default', true)
.single();
// Get the default membership type
const { data: defaultType, error: typeError } = await locals.supabase
.from('membership_types')
.select('id')
.eq('is_default', true)
.single();
// Validate that default status and type exist
if (statusError || !defaultStatus?.id) {
console.error('No default membership status found:', statusError);
// Clean up the auth user since we can't complete registration
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
return fail(500, {
error: 'System configuration error. Please contact support.',
first_name: firstName,
last_name: lastName,
// Create member profile using shared helper
const memberResult = await createMemberRecord(
{
userId: authData.user.id,
email,
firstName,
lastName,
phone,
date_of_birth: dateOfBirth,
address
});
}
dateOfBirth,
address,
nationality
},
locals.supabase,
{ generateMemberId: true }
);
if (typeError || !defaultType?.id) {
console.error('No default membership type found:', typeError);
// Clean up the auth user since we can't complete registration
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
return fail(500, {
error: 'System configuration error. Please contact support.',
first_name: firstName,
last_name: lastName,
email,
phone,
date_of_birth: dateOfBirth,
address
});
}
// Create member profile
const { error: memberError } = await locals.supabase.from('members').insert({
id: authData.user.id,
first_name: firstName,
last_name: lastName,
email,
phone,
date_of_birth: dateOfBirth,
address,
nationality,
role: 'member',
membership_status_id: defaultStatus.id,
membership_type_id: defaultType.id
});
if (memberError) {
if (!memberResult.success) {
// Clean up the auth user since member profile creation failed
console.error('Failed to create member profile:', memberError);
try {
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
} catch (deleteError) {
console.error('Failed to clean up auth user:', deleteError);
}
await cleanupAuthUser(authData.user.id);
return fail(500, {
error: 'Failed to create member profile. Please try again or contact support.',
error: memberResult.error || 'Failed to create member profile.',
first_name: firstName,
last_name: lastName,
email,

View File

@ -35,11 +35,11 @@
</p>
<div class="flex flex-col gap-4 sm:flex-row">
<Button variant="monaco" size="xl">
<Button variant="monaco" size="xl" href="/login">
Sign In
</Button>
<Button variant="monaco-outline" size="xl">
Learn More
<Button variant="monaco-outline" size="xl" href="/join">
Join Us
</Button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sanitizeRedirectUrl } from '$lib/server/auth-utils';
/**
* Auth callback handler for email verification and OAuth redirects
@ -7,7 +8,7 @@ import type { RequestHandler } from './$types';
*/
export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code');
const next = url.searchParams.get('next') || '/dashboard';
const next = sanitizeRedirectUrl(url.searchParams.get('next'));
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');

View File

@ -32,8 +32,12 @@
<!-- Logo and Branding -->
<div class="mb-8 text-center">
<a href="/" class="inline-flex flex-col items-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-monaco-600 shadow-lg">
<span class="text-2xl font-bold text-white">M</span>
<div class="mb-4 overflow-hidden rounded-2xl bg-white/90 p-2 shadow-xl backdrop-blur-sm">
<img
src="/MONACOUSA-Flags_376x376.png"
alt="Monaco USA"
class="h-16 w-16 object-contain"
/>
</div>
<h1 class="text-2xl font-bold text-slate-900">
Monaco <span class="text-monaco-600">USA</span>

View File

@ -1,7 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
import { sendTemplatedEmail } from '$lib/server/email';
import { createMemberRecord, cleanupAuthUser, sendWelcomeEmail } from '$lib/server/registration';
export const load: PageServerLoad = async ({ locals, url }) => {
const { session } = await locals.safeGetSession();
@ -162,64 +161,32 @@ export const actions: Actions = {
return fail(500, { error: 'Failed to create account. Please try again.', step: 2 });
}
// Get the pending membership status
const { data: pendingStatus } = await locals.supabase
.from('membership_statuses')
.select('id')
.eq('name', 'pending')
.single();
// Create member profile using shared helper
const memberResult = await createMemberRecord(
{
userId: authData.user.id,
email,
firstName,
lastName,
phone,
dateOfBirth,
address,
nationality
},
locals.supabase,
{ statusName: 'pending', generateMemberId: true }
);
// Get the default membership type
const { data: defaultType } = await locals.supabase
.from('membership_types')
.select('id')
.eq('is_default', true)
.single();
if (!pendingStatus?.id || !defaultType?.id) {
console.error('Missing default status or type');
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
return fail(500, { error: 'System configuration error. Please contact support.', step: 2 });
}
// Generate member ID
const year = new Date().getFullYear();
const { count } = await locals.supabase
.from('members')
.select('*', { count: 'exact', head: true });
const memberNumber = String((count || 0) + 1).padStart(4, '0');
const memberId = `MUSA-${year}-${memberNumber}`;
// Create member profile
const { error: memberError } = await locals.supabase.from('members').insert({
id: authData.user.id,
first_name: firstName,
last_name: lastName,
email,
phone,
date_of_birth: dateOfBirth,
address,
nationality,
member_id: memberId,
role: 'member',
membership_status_id: pendingStatus.id,
membership_type_id: defaultType.id
});
if (memberError) {
console.error('Failed to create member profile:', memberError);
try {
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
} catch (deleteError) {
console.error('Failed to clean up auth user:', deleteError);
}
if (!memberResult.success) {
await cleanupAuthUser(authData.user.id);
return fail(500, {
error: 'Failed to create member profile. Please try again.',
error: memberResult.error || 'Failed to create member profile.',
step: 2
});
}
const memberId = memberResult.memberId;
// Sign in the user so they can continue the wizard
const { error: signInError } = await locals.supabase.auth.signInWithPassword({
email,
@ -312,34 +279,18 @@ export const actions: Actions = {
// Send welcome email with payment instructions
if (member) {
try {
await sendTemplatedEmail(
'onboarding_welcome',
member.email,
{
first_name: member.first_name,
member_id: member.member_id || 'N/A',
amount: `${defaultType?.annual_dues || 150}`,
payment_deadline: paymentDeadline.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}),
account_holder: paymentSettings.account_holder || 'Monaco USA',
bank_name: paymentSettings.bank_name || 'Credit Foncier de Monaco',
iban: paymentSettings.iban || 'Contact for details'
},
{
recipientId: session.user.id,
recipientName: `${member.first_name}`,
sentBy: 'system'
}
);
} catch (emailError) {
console.error('Failed to send welcome email:', emailError);
// Continue anyway - not critical
}
await sendWelcomeEmail(
{
id: session.user.id,
first_name: member.first_name,
email: member.email,
member_id: member.member_id
},
paymentSettings,
defaultType?.annual_dues || 150,
paymentDeadline
);
// Errors are logged internally; not critical to block on failure
}
return {

View File

@ -42,9 +42,9 @@
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat('fr-MC', {
style: 'currency',
currency: 'USD'
currency: 'EUR'
}).format(amount);
}

View File

@ -0,0 +1,16 @@
-- Migration 017: Fix RLS policy to prevent self-role-escalation
-- The "Users can update own profile" policy allows users to SET role = 'admin'
-- on their own row because it lacks a WITH CHECK clause restricting role changes.
-- Drop the existing policy
DROP POLICY IF EXISTS "Users can update own profile" ON public.members;
-- Recreate with WITH CHECK that prevents role changes
CREATE POLICY "Users can update own profile"
ON public.members FOR UPDATE
TO authenticated
USING (auth.uid() = id)
WITH CHECK (
auth.uid() = id
AND role = (SELECT m.role FROM public.members m WHERE m.id = auth.uid())
);