add registration page with validation
This commit is contained in:
parent
fd10dcfb15
commit
93efc9946a
2 changed files with 380 additions and 0 deletions
54
src/routes/auth/register/+page.server.ts
Normal file
54
src/routes/auth/register/+page.server.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
326
src/routes/auth/register/+page.svelte
Normal file
326
src/routes/auth/register/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue