Refactor login page to support user registration. Enhanced form handling by adding name and password confirmation fields, improved error messaging, and updated button labels for better user experience. Adjusted internal server options to reflect changes in form data structure.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m52s

This commit is contained in:
eewing
2026-02-26 15:11:24 -06:00
parent 790357cc13
commit 60ac4a8f79
4 changed files with 204 additions and 41 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ export const options = {
app: ({ head, body, assets, nonce, env }) => "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, viewport-fit=cover\" />\n <meta name=\"theme-color\" content=\"#0f172a\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n", app: ({ head, body, assets, nonce, env }) => "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, viewport-fit=cover\" />\n <meta name=\"theme-color\" content=\"#0f172a\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n" error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
}, },
version_hash: "1pywl2x" version_hash: "3lzvrw"
}; };
export async function get_hooks() { export async function get_hooks() {
@@ -8,37 +8,97 @@ export const load = async ({ url, locals }: Parameters<PageServerLoad>[0]) => {
} }
return { return {
redirectTo: url.searchParams.get('redirectTo') ?? '', redirectTo: url.searchParams.get('redirectTo') ?? '',
error: null error: null,
form: null as { error?: string; redirectTo?: string } | null
}; };
}; };
export const actions = { const loginAction = async ({
default: async ({ request, locals, url }: import('./$types').RequestEvent) => { request,
locals
}: {
request: Request;
locals: App.Locals;
}) => {
const formData = await request.formData(); const formData = await request.formData();
const intent = String(formData.get('intent') ?? 'login').trim();
const name = String(formData.get('name') ?? '').trim();
const email = String(formData.get('email') ?? '').trim(); const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? ''); const password = String(formData.get('password') ?? '');
const passwordConfirm = String(formData.get('passwordConfirm') ?? '');
const redirectTo = String(formData.get('redirectTo') ?? '').trim() || '/'; const redirectTo = String(formData.get('redirectTo') ?? '').trim() || '/';
if (intent === 'register') {
if (!name) {
return {
redirectTo,
form: { error: 'Name is required.', redirectTo }
};
}
if (!email || !password) {
return {
redirectTo,
form: { error: 'Email and password are required.', redirectTo }
};
}
if (password.length < 8) {
return {
redirectTo,
form: { error: 'Password must be at least 8 characters.', redirectTo }
};
}
if (password !== passwordConfirm) {
return {
redirectTo,
form: { error: 'Passwords do not match.', redirectTo }
};
}
try {
await locals.pb.collection('users').create({
name,
email,
password,
passwordConfirm: password,
Stackq: true
});
await locals.pb.collection('users').authWithPassword(email, password);
} catch (err) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: string }).message)
: 'Could not create account. Email may already be in use.';
return {
redirectTo,
form: { error: message, redirectTo }
};
}
throw redirect(303, redirectTo);
}
if (!email || !password) { if (!email || !password) {
return { return {
redirectTo, redirectTo,
error: 'Email and password are required.' form: { error: 'Email and password are required.', redirectTo }
}; };
} }
try { try {
await locals.pb.collection('users').authWithPassword(email, password); await locals.pb.collection('users').authWithPassword(email, password);
} catch (err) { } catch (err) {
const message = err && typeof err === 'object' && 'message' in err const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: string }).message) ? String((err as { message: string }).message)
: 'Invalid email or password.'; : 'Invalid email or password.';
return { return {
redirectTo, redirectTo,
error: message form: { error: message, redirectTo }
}; };
} }
throw redirect(303, redirectTo); throw redirect(303, redirectTo);
} };
export const actions = {
default: loginAction
}; };
;null as any as Actions; ;null as any as Actions;
+67 -7
View File
@@ -7,36 +7,96 @@ export const load: PageServerLoad = async ({ url, locals }) => {
} }
return { return {
redirectTo: url.searchParams.get('redirectTo') ?? '', redirectTo: url.searchParams.get('redirectTo') ?? '',
error: null error: null,
form: null as { error?: string; redirectTo?: string } | null
}; };
}; };
export const actions: Actions = { const loginAction = async ({
default: async ({ request, locals, url }) => { request,
locals
}: {
request: Request;
locals: App.Locals;
}) => {
const formData = await request.formData(); const formData = await request.formData();
const intent = String(formData.get('intent') ?? 'login').trim();
const name = String(formData.get('name') ?? '').trim();
const email = String(formData.get('email') ?? '').trim(); const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? ''); const password = String(formData.get('password') ?? '');
const passwordConfirm = String(formData.get('passwordConfirm') ?? '');
const redirectTo = String(formData.get('redirectTo') ?? '').trim() || '/'; const redirectTo = String(formData.get('redirectTo') ?? '').trim() || '/';
if (intent === 'register') {
if (!name) {
return {
redirectTo,
form: { error: 'Name is required.', redirectTo }
};
}
if (!email || !password) {
return {
redirectTo,
form: { error: 'Email and password are required.', redirectTo }
};
}
if (password.length < 8) {
return {
redirectTo,
form: { error: 'Password must be at least 8 characters.', redirectTo }
};
}
if (password !== passwordConfirm) {
return {
redirectTo,
form: { error: 'Passwords do not match.', redirectTo }
};
}
try {
await locals.pb.collection('users').create({
name,
email,
password,
passwordConfirm: password,
Stackq: true
});
await locals.pb.collection('users').authWithPassword(email, password);
} catch (err) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: string }).message)
: 'Could not create account. Email may already be in use.';
return {
redirectTo,
form: { error: message, redirectTo }
};
}
throw redirect(303, redirectTo);
}
if (!email || !password) { if (!email || !password) {
return { return {
redirectTo, redirectTo,
error: 'Email and password are required.' form: { error: 'Email and password are required.', redirectTo }
}; };
} }
try { try {
await locals.pb.collection('users').authWithPassword(email, password); await locals.pb.collection('users').authWithPassword(email, password);
} catch (err) { } catch (err) {
const message = err && typeof err === 'object' && 'message' in err const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: string }).message) ? String((err as { message: string }).message)
: 'Invalid email or password.'; : 'Invalid email or password.';
return { return {
redirectTo, redirectTo,
error: message form: { error: message, redirectTo }
}; };
} }
throw redirect(303, redirectTo); throw redirect(303, redirectTo);
} };
export const actions: Actions = {
default: loginAction
}; };
+49 -6
View File
@@ -10,6 +10,8 @@
formAction: string; formAction: string;
} = $props(); } = $props();
let isRegister = $state(false);
const redirectTo = $derived(data.form?.redirectTo ?? data.redirectTo ?? ''); const redirectTo = $derived(data.form?.redirectTo ?? data.redirectTo ?? '');
const error = $derived(data.form?.error); const error = $derived(data.form?.error);
</script> </script>
@@ -17,17 +19,35 @@
<div class="flex min-h-0 flex-1 flex-col items-center justify-center p-4"> <div class="flex min-h-0 flex-1 flex-col items-center justify-center p-4">
<Card.Root class="w-full max-w-sm"> <Card.Root class="w-full max-w-sm">
<Card.Header> <Card.Header>
<Card.Title>Sign in</Card.Title> <Card.Title>{isRegister ? 'Create account' : 'Sign in'}</Card.Title>
<Card.Description>Enter your email and password.</Card.Description> <Card.Description>
{isRegister
? 'Enter your email and a password (min 8 characters).'
: 'Enter your email and password.'}
</Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content class="flex flex-col gap-4">
<form <form
method="POST" method="POST"
action={formAction} action={formAction}
use:enhance use:enhance
class="flex flex-col gap-4" class="flex flex-col gap-4"
> >
<input type="hidden" name="intent" value={isRegister ? 'register' : 'login'} />
<input type="hidden" name="redirectTo" value={redirectTo} /> <input type="hidden" name="redirectTo" value={redirectTo} />
{#if isRegister}
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="Your name"
required
autocomplete="name"
/>
</div>
{/if}
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="email">Email</Label> <Label for="email">Email</Label>
<Input <Input
@@ -36,7 +56,7 @@
type="email" type="email"
placeholder="you@example.com" placeholder="you@example.com"
required required
autocomplete="email" autocomplete={isRegister ? 'email' : 'email'}
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -46,14 +66,37 @@
name="password" name="password"
type="password" type="password"
required required
autocomplete="current-password" minlength={isRegister ? 8 : undefined}
autocomplete={isRegister ? 'new-password' : 'current-password'}
/> />
</div> </div>
{#if isRegister}
<div class="grid gap-2">
<Label for="passwordConfirm">Confirm password</Label>
<Input
id="passwordConfirm"
name="passwordConfirm"
type="password"
required
minlength={8}
autocomplete="new-password"
/>
</div>
{/if}
{#if error} {#if error}
<p class="text-sm text-destructive">{error}</p> <p class="text-sm text-destructive">{error}</p>
{/if} {/if}
<Button type="submit" class="w-full">Sign in</Button> <Button type="submit" class="w-full">
{isRegister ? 'Create account' : 'Sign in'}
</Button>
</form> </form>
<button
type="button"
class="text-muted-foreground hover:text-foreground text-center text-sm underline"
onclick={() => (isRegister = !isRegister)}
>
{isRegister ? 'Already have an account? Sign in' : 'Create an account'}
</button>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
</div> </div>