Skip to content

Commit 6c97b07

Browse files
author
Ignacio Van Droogenbroeck
committed
fix(setup): test-email is now a client-side call (no form wipe) + allow /setup/* during first-run
1 parent 8c279c5 commit 6c97b07

4 files changed

Lines changed: 125 additions & 47 deletions

File tree

src/hooks.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,18 @@ export const handle: Handle = async ({ event, resolve }) => {
9191
const db = getDb();
9292
const hasUser = db.prepare('SELECT 1 FROM users WHERE deleted_at IS NULL LIMIT 1').get();
9393

94+
const onSetup = path === '/setup' || path.startsWith('/setup/');
9495
const firstRunAllowed =
95-
path === '/setup' ||
96+
onSetup ||
9697
path.startsWith('/_app/') ||
9798
path.startsWith('/images/') ||
9899
path === '/favicon.ico';
99100
if (!hasUser && !firstRunAllowed) {
100101
return Response.redirect(new URL('/setup', event.url.origin).toString(), 302);
101102
}
102-
// Once setup is complete, the wizard is gone — never serve it again.
103+
// Once setup is complete, the wizard page is gone — redirect it to login.
104+
// (Its sub-routes like /setup/test-email return their own 403, so only the
105+
// page itself is redirected.)
103106
if (hasUser && path === '/setup') {
104107
return Response.redirect(new URL('/login', event.url.origin).toString(), 302);
105108
}

src/routes/(auth)/setup/+page.server.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { PageServerLoad, Actions } from './$types';
44
import { getDb } from '$lib/server/db';
55
import { hashPassword, createToken, sessionCookieOptions } from '$lib/server/auth';
66
import { hasAnyUser, setEmailConfig, type EmailConfig } from '$lib/server/settings';
7-
import { sendTestEmail } from '$lib/server/email';
87

98
// First-run setup wizard. Available ONLY while no account exists; once the
109
// admin is created it is permanently closed (redirects to /login).
@@ -48,23 +47,9 @@ function parseEmailConfig(form: FormData): EmailConfig {
4847
}
4948

5049
export const actions: Actions = {
51-
// Test the email config without creating the admin (used by the "Send test" button).
52-
testEmail: async ({ request }) => {
53-
if (hasAnyUser()) return fail(403, { error: 'Setup already completed.' });
54-
const form = await request.formData();
55-
const to = String(form.get('test_to') || '').trim();
56-
if (!to) return fail(400, { emailError: 'Enter an address to send the test to.' });
57-
const cfg = parseEmailConfig(form);
58-
if (cfg.provider === 'none') return fail(400, { emailError: 'Choose a provider first.' });
59-
try {
60-
await sendTestEmail(cfg, to);
61-
return { emailTested: true };
62-
} catch (err: any) {
63-
return fail(400, { emailError: `Test failed: ${err.message}` });
64-
}
65-
},
66-
6750
// Create the first admin account (and persist email config if provided).
51+
// (The "Send test" button uses the /setup/test-email endpoint instead, so it
52+
// never submits this form and never wipes the entered data.)
6853
complete: async ({ request, cookies }) => {
6954
if (hasAnyUser()) throw redirect(302, '/login');
7055

src/routes/(auth)/setup/+page.svelte

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,62 @@
77
export let form: ActionData;
88
99
let step = 1;
10-
let provider: 'none' | 'smtp' | 'mailgun' = 'none';
1110
let submitting = false;
11+
12+
// All fields are bound so their values survive any re-render (notably the
13+
// "Send test" round-trip, which previously wiped unbound inputs).
14+
let firstName = '';
15+
let lastName = '';
16+
let email = '';
17+
let password = '';
18+
19+
let provider: 'none' | 'smtp' | 'mailgun' = 'none';
20+
let from = '';
21+
let smtpHost = '';
22+
let smtpPort = 587;
23+
let smtpSecure = false;
24+
let smtpUser = '';
25+
let smtpPass = '';
26+
let mgDomain = '';
27+
let mgApiKey = '';
28+
let mgApiUrl = '';
29+
30+
// Test email is a pure client-side call — it never submits the form, so the
31+
// entered data is preserved.
32+
let testTo = '';
1233
let testing = false;
34+
let testError = '';
35+
let testOk = false;
36+
37+
function emailPayload() {
38+
if (provider === 'smtp') {
39+
return { provider, from, host: smtpHost, port: smtpPort, secure: smtpSecure, user: smtpUser, pass: smtpPass };
40+
}
41+
if (provider === 'mailgun') {
42+
return { provider, from, domain: mgDomain, apiKey: mgApiKey, apiUrl: mgApiUrl };
43+
}
44+
return { provider: 'none' };
45+
}
46+
47+
async function sendTest() {
48+
testing = true;
49+
testError = '';
50+
testOk = false;
51+
try {
52+
const res = await fetch('/setup/test-email', {
53+
method: 'POST',
54+
headers: { 'Content-Type': 'application/json' },
55+
body: JSON.stringify({ ...emailPayload(), to: testTo }),
56+
});
57+
const data = await res.json();
58+
if (!res.ok) throw new Error(data.error || 'Test failed');
59+
testOk = true;
60+
} catch (err: any) {
61+
testError = err.message;
62+
} finally {
63+
testing = false;
64+
}
65+
}
1366
1467
// Shared input styling — matches the login/signup fields.
1568
const inputClass =
@@ -71,20 +124,20 @@
71124
<div class="grid grid-cols-2 gap-3">
72125
<div>
73126
<label for="first_name" class="mb-1.5 block text-sm font-medium">First name</label>
74-
<input id="first_name" name="first_name" autocomplete="given-name" class={inputClass} />
127+
<input id="first_name" name="first_name" bind:value={firstName} autocomplete="given-name" class={inputClass} />
75128
</div>
76129
<div>
77130
<label for="last_name" class="mb-1.5 block text-sm font-medium">Last name</label>
78-
<input id="last_name" name="last_name" autocomplete="family-name" class={inputClass} />
131+
<input id="last_name" name="last_name" bind:value={lastName} autocomplete="family-name" class={inputClass} />
79132
</div>
80133
</div>
81134
<div>
82135
<label for="email" class="mb-1.5 block text-sm font-medium">Email</label>
83-
<input id="email" name="email" type="email" required autocomplete="email" class={inputClass} />
136+
<input id="email" name="email" type="email" required bind:value={email} autocomplete="email" class={inputClass} />
84137
</div>
85138
<div>
86139
<label for="password" class="mb-1.5 block text-sm font-medium">Password</label>
87-
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password" class={inputClass} />
140+
<input id="password" name="password" type="password" required minlength="8" bind:value={password} autocomplete="new-password" class={inputClass} />
88141
<p class="mt-1.5 text-xs text-muted-foreground">At least 8 characters, with an uppercase letter and a number or symbol.</p>
89142
</div>
90143
<Button type="button" class="w-full" on:click={() => (step = 2)}>Continue</Button>
@@ -104,76 +157,67 @@
104157
{#if provider !== 'none'}
105158
<div>
106159
<label for="from" class="mb-1.5 block text-sm font-medium">From address</label>
107-
<input id="from" name="from" placeholder="Arc Launchpad <noreply@example.com>" class={inputClass} />
160+
<input id="from" name="from" bind:value={from} placeholder="Arc Launchpad <noreply@example.com>" class={inputClass} />
108161
</div>
109162
{/if}
110163

111164
{#if provider === 'smtp'}
112165
<div class="grid grid-cols-3 gap-3">
113166
<div class="col-span-2">
114167
<label for="smtp_host" class="mb-1.5 block text-sm font-medium">Host</label>
115-
<input id="smtp_host" name="smtp_host" placeholder="smtp.example.com" class={inputClass} />
168+
<input id="smtp_host" name="smtp_host" bind:value={smtpHost} placeholder="smtp.example.com" class={inputClass} />
116169
</div>
117170
<div>
118171
<label for="smtp_port" class="mb-1.5 block text-sm font-medium">Port</label>
119-
<input id="smtp_port" name="smtp_port" type="number" value="587" class={inputClass} />
172+
<input id="smtp_port" name="smtp_port" type="number" bind:value={smtpPort} class={inputClass} />
120173
</div>
121174
</div>
122175
<div class="grid grid-cols-2 gap-3">
123176
<div>
124177
<label for="smtp_user" class="mb-1.5 block text-sm font-medium">Username</label>
125-
<input id="smtp_user" name="smtp_user" autocomplete="off" class={inputClass} />
178+
<input id="smtp_user" name="smtp_user" bind:value={smtpUser} autocomplete="off" class={inputClass} />
126179
</div>
127180
<div>
128181
<label for="smtp_pass" class="mb-1.5 block text-sm font-medium">Password</label>
129-
<input id="smtp_pass" name="smtp_pass" type="password" autocomplete="off" class={inputClass} />
182+
<input id="smtp_pass" name="smtp_pass" type="password" bind:value={smtpPass} autocomplete="off" class={inputClass} />
130183
</div>
131184
</div>
132185
<label class="flex items-center gap-2 text-sm">
133-
<input type="checkbox" name="smtp_secure" class="h-4 w-4 rounded border-input accent-primary" />
186+
<input type="checkbox" name="smtp_secure" bind:checked={smtpSecure} class="h-4 w-4 rounded border-input accent-primary" />
134187
<span class="text-muted-foreground">Use TLS (port 465)</span>
135188
</label>
136189
{/if}
137190

138191
{#if provider === 'mailgun'}
139192
<div>
140193
<label for="mg_domain" class="mb-1.5 block text-sm font-medium">Mailgun domain</label>
141-
<input id="mg_domain" name="mg_domain" placeholder="mg.example.com" class={inputClass} />
194+
<input id="mg_domain" name="mg_domain" bind:value={mgDomain} placeholder="mg.example.com" class={inputClass} />
142195
</div>
143196
<div>
144197
<label for="mg_api_key" class="mb-1.5 block text-sm font-medium">API key</label>
145-
<input id="mg_api_key" name="mg_api_key" type="password" autocomplete="off" class={inputClass} />
198+
<input id="mg_api_key" name="mg_api_key" type="password" bind:value={mgApiKey} autocomplete="off" class={inputClass} />
146199
</div>
147200
<div>
148201
<label for="mg_api_url" class="mb-1.5 block text-sm font-medium">API base URL</label>
149-
<input id="mg_api_url" name="mg_api_url" placeholder="https://api.mailgun.net" class={inputClass} />
202+
<input id="mg_api_url" name="mg_api_url" bind:value={mgApiUrl} placeholder="https://api.mailgun.net" class={inputClass} />
150203
</div>
151204
{/if}
152205

153206
{#if provider !== 'none'}
154-
{#if form?.emailError}
155-
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{form.emailError}</div>
207+
{#if testError}
208+
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{testError}</div>
156209
{/if}
157-
{#if form?.emailTested}
210+
{#if testOk}
158211
<div class="flex items-center gap-1.5 rounded-md bg-green-500/10 p-3 text-sm text-green-600">
159212
<Check class="h-4 w-4" /> Test email sent — check the inbox.
160213
</div>
161214
{/if}
162215
<div class="flex items-end gap-2">
163216
<div class="flex-1">
164217
<label for="test_to" class="mb-1.5 block text-sm font-medium">Send a test to</label>
165-
<input id="test_to" name="test_to" type="email" placeholder="you@example.com" class={inputClass} />
218+
<input id="test_to" type="email" bind:value={testTo} placeholder="you@example.com" class={inputClass} />
166219
</div>
167-
<Button
168-
type="submit"
169-
variant="outline"
170-
formaction="?/testEmail"
171-
disabled={testing}
172-
on:click={() => {
173-
testing = true;
174-
setTimeout(() => (testing = false), 4000);
175-
}}
176-
>
220+
<Button type="button" variant="outline" disabled={testing || !testTo} on:click={sendTest}>
177221
{testing ? 'Sending…' : 'Send test'}
178222
</Button>
179223
</div>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { json } from '@sveltejs/kit';
2+
import type { RequestHandler } from './$types';
3+
import { hasAnyUser, type EmailConfig } from '$lib/server/settings';
4+
import { sendTestEmail } from '$lib/server/email';
5+
6+
// Client-side test-email endpoint for the setup wizard. Available only during
7+
// first-run (no users yet); once an admin exists, use Settings → Email instead.
8+
export const POST: RequestHandler = async ({ request }) => {
9+
if (hasAnyUser()) {
10+
return json({ error: 'Setup already completed.' }, { status: 403 });
11+
}
12+
13+
const body = await request.json();
14+
const to = String(body?.to || '').trim();
15+
if (!to) return json({ error: 'Enter an address to send the test to.' }, { status: 400 });
16+
17+
let cfg: EmailConfig;
18+
if (body.provider === 'smtp') {
19+
cfg = {
20+
provider: 'smtp',
21+
from: String(body.from || '').trim(),
22+
host: String(body.host || '').trim(),
23+
port: parseInt(String(body.port || '587'), 10),
24+
secure: !!body.secure,
25+
user: body.user ? String(body.user).trim() : undefined,
26+
pass: body.pass ? String(body.pass) : undefined,
27+
};
28+
} else if (body.provider === 'mailgun') {
29+
cfg = {
30+
provider: 'mailgun',
31+
from: String(body.from || '').trim(),
32+
domain: String(body.domain || '').trim(),
33+
apiKey: String(body.apiKey || '').trim(),
34+
apiUrl: body.apiUrl ? String(body.apiUrl).trim() : undefined,
35+
};
36+
} else {
37+
return json({ error: 'Choose a provider first.' }, { status: 400 });
38+
}
39+
40+
try {
41+
await sendTestEmail(cfg, to);
42+
return json({ ok: true });
43+
} catch (err: any) {
44+
return json({ error: `Test failed: ${err.message}` }, { status: 400 });
45+
}
46+
};

0 commit comments

Comments
 (0)