diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml index fb84046..f8c7233 100644 --- a/docker-compose.nginx.yml +++ b/docker-compose.nginx.yml @@ -207,7 +207,7 @@ services: PORT: 4000 DB_HOST: db DB_PORT: 5432 - DB_USER: supabase_admin + DB_USER: ${POSTGRES_USER} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: ${POSTGRES_DB} DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' @@ -257,7 +257,7 @@ services: db: condition: service_healthy rest: - condition: service_healthy + condition: service_started healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"] interval: 10s @@ -310,7 +310,7 @@ services: PG_META_DB_HOST: db PG_META_DB_PORT: 5432 PG_META_DB_NAME: ${POSTGRES_DB} - PG_META_DB_USER: supabase_admin + PG_META_DB_USER: ${POSTGRES_USER} PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} depends_on: db: diff --git a/docker-compose.yml b/docker-compose.yml index cd12f0f..70adce5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,8 @@ services: JWT_EXP: ${JWT_EXPIRY:-3600} volumes: - db-data:/var/lib/postgresql/data - - ./supabase/migrations:/docker-entrypoint-initdb.d + # Migrations mounted separately - DO NOT use /docker-entrypoint-initdb.d (overwrites Supabase init) + - ./supabase/migrations:/migrations:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s @@ -156,7 +157,7 @@ services: networks: - monacousa-network - # ============================================ + # ============================================ # Realtime # ============================================ realtime: @@ -167,7 +168,7 @@ services: PORT: 4000 DB_HOST: db DB_PORT: 5432 - DB_USER: supabase_admin + DB_USER: ${POSTGRES_USER} DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} DB_NAME: ${POSTGRES_DB:-postgres} DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' @@ -212,7 +213,7 @@ services: db: condition: service_healthy rest: - condition: service_healthy + condition: service_started healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"] interval: 10s @@ -255,7 +256,7 @@ services: PG_META_DB_HOST: db PG_META_DB_PORT: 5432 PG_META_DB_NAME: ${POSTGRES_DB:-postgres} - PG_META_DB_USER: supabase_admin + PG_META_DB_USER: ${POSTGRES_USER} PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} depends_on: db: @@ -273,13 +274,7 @@ services: # Monaco USA Portal (SvelteKit App) # ============================================ portal: - build: - context: . - dockerfile: Dockerfile - args: - PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455} - PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} - SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + image: code.letsbe.solutions/letsbe/monacousa-portal:latest container_name: monacousa-portal restart: unless-stopped ports: @@ -300,7 +295,6 @@ services: condition: service_healthy networks: - monacousa-network - # ============================================ # Networks # ============================================ @@ -315,4 +309,4 @@ volumes: db-data: driver: local storage-data: - driver: local + driver: local \ No newline at end of file diff --git a/src/app.html b/src/app.html index f273cc5..1f4cb8d 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,10 @@ + + + + %sveltekit.head% diff --git a/src/hooks.server.ts b/src/hooks.server.ts index eb422c7..15a341d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,6 +5,11 @@ import { sequence } from '@sveltejs/kit/hooks'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { env } from '$env/dynamic/private'; import type { Database } from '$lib/types/database'; +import { supabaseAdmin } from '$lib/server/supabase'; + +// Cache for setup check to avoid hitting DB on every request +let setupCheckCache: { needsSetup: boolean; checkedAt: number } | null = null; +const SETUP_CACHE_TTL = 60000; // 1 minute cache // Use internal URL for server-side operations (Docker network), fallback to public URL const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL; @@ -69,6 +74,56 @@ const supabaseHandle: Handle = async ({ event, resolve }) => { }); }; +/** + * Setup check hook + * Redirects to /setup if no users exist in the system + */ +const setupCheckHandle: Handle = async ({ event, resolve }) => { + const path = event.url.pathname; + + // Always allow access to setup page and static assets + if (path === '/setup' || path.startsWith('/api/') || path.startsWith('/auth/')) { + return resolve(event); + } + + // Check cache first + const now = Date.now(); + if (setupCheckCache && (now - setupCheckCache.checkedAt) < SETUP_CACHE_TTL) { + if (setupCheckCache.needsSetup) { + throw redirect(303, '/setup'); + } + return resolve(event); + } + + // Check if any users exist + try { + const { count, error } = await supabaseAdmin + .from('members') + .select('*', { count: 'exact', head: true }); + + if (error) { + console.error('Error checking for existing users:', error); + // On error, continue without redirect (fail open) + return resolve(event); + } + + const needsSetup = !count || count === 0; + setupCheckCache = { needsSetup, checkedAt: now }; + + if (needsSetup) { + throw redirect(303, '/setup'); + } + } catch (err) { + // If it's a redirect, rethrow it + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + console.error('Error in setup check:', err); + } + + return resolve(event); +}; + /** * Authorization hook * Protects routes based on authentication and role requirements @@ -77,6 +132,11 @@ const authorizationHandle: Handle = async ({ event, resolve }) => { const { session, member } = await event.locals.safeGetSession(); const path = event.url.pathname; + // Setup page is handled by setupCheckHandle + if (path === '/setup') { + return resolve(event); + } + // API routes handle their own authentication if (path.startsWith('/api/')) { return resolve(event); @@ -131,4 +191,4 @@ const authorizationHandle: Handle = async ({ event, resolve }) => { return resolve(event); }; -export const handle: Handle = sequence(supabaseHandle, authorizationHandle); +export const handle: Handle = sequence(supabaseHandle, setupCheckHandle, authorizationHandle); diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index b2cd18c..2609238 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -21,9 +21,17 @@ export const load: PageServerLoad = async ({ url, locals }) => { errorMessage = decodeURIComponent(errorCode); } + // Handle setup completion message + const setupComplete = url.searchParams.get('setup') === 'complete'; + let successMessage: string | null = null; + if (setupComplete) { + successMessage = 'Admin account created successfully! Please sign in to continue.'; + } + return { redirectTo: url.searchParams.get('redirectTo') || '/dashboard', - urlError: errorMessage + urlError: errorMessage, + urlSuccess: successMessage }; }; diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index 0929262..8472818 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -24,6 +24,10 @@ {/if} + {#if data.urlSuccess} + + {/if} + {#if form?.error} {/if} diff --git a/src/routes/setup/+page.server.ts b/src/routes/setup/+page.server.ts new file mode 100644 index 0000000..bb983f6 --- /dev/null +++ b/src/routes/setup/+page.server.ts @@ -0,0 +1,208 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const load: PageServerLoad = async () => { + // Check if any users exist in the system + const { count, error } = await supabaseAdmin + .from('members') + .select('*', { count: 'exact', head: true }); + + if (error) { + console.error('Error checking for existing users:', error); + // If we can't check, assume setup is needed (will fail gracefully later) + return { needsSetup: true }; + } + + // If users already exist, redirect to login + if (count && count > 0) { + throw redirect(303, '/login'); + } + + return { needsSetup: true }; +}; + +export const actions: Actions = { + default: async ({ request, url }) => { + // Double-check that no users exist (prevent race conditions) + const { count: existingCount } = await supabaseAdmin + .from('members') + .select('*', { count: 'exact', head: true }); + + if (existingCount && existingCount > 0) { + return fail(400, { + error: 'Setup has already been completed. Please sign in instead.' + }); + } + + const formData = await request.formData(); + + // Extract form fields + const firstName = formData.get('first_name') as string; + const lastName = formData.get('last_name') as string; + const email = formData.get('email') as string; + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirm_password') as string; + + // Validation + const errors: Record = {}; + + if (!firstName || firstName.length < 2) { + errors.first_name = 'First name must be at least 2 characters'; + } + + if (!lastName || lastName.length < 2) { + errors.last_name = 'Last name must be at least 2 characters'; + } + + if (!email || !email.includes('@')) { + errors.email = 'Please enter a valid email address'; + } + + if (!password || password.length < 8) { + errors.password = 'Password must be at least 8 characters'; + } + + if (password !== confirmPassword) { + errors.confirm_password = 'Passwords do not match'; + } + + // Return validation errors + if (Object.keys(errors).length > 0) { + return fail(400, { + error: Object.values(errors)[0], + first_name: firstName, + last_name: lastName, + email + }); + } + + // Create user with admin service role (auto-confirms email) + const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({ + email, + password, + email_confirm: true, // Auto-confirm email for initial admin + user_metadata: { + first_name: firstName, + last_name: lastName + } + }); + + if (authError) { + console.error('Failed to create admin user:', authError); + return fail(400, { + error: authError.message, + first_name: firstName, + last_name: lastName, + email + }); + } + + if (!authData.user) { + return fail(500, { + error: 'Failed to create admin account. Please try again.', + first_name: firstName, + last_name: lastName, + email + }); + } + + // Get or create default membership status (active for admin) + let statusId: string; + const { data: activeStatus } = await supabaseAdmin + .from('membership_statuses') + .select('id') + .eq('name', 'Active') + .single(); + + if (activeStatus) { + statusId = activeStatus.id; + } else { + // Create active status if it doesn't exist + const { data: newStatus, error: statusError } = await supabaseAdmin + .from('membership_statuses') + .insert({ + name: 'Active', + description: 'Active member in good standing', + is_default: false, + color: '#22c55e' + }) + .select('id') + .single(); + + if (statusError || !newStatus) { + console.error('Failed to create membership status:', statusError); + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { + error: 'Failed to set up membership status. Please try again.', + first_name: firstName, + last_name: lastName, + email + }); + } + statusId = newStatus.id; + } + + // Get or create default membership type + let typeId: string; + const { data: defaultType } = await supabaseAdmin + .from('membership_types') + .select('id') + .eq('is_default', true) + .single(); + + if (defaultType) { + typeId = defaultType.id; + } else { + // Create default membership type if it doesn't exist + const { data: newType, error: typeError } = await supabaseAdmin + .from('membership_types') + .insert({ + name: 'Standard', + description: 'Standard membership', + annual_dues: 0, + is_default: true + }) + .select('id') + .single(); + + if (typeError || !newType) { + console.error('Failed to create membership type:', typeError); + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { + error: 'Failed to set up membership type. Please try again.', + first_name: firstName, + last_name: lastName, + email + }); + } + typeId = newType.id; + } + + // Create admin member profile + const { error: memberError } = await supabaseAdmin.from('members').insert({ + id: authData.user.id, + first_name: firstName, + last_name: lastName, + email, + role: 'admin', // Set as admin + membership_status_id: statusId, + membership_type_id: typeId, + nationality: ['US'] // Default nationality + }); + + if (memberError) { + console.error('Failed to create admin member profile:', memberError); + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { + error: 'Failed to create admin profile. Please try again.', + first_name: firstName, + last_name: lastName, + email + }); + } + + // Success - redirect to login + throw redirect(303, '/login?setup=complete'); + } +}; diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte new file mode 100644 index 0000000..5b7a44a --- /dev/null +++ b/src/routes/setup/+page.svelte @@ -0,0 +1,172 @@ + + + + Initial Setup | Monaco USA + + +
+ +
+ +
+
+ + +
+
+
+
+
+ +
+ +
+
+
+ Monaco USA +
+

+ Monaco USA +

+

Americans in Monaco

+
+
+ + +
+
+
+

Welcome to Monaco USA

+

Create your administrator account to get started

+
+ + +
+
+ + + +
+

Initial Setup Required

+

This is the first time the portal is being accessed. Create an admin account to manage the portal.

+
+
+
+ + {#if form?.error} + + {/if} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" + > +
+ + + +
+ + + + + + + + + +
+
+ + +

+ © 2026 Monaco USA. All rights reserved. +

+
+