diff --git a/deploy/init.sql b/deploy/init.sql
index 9e4589d..44a6a38 100644
--- a/deploy/init.sql
+++ b/deploy/init.sql
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index a271694..83fa87f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
# ============================================
diff --git a/src/lib/components/documents/DocumentPreviewModal.svelte b/src/lib/components/documents/DocumentPreviewModal.svelte
index d2ffbcb..626a156 100644
--- a/src/lib/components/documents/DocumentPreviewModal.svelte
+++ b/src/lib/components/documents/DocumentPreviewModal.svelte
@@ -1,6 +1,7 @@
+
+
diff --git a/src/lib/components/ui/empty-state.svelte b/src/lib/components/ui/empty-state.svelte
new file mode 100644
index 0000000..1930b37
--- /dev/null
+++ b/src/lib/components/ui/empty-state.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#if Icon}
+
+
+
+ {/if}
+
{title}
+ {#if description}
+
{description}
+ {/if}
+ {#if children}
+
+ {@render children()}
+
+ {/if}
+
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts
index 41c3320..53623d7 100644
--- a/src/lib/components/ui/index.ts
+++ b/src/lib/components/ui/index.ts
@@ -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';
diff --git a/src/lib/server/auth-utils.ts b/src/lib/server/auth-utils.ts
new file mode 100644
index 0000000..81baae2
--- /dev/null
+++ b/src/lib/server/auth-utils.ts
@@ -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;
+}
diff --git a/src/lib/server/dues.ts b/src/lib/server/dues.ts
index 1c0ba73..6fedd1f 100644
--- a/src/lib/server/dues.ts
+++ b/src/lib/server/dues.ts
@@ -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