Add initial admin setup page and favicon support
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m2s
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:
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
208
src/routes/setup/+page.server.ts
Normal file
208
src/routes/setup/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
172
src/routes/setup/+page.svelte
Normal file
172
src/routes/setup/+page.svelte
Normal 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">
|
||||
© 2026 Monaco USA. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user