Files
Crussell/frontend/src/routes/login/+page.svelte
T
popertots 4af2b8dfb4
Backend CI / Tests (push) Failing after 1m41s
Backend CI / Lint & vulns (push) Failing after 2m26s
Backend CI / Race detector (push) Failing after 3m45s
style: fix prefer-const and prettier formatting issues
2026-06-25 13:48:03 +01:00

590 lines
17 KiB
Svelte

<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { EmailInput } from '$lib/components/ui/email-input/index.js';
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { SvelteDate } from 'svelte/reactivity';
// zxcvbn-ts imports
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import * as languageCommon from '@zxcvbn-ts/language-common';
import * as languageEn from '@zxcvbn-ts/language-en';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
// set up options so that feedback, dictionary etc. are included
zxcvbnOptions.setOptions({
translations: languageEn.translations,
graphs: languageCommon.adjacencyGraphs,
dictionary: {
...languageCommon.dictionary,
...languageEn.dictionary
}
});
let isLogin = $state(true);
let agreedToPolicy = $state(false);
let formData = $state({
email: '',
password: '',
firstName: '',
lastName: '',
confirmPassword: '',
phone: '',
dateOfBirth: '',
referralCode: ''
});
// Validation state
let validationErrors = $state({
email: '',
phone: '',
dateOfBirth: ''
});
function toggleMode() {
isLogin = !isLogin;
agreedToPolicy = false;
formData = {
email: '',
password: '',
firstName: '',
lastName: '',
confirmPassword: '',
phone: '',
dateOfBirth: '',
referralCode: ''
};
validationErrors = {
email: '',
phone: '',
dateOfBirth: ''
};
}
function validatePhone(phone: string): boolean {
const valid = isValidUKPhone(phone);
validationErrors.phone = valid ? '' : 'Please enter a valid UK phone number';
return valid;
}
// Age validation (must be 16+)
function validateAge(dateStr: string): boolean {
if (!dateStr) {
validationErrors.dateOfBirth = '';
return true;
}
const dob = new SvelteDate(dateStr);
const today = new SvelteDate();
const sixteenYearsAgo = new SvelteDate(
today.getFullYear() - 16,
today.getMonth(),
today.getDate()
);
const isValid = dob <= sixteenYearsAgo;
validationErrors.dateOfBirth = isValid
? ''
: "You must be at least 16 years old to register, but you can call for an appointment by clicking 'Contact' at the top of the page.";
return isValid;
}
/**
* Formats referral code input into 3 blocks of 4 characters (xxxx-xxxx-xxxx).
* Handles paste, backspace, and partial inputs gracefully.
*/
function handleReferralInput(e: Event) {
const target = e.target as HTMLInputElement;
// 1. Clean the input: keep only alphanumeric characters
let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
// 2. Limit to 12 characters
if (raw.length > 12) {
raw = raw.slice(0, 12);
}
// 3. Reconstruct with dashes
let formatted = '';
if (raw.length > 0) {
formatted += raw.slice(0, 4);
}
if (raw.length > 4) {
formatted += '-' + raw.slice(4, 8);
}
if (raw.length > 8) {
formatted += '-' + raw.slice(8, 12);
}
// 4. Update state
formData.referralCode = formatted;
}
// Normalize data before sending
function normalizeFormData() {
return {
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
email: formData.email.trim().toLowerCase(),
password: formData.password,
phone: toE164UK(formData.phone) ?? formData.phone,
dateOfBirth: formData.dateOfBirth.trim(),
// Strip dashes to send raw 12 characters
referralCode: formData.referralCode.replace(/-/g, '').trim() || undefined,
agreedToPolicy: agreedToPolicy
};
}
async function handleSubmit() {
if (isLogin) {
// Login flow with loading toast
const loadingToast = toast.loading('Signing in...');
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email.trim().toLowerCase(),
password: formData.password
})
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.token);
// Decode token to check role for redirect
let redirectTo = '/';
try {
const payload = JSON.parse(atob(data.token.split('.')[1]));
if (payload.role === 'admin') {
redirectTo = '/today';
}
} catch {}
toast.success('Successfully logged in!', { id: loadingToast });
window.location.href = redirectTo;
} else if (response.status === 409) {
toast.error('Login already in progress. Please wait.', { id: loadingToast });
} else if (response.status === 401) {
toast.error('Invalid email or password.', { id: loadingToast });
} else {
const text = await response.text();
toast.error('Error: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error(err);
toast.error('Network error, please try again later.', { id: loadingToast });
}
return;
}
// Validate before submitting
const isEmailValid = !validationErrors.email && formData.email.trim().length > 0;
const isPhoneValid = validatePhone(formData.phone);
const isAgeValid = validateAge(formData.dateOfBirth);
if (!isEmailValid || !isPhoneValid || !isAgeValid) {
toast.error('Please fix the validation errors before submitting.');
return;
}
// Registration flow with loading toast
const loadingToast = toast.loading('Creating your account...');
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizeFormData())
});
if (response.ok) {
toast.success('Account created successfully!', { id: loadingToast });
toggleMode();
} else {
const text = await response.text();
toast.error('Error: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error(err);
toast.error('Network error, please try again later.', { id: loadingToast });
}
}
function handleSocialLogin(provider: string) {
toast.info(`${provider} login coming soon`);
}
// when password changes, re-compute strength
const passwordStrength = $derived(
formData.password
? (() => {
const result = zxcvbn(formData.password);
// Add minimum length check (6 chars) to the score feedback
if (formData.password.length < 6) {
return {
...result,
score: Math.min(result.score, 0), // Force weak for too short
feedback: {
warning: 'Password must be at least 6 characters',
suggestions: ['Add more characters to meet the minimum length requirement']
}
};
}
return result;
})()
: null
);
// form completion check
const isFormComplete = $derived(
isLogin ||
(formData.firstName.trim() &&
formData.lastName.trim() &&
formData.phone &&
formData.dateOfBirth &&
formData.email &&
formData.password &&
formData.confirmPassword &&
formData.password === formData.confirmPassword &&
passwordStrength &&
formData.password.length >= 6 &&
passwordStrength.score >= 2 &&
agreedToPolicy &&
!validationErrors.email &&
!validationErrors.phone &&
!validationErrors.dateOfBirth)
);
</script>
<svelte:head>
<script>
(function () {
try {
var token = localStorage.getItem('authToken');
if (token) {
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 > Date.now()) {
var path = payload.role === 'admin' ? '/today' : '/';
window.location.replace(path);
}
}
} catch (e) {}
})();
</script>
</svelte:head>
<div class="mx-auto flex w-full justify-center p-4 pt-2">
<div class="w-full max-w-md space-y-6">
<!-- Header -->
<div class="space-y-2 text-center">
<h1 class="font-['Playfair_Display'] text-4xl font-bold">
{isLogin ? 'Welcome back' : 'Create account'}
</h1>
<p class="text-muted-foreground">
{isLogin
? 'Sign in to your account to continue'
: 'Choose an option below to create your account'}
</p>
</div>
<!-- Social Login Buttons -->
<div class="space-y-3">
<Button variant="outline" onclick={() => handleSocialLogin('Google')} class="w-full">
<svg class="mr-2" style="width: 1rem; height: 1rem;" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button variant="outline" onclick={() => handleSocialLogin('Microsoft')} class="w-full">
<svg
class="mr-2"
style="width: 1rem; height: 1rem;"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zM24 11.4H12.6V0H24v11.4z"
/>
</svg>
Continue with Microsoft
</Button>
<Button variant="outline" onclick={() => handleSocialLogin('Facebook')} class="w-full">
<svg
class="mr-2"
style="width: 1rem; height: 1rem;"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
/>
</svg>
Continue with Facebook
</Button>
</div>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<Separator class="w-full" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">or continue with email</span>
</div>
</div>
<!-- Email/Password Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if !isLogin}
<!-- SECTION 1: LOGIN DETAILS -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">What we need to log you in</h3>
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<EmailInput
id="email"
bind:value={formData.email}
bind:error={validationErrors.email}
placeholder="john@example.com"
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<Input
id="password"
type="password"
placeholder="Enter your password"
maxlength={72}
bind:value={formData.password}
required
/>
{#if passwordStrength}
<!-- Strength meter bar -->
<div class="mt-1">
<div class="h-2 overflow-hidden rounded bg-gray-200">
<div
class="h-2 transition-all"
style="
width: {(passwordStrength.score + 1) * 20}%;
background-color: {passwordStrength.score < 2
? 'var(--chart-1)'
: passwordStrength.score === 2
? 'orange'
: passwordStrength.score === 3
? 'var(--chart-2)'
: 'var(--chart-2)'};
"
></div>
</div>
<p class="mt-1 text-xs">
{passwordStrength.feedback.warning
? passwordStrength.feedback.warning
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][passwordStrength.score]}`}
</p>
{#if passwordStrength.feedback.suggestions.length > 0}
<ul class="mt-1 ml-4 list-disc text-xs text-muted-foreground">
{#each passwordStrength.feedback.suggestions as suggestion (suggestion)}
<li>{suggestion}</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<div class="space-y-2">
<RequiredLabel forId="confirmPassword" text="Confirm Password" />
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
bind:value={formData.confirmPassword}
required
/>
{#if formData.confirmPassword && formData.password !== formData.confirmPassword}
<p class="mt-1 text-sm text-red-500">Passwords do not match</p>
{/if}
</div>
</div>
<Separator class="w-full" />
<!-- SECTION 2: CONTACT DETAILS -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">What we need to know about you</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<RequiredLabel forId="firstName" text="First Name" />
<Input
id="firstName"
placeholder="John"
maxlength={50}
bind:value={formData.firstName}
onblur={() => (formData.firstName = formData.firstName.trim())}
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="lastName" text="Last Name" />
<Input
id="lastName"
placeholder="Doe"
maxlength={50}
bind:value={formData.lastName}
onblur={() => (formData.lastName = formData.lastName.trim())}
required
/>
</div>
</div>
<div class="space-y-2">
<RequiredLabel forId="phone" text="Phone Number" />
<PhoneInput
id="phone"
bind:value={formData.phone}
bind:error={validationErrors.phone}
placeholder="07123 456789 or +44 7123 456789"
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="dateOfBirth" text="Date of Birth" />
<Input
id="dateOfBirth"
type="date"
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new SvelteDate(
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
required
/>
{#if validationErrors.dateOfBirth}
<p class="text-sm text-red-500">{validationErrors.dateOfBirth}</p>
{/if}
</div>
</div>
<Separator class="w-full" />
<!-- SECTION 3: REFERRAL -->
<div class="space-y-4">
<!-- <h3 class="text-lg font-semibold">Referral</h3> -->
<div class="space-y-2">
<Label for="referralCode">Referral Code (optional)</Label>
<Input
id="referralCode"
placeholder="a2c4-e6g8-i0k2"
maxlength={14}
value={formData.referralCode}
oninput={handleReferralInput}
/>
<p class="text-xs text-muted-foreground">
Enter a 12-character referral code if you were referred by an existing customer
</p>
</div>
</div>
{:else}
<!-- Login Fields (Simple inline layout) -->
<div class="space-y-4">
<div class="space-y-2">
<RequiredLabel forId="email" text="Email" />
<EmailInput
id="email"
bind:value={formData.email}
bind:error={validationErrors.email}
placeholder="john@example.com"
required
/>
</div>
<div class="space-y-2">
<RequiredLabel forId="password" text="Password" />
<Input
id="password"
type="password"
placeholder="Enter your password"
maxlength={72}
bind:value={formData.password}
required
/>
</div>
</div>
{/if}
{#if !isLogin}
<div class="flex items-center space-x-2">
<Checkbox id="privacy" bind:checked={agreedToPolicy} class="mt-1" />
<div class="text-sm leading-snug">
<Label for="privacy">
I agree to the
<a
href="/terms"
class="font-semibold text-primary hover:underline"
target="_blank"
rel="noopener noreferrer">Terms & Conditions</a
>
and
<a
href="/privacy"
class="font-semibold text-primary hover:underline"
target="_blank"
rel="noopener noreferrer">Privacy Policy</a
>
</Label>
</div>
</div>
{/if}
<Button type="submit" class="w-full" disabled={!isFormComplete}>
{isLogin ? 'Sign In' : 'Create Account'}
</Button>
</form>
<div class="text-center text-sm text-muted-foreground">
{isLogin ? "Don't have an account?" : 'Already have an account?'}
<Button variant="link" onclick={toggleMode} class="px-1 font-semibold text-primary">
{isLogin ? 'Sign up' : 'Sign in'}
</Button>
</div>
</div>
</div>