Add initial admin setup page and favicon support
Build and Push Docker Image / build (push) Successful in 2m2s Details

- Add /setup route for first-run admin user creation
- Add setup check hook to redirect to /setup when no users exist
- Fix storage container dependency (service_started vs service_healthy)
- Fix migrations mount path (don't overwrite Supabase init scripts)
- Add favicon and apple touch icon links to app.html
- Show success message on login after setup completion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-01-26 09:36:25 +01:00
parent a450e1afd9
commit 5bbf26e7a1
8 changed files with 469 additions and 19 deletions

View File

@ -207,7 +207,7 @@ services:
PORT: 4000 PORT: 4000
DB_HOST: db DB_HOST: db
DB_PORT: 5432 DB_PORT: 5432
DB_USER: supabase_admin DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD} DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB} DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
@ -257,7 +257,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
rest: rest:
condition: service_healthy condition: service_started
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
interval: 10s interval: 10s
@ -310,7 +310,7 @@ services:
PG_META_DB_HOST: db PG_META_DB_HOST: db
PG_META_DB_PORT: 5432 PG_META_DB_PORT: 5432
PG_META_DB_NAME: ${POSTGRES_DB} PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: supabase_admin PG_META_DB_USER: ${POSTGRES_USER}
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
depends_on: depends_on:
db: db:

View File

@ -19,7 +19,8 @@ services:
JWT_EXP: ${JWT_EXPIRY:-3600} JWT_EXP: ${JWT_EXPIRY:-3600}
volumes: volumes:
- db-data:/var/lib/postgresql/data - 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: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s interval: 10s
@ -156,7 +157,7 @@ services:
networks: networks:
- monacousa-network - monacousa-network
# ============================================ # ============================================
# Realtime # Realtime
# ============================================ # ============================================
realtime: realtime:
@ -167,7 +168,7 @@ services:
PORT: 4000 PORT: 4000
DB_HOST: db DB_HOST: db
DB_PORT: 5432 DB_PORT: 5432
DB_USER: supabase_admin DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DB_NAME: ${POSTGRES_DB:-postgres} DB_NAME: ${POSTGRES_DB:-postgres}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
@ -212,7 +213,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
rest: rest:
condition: service_healthy condition: service_started
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
interval: 10s interval: 10s
@ -255,7 +256,7 @@ services:
PG_META_DB_HOST: db PG_META_DB_HOST: db
PG_META_DB_PORT: 5432 PG_META_DB_PORT: 5432
PG_META_DB_NAME: ${POSTGRES_DB:-postgres} 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} PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
depends_on: depends_on:
db: db:
@ -273,13 +274,7 @@ services:
# Monaco USA Portal (SvelteKit App) # Monaco USA Portal (SvelteKit App)
# ============================================ # ============================================
portal: portal:
build: image: code.letsbe.solutions/letsbe/monacousa-portal:latest
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}
container_name: monacousa-portal container_name: monacousa-portal
restart: unless-stopped restart: unless-stopped
ports: ports:
@ -300,7 +295,6 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- monacousa-network - monacousa-network
# ============================================ # ============================================
# Networks # Networks
# ============================================ # ============================================

View File

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192x192.png" />
<meta name="theme-color" content="#dc2626" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@ -5,6 +5,11 @@ import { sequence } from '@sveltejs/kit/hooks';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import type { Database } from '$lib/types/database'; 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 // Use internal URL for server-side operations (Docker network), fallback to public URL
const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_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 * Authorization hook
* Protects routes based on authentication and role requirements * 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 { session, member } = await event.locals.safeGetSession();
const path = event.url.pathname; const path = event.url.pathname;
// Setup page is handled by setupCheckHandle
if (path === '/setup') {
return resolve(event);
}
// API routes handle their own authentication // API routes handle their own authentication
if (path.startsWith('/api/')) { if (path.startsWith('/api/')) {
return resolve(event); return resolve(event);
@ -131,4 +191,4 @@ const authorizationHandle: Handle = async ({ event, resolve }) => {
return resolve(event); return resolve(event);
}; };
export const handle: Handle = sequence(supabaseHandle, authorizationHandle); export const handle: Handle = sequence(supabaseHandle, setupCheckHandle, authorizationHandle);

View File

@ -21,9 +21,17 @@ export const load: PageServerLoad = async ({ url, locals }) => {
errorMessage = decodeURIComponent(errorCode); 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 { return {
redirectTo: url.searchParams.get('redirectTo') || '/dashboard', redirectTo: url.searchParams.get('redirectTo') || '/dashboard',
urlError: errorMessage urlError: errorMessage,
urlSuccess: successMessage
}; };
}; };

View File

@ -24,6 +24,10 @@
<FormMessage type="error" message={data.urlError} /> <FormMessage type="error" message={data.urlError} />
{/if} {/if}
{#if data.urlSuccess}
<FormMessage type="success" message={data.urlSuccess} />
{/if}
{#if form?.error} {#if form?.error}
<FormMessage type="error" message={form.error} /> <FormMessage type="error" message={form.error} />
{/if} {/if}

View File

@ -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<string, string> = {};
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');
}
};

View File

@ -0,0 +1,172 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { FormField, FormMessage, LoadingSpinner } from '$lib/components/auth';
let { form } = $props();
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let loading = $state(false);
</script>
<svelte:head>
<title>Initial Setup | Monaco USA</title>
</svelte:head>
<div class="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-12">
<!-- Background image -->
<div class="absolute inset-0 -z-20">
<img
src="/monaco_high_res.jpg"
alt=""
class="h-full w-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-br from-slate-900/80 via-slate-900/60 to-monaco-900/70"></div>
</div>
<!-- Decorative blur elements -->
<div class="absolute inset-0 -z-10">
<div class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-monaco-500/30 blur-3xl"></div>
<div class="absolute -right-40 top-1/3 h-96 w-96 rounded-full bg-monaco-400/20 blur-3xl"></div>
<div class="absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-white/10 blur-3xl"></div>
</div>
<div class="w-full max-w-md">
<!-- Logo and Branding -->
<div class="mb-8 text-center">
<div class="inline-flex flex-col items-center">
<div class="mb-4 overflow-hidden rounded-2xl bg-white/90 p-2 shadow-xl backdrop-blur-sm">
<img
src="/MONACOUSA-Flags_376x376.png"
alt="Monaco USA"
class="h-20 w-20 object-contain"
/>
</div>
<h1 class="text-2xl font-bold text-white drop-shadow-lg">
Monaco <span class="text-monaco-300">USA</span>
</h1>
<p class="mt-1 text-sm text-white/80">Americans in Monaco</p>
</div>
</div>
<!-- Setup Card Container -->
<div class="rounded-2xl bg-white p-8 shadow-xl">
<div class="space-y-6">
<div class="text-center">
<h2 class="text-xl font-semibold text-slate-900">Welcome to Monaco USA</h2>
<p class="mt-1 text-sm text-slate-500">Create your administrator account to get started</p>
</div>
<!-- Setup Notice -->
<div class="rounded-lg bg-blue-50 p-4 text-sm text-blue-800">
<div class="flex items-start gap-3">
<svg class="h-5 w-5 flex-shrink-0 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="font-medium">Initial Setup Required</p>
<p class="mt-1 text-blue-700">This is the first time the portal is being accessed. Create an admin account to manage the portal.</p>
</div>
</div>
</div>
{#if form?.error}
<FormMessage type="error" message={form.error} />
{/if}
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4">
<FormField
label="First name"
name="first_name"
type="text"
placeholder="John"
required
disabled={loading}
bind:value={firstName}
autocomplete="given-name"
/>
<FormField
label="Last name"
name="last_name"
type="text"
placeholder="Doe"
required
disabled={loading}
bind:value={lastName}
autocomplete="family-name"
/>
</div>
<FormField
label="Email address"
name="email"
type="email"
placeholder="admin@monacousa.org"
required
disabled={loading}
bind:value={email}
autocomplete="email"
/>
<FormField
label="Password"
name="password"
type="password"
placeholder="At least 8 characters"
required
disabled={loading}
bind:value={password}
autocomplete="new-password"
/>
<FormField
label="Confirm password"
name="confirm_password"
type="password"
placeholder="Confirm your password"
required
disabled={loading}
bind:value={confirmPassword}
autocomplete="new-password"
/>
<Button
type="submit"
variant="monaco"
size="lg"
class="w-full"
disabled={loading}
>
{#if loading}
<LoadingSpinner size="sm" class="mr-2" />
Creating admin account...
{:else}
Create Admin Account
{/if}
</Button>
</form>
</div>
</div>
<!-- Footer -->
<p class="mt-6 text-center text-xs text-white/60">
&copy; 2026 Monaco USA. All rights reserved.
</p>
</div>
</div>