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

- 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:
2026-01-26 09:36:25 +01:00
parent a450e1afd9
commit 5bbf26e7a1
8 changed files with 469 additions and 19 deletions

View File

@@ -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
};
};

View File

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