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