add registration page with validation

This commit is contained in:
Justin Edmund 2025-11-30 22:26:32 -08:00
parent fd10dcfb15
commit 93efc9946a
2 changed files with 380 additions and 0 deletions

View file

@ -0,0 +1,54 @@
import type { Actions, PageServerLoad } from './$types'
import { fail, redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ locals, url }) => {
if (locals.session.isAuthenticated) {
redirect(302, url.searchParams.get('next') ?? '/me')
}
return {}
}
export const actions: Actions = {
default: async ({ request, fetch, url }) => {
const form = await request.formData()
const username = String(form.get('username') ?? '')
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
const password_confirmation = String(form.get('password_confirmation') ?? '')
// Basic validation
if (!username || !email || !password || !password_confirmation) {
return fail(400, {
error: 'All fields are required',
username,
email
})
}
if (password !== password_confirmation) {
return fail(400, {
error: "Passwords don't match",
username,
email
})
}
const res = await fetch('/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password, password_confirmation })
})
if (res.ok) {
redirect(303, url.searchParams.get('next') ?? '/me')
}
const j = await res.json().catch(() => ({}))
return fail(res.status, {
error: j.error ?? 'Registration failed',
details: j.details,
username,
email
})
}
}

View file

@ -0,0 +1,326 @@
<script lang="ts">
import { enhance } from '$app/forms'
import AuthCard from '$lib/components/auth/AuthCard.svelte'
import Input from '$lib/components/ui/Input.svelte'
import Button from '$lib/components/ui/Button.svelte'
import { UserAdapter } from '$lib/api/adapters/user.adapter'
import * as m from '$lib/paraglide/messages'
interface Props {
form: {
error?: string
details?: { fieldErrors?: Record<string, string[]> }
username?: string
email?: string
} | null
}
let { form }: Props = $props()
let username = $state(form?.username ?? '')
let email = $state(form?.email ?? '')
let password = $state('')
let passwordConfirmation = $state('')
let isSubmitting = $state(false)
// Validation states
let usernameError = $state('')
let emailError = $state('')
let passwordError = $state('')
let passwordConfirmationError = $state('')
let isCheckingUsername = $state(false)
let isCheckingEmail = $state(false)
let usernameAvailable = $state<boolean | null>(null)
let emailAvailable = $state<boolean | null>(null)
// Debounce timers
let usernameTimer: ReturnType<typeof setTimeout>
let emailTimer: ReturnType<typeof setTimeout>
const userAdapter = new UserAdapter()
// Username validation regex
const usernameRegex = /^[a-zA-Z0-9_-]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// Computed validation
const isFormValid = $derived(
username.length >= 3 &&
username.length <= 20 &&
usernameRegex.test(username) &&
usernameAvailable === true &&
emailRegex.test(email) &&
emailAvailable === true &&
password.length >= 8 &&
passwordConfirmation === password &&
!usernameError &&
!emailError &&
!passwordError &&
!passwordConfirmationError
)
function validateUsername(value: string) {
if (value.length === 0) {
usernameError = ''
usernameAvailable = null
return
}
if (value.length < 3) {
usernameError = m.auth_register_errors_usernameMin()
usernameAvailable = null
return
}
if (value.length > 20) {
usernameError = m.auth_register_errors_usernameMax()
usernameAvailable = null
return
}
if (!usernameRegex.test(value)) {
usernameError = m.auth_register_errors_usernameFormat()
usernameAvailable = null
return
}
usernameError = ''
}
function validateEmail(value: string) {
if (value.length === 0) {
emailError = ''
emailAvailable = null
return
}
if (!emailRegex.test(value)) {
emailError = m.auth_register_errors_emailInvalid()
emailAvailable = null
return
}
emailError = ''
}
function validatePassword(value: string) {
if (value.length === 0) {
passwordError = ''
return
}
if (value.length < 8) {
passwordError = m.auth_register_errors_passwordMin()
return
}
passwordError = ''
}
function validatePasswordConfirmation(value: string, original: string) {
if (value.length === 0) {
passwordConfirmationError = ''
return
}
if (value !== original) {
passwordConfirmationError = m.auth_register_errors_passwordMismatch()
return
}
passwordConfirmationError = ''
}
async function checkUsernameAvailability(value: string) {
if (value.length < 3 || !usernameRegex.test(value)) return
isCheckingUsername = true
try {
const result = await userAdapter.checkUsernameAvailability(value)
// Only update if the value hasn't changed
if (username === value) {
usernameAvailable = result.available
if (!result.available) {
usernameError = m.auth_register_errors_usernameTaken()
}
}
} catch {
// Silently fail availability check
} finally {
isCheckingUsername = false
}
}
async function checkEmailAvailability(value: string) {
if (!emailRegex.test(value)) return
isCheckingEmail = true
try {
const result = await userAdapter.checkEmailAvailability(value)
// Only update if the value hasn't changed
if (email === value) {
emailAvailable = result.available
if (!result.available) {
emailError = m.auth_register_errors_emailTaken()
}
}
} catch {
// Silently fail availability check
} finally {
isCheckingEmail = false
}
}
function onUsernameInput(e: Event) {
const target = e.target as HTMLInputElement
username = target.value
usernameAvailable = null
clearTimeout(usernameTimer)
validateUsername(username)
if (username.length >= 3 && !usernameError) {
usernameTimer = setTimeout(() => checkUsernameAvailability(username), 300)
}
}
function onEmailInput(e: Event) {
const target = e.target as HTMLInputElement
email = target.value
emailAvailable = null
clearTimeout(emailTimer)
validateEmail(email)
if (emailRegex.test(email) && !emailError) {
emailTimer = setTimeout(() => checkEmailAvailability(email), 300)
}
}
function onPasswordInput(e: Event) {
const target = e.target as HTMLInputElement
password = target.value
validatePassword(password)
// Re-validate confirmation if it has content
if (passwordConfirmation.length > 0) {
validatePasswordConfirmation(passwordConfirmation, password)
}
}
function onPasswordConfirmationInput(e: Event) {
const target = e.target as HTMLInputElement
passwordConfirmation = target.value
validatePasswordConfirmation(passwordConfirmation, password)
}
// Derive validation icon states
const usernameIcon = $derived(
isCheckingUsername ? 'loader' : usernameAvailable === true ? 'check' : usernameAvailable === false ? 'x' : undefined
)
const emailIcon = $derived(
isCheckingEmail ? 'loader' : emailAvailable === true ? 'check' : emailAvailable === false ? 'x' : undefined
)
</script>
<svelte:head>
<title>{m.auth_register_title()} | Granblue Party</title>
</svelte:head>
<AuthCard title={m.auth_register_title()}>
<form
method="post"
use:enhance={() => {
isSubmitting = true
return async ({ update }) => {
isSubmitting = false
await update()
}
}}
>
<Input
type="text"
name="username"
label={m.auth_register_username()}
value={username}
oninput={onUsernameInput}
autocomplete="username"
minlength={3}
maxLength={20}
required
fullWidth
contained
error={usernameError}
rightIcon={usernameIcon}
/>
<Input
type="email"
name="email"
label={m.auth_register_email()}
value={email}
oninput={onEmailInput}
autocomplete="email"
required
fullWidth
contained
error={emailError}
rightIcon={emailIcon}
/>
<Input
type="password"
name="password"
label={m.auth_register_password()}
value={password}
oninput={onPasswordInput}
autocomplete="new-password"
minlength={8}
required
fullWidth
contained
error={passwordError}
/>
<Input
type="password"
name="password_confirmation"
label={m.auth_register_confirmPassword()}
value={passwordConfirmation}
oninput={onPasswordConfirmationInput}
autocomplete="new-password"
required
fullWidth
contained
error={passwordConfirmationError}
/>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<Button type="submit" variant="primary" fullWidth disabled={isSubmitting || !isFormValid}>
{isSubmitting ? m.auth_register_submitting() : m.auth_register_submit()}
</Button>
</form>
{#snippet footer()}
<p>
{m.auth_register_hasAccount()}
<a href="/auth/login">{m.auth_register_login()}</a>
</p>
{/snippet}
</AuthCard>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
form {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.error {
color: $error;
font-size: $font-small;
text-align: center;
margin: 0;
padding: $unit $unit-2x;
background: var(--danger-bg);
border-radius: $unit;
}
</style>