Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
Build and Push Docker Image / build (push) Successful in 2m8s
Details
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:
parent
9b119302d3
commit
19eb2be85f
|
|
@ -691,7 +691,11 @@ CREATE POLICY "Members viewable by authenticated users"
|
||||||
CREATE POLICY "Users can update own profile"
|
CREATE POLICY "Users can update own profile"
|
||||||
ON public.members FOR UPDATE
|
ON public.members FOR UPDATE
|
||||||
TO authenticated
|
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"
|
CREATE POLICY "Admins can insert members"
|
||||||
ON public.members FOR INSERT
|
ON public.members FOR INSERT
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ services:
|
||||||
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||||
DB_ENC_KEY: supabaserealtime
|
DB_ENC_KEY: supabaserealtime
|
||||||
API_JWT_SECRET: ${JWT_SECRET}
|
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
|
ERL_AFLAGS: -proto_dist inet_tcp
|
||||||
DNS_NODES: "''"
|
DNS_NODES: "''"
|
||||||
RLIMIT_NOFILE: "10000"
|
RLIMIT_NOFILE: "10000"
|
||||||
|
|
@ -299,6 +299,16 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- monacousa-network
|
- monacousa-network
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- "/tmp"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '1.0'
|
||||||
# ============================================
|
# ============================================
|
||||||
# Networks
|
# Networks
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, Download, ZoomIn, ZoomOut, RotateCw, Maximize2, FileText, Image, File } from 'lucide-svelte';
|
import { X, Download, ZoomIn, ZoomOut, RotateCw, Maximize2, FileText, Image, File } from 'lucide-svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { LoadingSpinner } from '$lib/components/ui';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -172,7 +173,7 @@
|
||||||
<div class="relative flex-1 overflow-auto p-4">
|
<div class="relative flex-1 overflow-auto p-4">
|
||||||
{#if isLoading && !isImage && !isPdf}
|
{#if isLoading && !isImage && !isPdf}
|
||||||
<div class="flex h-full items-center justify-center">
|
<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>
|
</div>
|
||||||
{:else if loadError}
|
{:else if loadError}
|
||||||
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
|
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
|
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { LoadingSpinner } from '$lib/components/ui';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -183,7 +184,7 @@
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center justify-center py-8">
|
<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>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="px-4 py-8 text-center">
|
<div class="px-4 py-8 text-center">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -17,3 +17,5 @@ export { default as NationalitySelect } from './NationalitySelect.svelte';
|
||||||
export { default as CountryFlag } from './CountryFlag.svelte';
|
export { default as CountryFlag } from './CountryFlag.svelte';
|
||||||
export { default as PhoneInput } from './PhoneInput.svelte';
|
export { default as PhoneInput } from './PhoneInput.svelte';
|
||||||
export { default as CountrySelect } from './CountrySelect.svelte';
|
export { default as CountrySelect } from './CountrySelect.svelte';
|
||||||
|
export { default as EmptyState } from './empty-state.svelte';
|
||||||
|
export { default as LoadingSpinner } from './LoadingSpinner.svelte';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 paidWithin7Days = 0;
|
||||||
let paidWithin30Days = 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 sevenDaysLater = new Date(sentDate.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Check if member paid within windows
|
const memberPayments = paymentsByMember.get(reminder.member_id) || [];
|
||||||
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);
|
|
||||||
|
|
||||||
if (payments && payments.length > 0) {
|
// Find a payment within the 30-day window after the reminder
|
||||||
const paymentDate = new Date(payments[0].payment_date);
|
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) {
|
if (paymentDate <= sevenDaysLater) {
|
||||||
paidWithin7Days++;
|
paidWithin7Days++;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,16 @@ export async function sendTemplatedEmail(
|
||||||
...variables
|
...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
|
// Replace variables in subject and body
|
||||||
let subject = template.subject;
|
let subject = template.subject;
|
||||||
let bodyContent = template.body_html;
|
let bodyContent = template.body_html;
|
||||||
|
|
@ -395,6 +405,19 @@ export function wrapInMonacoTemplate(options: {
|
||||||
</html>`;
|
</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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip HTML tags from a string to create plain text version
|
* Strip HTML tags from a string to create plain text version
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,49 @@ import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
|
||||||
export type StorageBucket = 'documents' | 'avatars' | 'event-images';
|
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
|
* Generate a browser-accessible public URL for Supabase Storage
|
||||||
* This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
|
* This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
|
||||||
|
|
@ -585,6 +628,11 @@ export async function uploadAvatar(
|
||||||
// Convert to ArrayBuffer
|
// Convert to ArrayBuffer
|
||||||
const arrayBuffer = await file.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
|
// Check if S3 is enabled
|
||||||
const s3Enabled = await isS3Enabled();
|
const s3Enabled = await isS3Enabled();
|
||||||
|
|
||||||
|
|
@ -759,6 +807,11 @@ export async function uploadDocument(
|
||||||
// Convert to ArrayBuffer
|
// Convert to ArrayBuffer
|
||||||
const arrayBuffer = await file.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
|
// Check if S3 is enabled
|
||||||
const s3Enabled = await isS3Enabled();
|
const s3Enabled = await isS3Enabled();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page, navigating } from '$app/stores';
|
||||||
import { Sidebar, Header, MobileNav, MobileMenu } from '$lib/components/layout';
|
import { Sidebar, Header, MobileNav, MobileMenu } from '$lib/components/layout';
|
||||||
import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte';
|
import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte';
|
||||||
|
|
||||||
|
|
@ -46,6 +46,16 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
<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 -->
|
<!-- Desktop Sidebar -->
|
||||||
<div class="hidden lg:block">
|
<div class="hidden lg:block">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -67,7 +77,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- 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">
|
<div class="container mx-auto max-w-7xl p-4 lg:p-6">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@
|
||||||
const revenueUp = Number(revenueTrend) >= 0;
|
const revenueUp = Number(revenueTrend) >= 0;
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('fr-MC', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD'
|
currency: 'EUR'
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,13 +343,13 @@ export const actions: Actions = {
|
||||||
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
||||||
|
|
||||||
// Format event date and time
|
// 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',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
});
|
});
|
||||||
const eventTime = new Date(event.start_time).toLocaleTimeString('en-US', {
|
const eventTime = new Date(event.start_datetime).toLocaleTimeString('en-US', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
const statusFilter = url.searchParams.get('status') || 'all';
|
const statusFilter = url.searchParams.get('status') || 'all';
|
||||||
const roleFilter = url.searchParams.get('role') || '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
|
let query = locals.supabase
|
||||||
.from('members_with_dues')
|
.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 });
|
.order('last_name', { ascending: true });
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('fr-MC', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD'
|
currency: 'EUR'
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getVisibleLevels } from '$lib/server/visibility';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||||
const { member } = await 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'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { isS3Enabled } from '$lib/server/storage';
|
import { isS3Enabled } from '$lib/server/storage';
|
||||||
|
import { getVisibleLevels } from '$lib/server/visibility';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||||
const { member } = await 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'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getVisibleLevels } from '$lib/server/visibility';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||||
const { member } = await 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'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { fail, error } from '@sveltejs/kit';
|
import { fail, error } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { sendTemplatedEmail } from '$lib/server/email';
|
import { sendTemplatedEmail } from '$lib/server/email';
|
||||||
|
import { getVisibleLevels } from '$lib/server/visibility';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||||
const { member } = await 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'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { uploadAvatar, deleteAvatar, isS3Enabled, getActiveAvatarUrl } from '$lib/server/storage';
|
import { getActiveAvatarUrl } from '$lib/server/storage';
|
||||||
import { supabaseAdmin } from '$lib/server/supabase';
|
import { handleAvatarUpload, handleAvatarRemoval, handleProfileUpdate } from '$lib/server/member-profile';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent }) => {
|
export const load: PageServerLoad = async ({ parent }) => {
|
||||||
const { member } = await parent();
|
const { member } = await parent();
|
||||||
|
|
@ -29,50 +29,22 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
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;
|
const nationalityString = formData.get('nationality') as string;
|
||||||
|
|
||||||
// Validation
|
const result = await handleProfileUpdate(
|
||||||
if (!firstName || firstName.length < 2) {
|
member.id,
|
||||||
return fail(400, { error: 'First name must be at least 2 characters' });
|
{
|
||||||
}
|
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) {
|
if (!result.success) {
|
||||||
return fail(400, { error: 'Last name must be at least 2 characters' });
|
return fail(400, { error: result.error });
|
||||||
}
|
|
||||||
|
|
||||||
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.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: 'Profile updated successfully!' };
|
return { success: 'Profile updated successfully!' };
|
||||||
|
|
@ -88,46 +60,13 @@ export const actions: Actions = {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('avatar') as File;
|
const file = formData.get('avatar') as File;
|
||||||
|
|
||||||
if (!file || !file.size) {
|
const result = await handleAvatarUpload(member, file, { useAdmin: true });
|
||||||
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);
|
|
||||||
|
|
||||||
if (!result.success) {
|
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
|
return { success: result.successMessage };
|
||||||
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!' };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
removeAvatar: async ({ locals }) => {
|
removeAvatar: async ({ locals }) => {
|
||||||
|
|
@ -137,31 +76,12 @@ export const actions: Actions = {
|
||||||
return fail(401, { error: 'Not authenticated' });
|
return fail(401, { error: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the avatar from BOTH storage backends using the stored path
|
const result = await handleAvatarRemoval(member, { useAdmin: true });
|
||||||
if (member.avatar_path) {
|
|
||||||
await deleteAvatar(member.id, member.avatar_path);
|
if (!result.success) {
|
||||||
} else {
|
return fail(500, { error: result.error });
|
||||||
// Fallback: try to delete common extensions
|
|
||||||
await deleteAvatar(member.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update member record to clear all avatar URLs (use admin client to bypass RLS)
|
return { success: result.successMessage };
|
||||||
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!' };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { uploadAvatar, deleteAvatar } from '$lib/server/storage';
|
|
||||||
import { sendTemplatedEmail, wrapInMonacoTemplate, sendEmail } from '$lib/server/email';
|
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';
|
import { getMailbox, updateMailbox, type PosteConfig } from '$lib/server/poste';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||||
|
|
@ -64,39 +64,22 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
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;
|
const nationalityString = formData.get('nationality') as string;
|
||||||
|
|
||||||
// Validation
|
const result = await handleProfileUpdate(
|
||||||
if (!firstName || firstName.length < 2) {
|
member.id,
|
||||||
return fail(400, { error: 'First name must be at least 2 characters' });
|
{
|
||||||
}
|
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) {
|
if (!result.success) {
|
||||||
return fail(400, { error: 'Last name must be at least 2 characters' });
|
return fail(400, { error: result.error });
|
||||||
}
|
|
||||||
|
|
||||||
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.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: 'Profile updated successfully!' };
|
return { success: 'Profile updated successfully!' };
|
||||||
|
|
@ -112,43 +95,16 @@ export const actions: Actions = {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('avatar') as File;
|
const file = formData.get('avatar') as File;
|
||||||
|
|
||||||
if (!file || !file.size) {
|
const result = await handleAvatarUpload(member, file, {
|
||||||
return fail(400, { error: 'Please select an image to upload' });
|
useAdmin: false,
|
||||||
}
|
supabase: locals.supabase
|
||||||
|
});
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (!result.success) {
|
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
|
return { success: result.successMessage };
|
||||||
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!' };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
removeAvatar: async ({ locals }) => {
|
removeAvatar: async ({ locals }) => {
|
||||||
|
|
@ -158,24 +114,16 @@ export const actions: Actions = {
|
||||||
return fail(401, { error: 'Not authenticated' });
|
return fail(401, { error: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the avatar from storage - pass user's supabase client for RLS
|
const result = await handleAvatarRemoval(member, {
|
||||||
await deleteAvatar(member.id, locals.supabase);
|
useAdmin: false,
|
||||||
|
supabase: locals.supabase
|
||||||
|
});
|
||||||
|
|
||||||
// Update member record to remove avatar URL
|
if (!result.success) {
|
||||||
const { error: updateError } = await locals.supabase
|
return fail(500, { error: result.error });
|
||||||
.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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: 'Profile picture removed!' };
|
return { success: result.successMessage };
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNotifications: async ({ request, locals }) => {
|
updateNotifications: async ({ request, locals }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { checkRateLimit } from '$lib/server/rate-limit';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
return {};
|
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, {
|
const { error } = await locals.supabase.auth.resetPasswordForEmail(email, {
|
||||||
redirectTo: `${url.origin}/auth/reset-password`
|
redirectTo: `${url.origin}/auth/reset-password`
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
const { session } = await locals.safeGetSession();
|
const { session } = await locals.safeGetSession();
|
||||||
|
|
@ -29,7 +31,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirectTo: url.searchParams.get('redirectTo') || '/dashboard',
|
redirectTo: sanitizeRedirectUrl(url.searchParams.get('redirectTo')),
|
||||||
urlError: errorMessage,
|
urlError: errorMessage,
|
||||||
urlSuccess: successMessage
|
urlSuccess: successMessage
|
||||||
};
|
};
|
||||||
|
|
@ -40,7 +42,7 @@ export const actions: Actions = {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const password = formData.get('password') 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) {
|
if (!email || !password) {
|
||||||
return fail(400, {
|
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({
|
const { data, error } = await locals.supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password
|
password
|
||||||
|
|
@ -93,6 +106,9 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear rate limit on successful login
|
||||||
|
resetRateLimit(rateLimitKey);
|
||||||
|
|
||||||
throw redirect(303, redirectTo);
|
throw redirect(303, redirectTo);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const { session } = await locals.safeGetSession();
|
const { session } = await locals.safeGetSession();
|
||||||
|
|
@ -147,76 +147,27 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the default membership status (pending)
|
// Create member profile using shared helper
|
||||||
const { data: defaultStatus, error: statusError } = await locals.supabase
|
const memberResult = await createMemberRecord(
|
||||||
.from('membership_statuses')
|
{
|
||||||
.select('id')
|
userId: authData.user.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,
|
|
||||||
email,
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
phone,
|
phone,
|
||||||
date_of_birth: dateOfBirth,
|
dateOfBirth,
|
||||||
address
|
address,
|
||||||
});
|
nationality
|
||||||
}
|
},
|
||||||
|
locals.supabase,
|
||||||
|
{ generateMemberId: true }
|
||||||
|
);
|
||||||
|
|
||||||
if (typeError || !defaultType?.id) {
|
if (!memberResult.success) {
|
||||||
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) {
|
|
||||||
// Clean up the auth user since member profile creation failed
|
// Clean up the auth user since member profile creation failed
|
||||||
console.error('Failed to create member profile:', memberError);
|
await cleanupAuthUser(authData.user.id);
|
||||||
try {
|
|
||||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
|
||||||
} catch (deleteError) {
|
|
||||||
console.error('Failed to clean up auth user:', deleteError);
|
|
||||||
}
|
|
||||||
return fail(500, {
|
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,
|
first_name: firstName,
|
||||||
last_name: lastName,
|
last_name: lastName,
|
||||||
email,
|
email,
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,11 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row">
|
<div class="flex flex-col gap-4 sm:flex-row">
|
||||||
<Button variant="monaco" size="xl">
|
<Button variant="monaco" size="xl" href="/login">
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="monaco-outline" size="xl">
|
<Button variant="monaco-outline" size="xl" href="/join">
|
||||||
Learn More
|
Join Us
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sanitizeRedirectUrl } from '$lib/server/auth-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth callback handler for email verification and OAuth redirects
|
* 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 }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
const code = url.searchParams.get('code');
|
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 error = url.searchParams.get('error');
|
||||||
const errorDescription = url.searchParams.get('error_description');
|
const errorDescription = url.searchParams.get('error_description');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,12 @@
|
||||||
<!-- Logo and Branding -->
|
<!-- Logo and Branding -->
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<a href="/" class="inline-flex flex-col items-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">
|
<div class="mb-4 overflow-hidden rounded-2xl bg-white/90 p-2 shadow-xl backdrop-blur-sm">
|
||||||
<span class="text-2xl font-bold text-white">M</span>
|
<img
|
||||||
|
src="/MONACOUSA-Flags_376x376.png"
|
||||||
|
alt="Monaco USA"
|
||||||
|
class="h-16 w-16 object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-slate-900">
|
<h1 class="text-2xl font-bold text-slate-900">
|
||||||
Monaco <span class="text-monaco-600">USA</span>
|
Monaco <span class="text-monaco-600">USA</span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { supabaseAdmin } from '$lib/server/supabase';
|
import { createMemberRecord, cleanupAuthUser, sendWelcomeEmail } from '$lib/server/registration';
|
||||||
import { sendTemplatedEmail } from '$lib/server/email';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
const { session } = await locals.safeGetSession();
|
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 });
|
return fail(500, { error: 'Failed to create account. Please try again.', step: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the pending membership status
|
// Create member profile using shared helper
|
||||||
const { data: pendingStatus } = await locals.supabase
|
const memberResult = await createMemberRecord(
|
||||||
.from('membership_statuses')
|
{
|
||||||
.select('id')
|
userId: authData.user.id,
|
||||||
.eq('name', 'pending')
|
email,
|
||||||
.single();
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
dateOfBirth,
|
||||||
|
address,
|
||||||
|
nationality
|
||||||
|
},
|
||||||
|
locals.supabase,
|
||||||
|
{ statusName: 'pending', generateMemberId: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Get the default membership type
|
if (!memberResult.success) {
|
||||||
const { data: defaultType } = await locals.supabase
|
await cleanupAuthUser(authData.user.id);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
return fail(500, {
|
return fail(500, {
|
||||||
error: 'Failed to create member profile. Please try again.',
|
error: memberResult.error || 'Failed to create member profile.',
|
||||||
step: 2
|
step: 2
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const memberId = memberResult.memberId;
|
||||||
|
|
||||||
// Sign in the user so they can continue the wizard
|
// Sign in the user so they can continue the wizard
|
||||||
const { error: signInError } = await locals.supabase.auth.signInWithPassword({
|
const { error: signInError } = await locals.supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
|
|
@ -312,34 +279,18 @@ export const actions: Actions = {
|
||||||
|
|
||||||
// Send welcome email with payment instructions
|
// Send welcome email with payment instructions
|
||||||
if (member) {
|
if (member) {
|
||||||
try {
|
await sendWelcomeEmail(
|
||||||
await sendTemplatedEmail(
|
{
|
||||||
'onboarding_welcome',
|
id: session.user.id,
|
||||||
member.email,
|
first_name: member.first_name,
|
||||||
{
|
email: member.email,
|
||||||
first_name: member.first_name,
|
member_id: member.member_id
|
||||||
member_id: member.member_id || 'N/A',
|
},
|
||||||
amount: `€${defaultType?.annual_dues || 150}`,
|
paymentSettings,
|
||||||
payment_deadline: paymentDeadline.toLocaleDateString('en-US', {
|
defaultType?.annual_dues || 150,
|
||||||
weekday: 'long',
|
paymentDeadline
|
||||||
year: 'numeric',
|
);
|
||||||
month: 'long',
|
// Errors are logged internally; not critical to block on failure
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('fr-MC', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD'
|
currency: 'EUR'
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue