Files
Crussell/frontend/src/routes/login/+page.svelte
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

605 lines
18 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';
// zxcvbn-ts imports
import { ZxcvbnFactory } 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 { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { authStore } from '$lib/stores/auth.svelte';
// set up options so that feedback, dictionary etc. are included
const zxcvbn = new ZxcvbnFactory({
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 Date(dateStr);
const today = new Date();
const sixteenYearsAgo = new Date(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) {
const email = formData.email.trim().toLowerCase();
if (!email || !formData.password) {
toast.error('Please enter your email and password.');
return;
}
// 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,
password: formData.password
})
});
if (response.ok) {
const data = await response.json();
// Persist the login pair through the auth store — the single
// source of truth for where/how tokens are stored. setToken
// writes BOTH authToken and authRefreshToken to localStorage
// (refresh token only when present), so the full reload below
// still re-runs initializeAuth() and reads them back.
authStore.setToken(data.token, data.refreshToken ?? data.refresh_token ?? null);
// 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 {
// Token decode failure is non-fatal; default redirectTo = '/' is used
}
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(extractErrorMessage(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(extractErrorMessage(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.check(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.email.trim().length > 0 && formData.password.length > 0
: 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 () {
// Pre-hydration auth guard: reads localStorage directly because the Svelte
// authStore hasn't initialized yet at this point (async+$state). This runs
// synchronously in <svelte:head> before any rendering, preventing a flash
// of protected content. The authStore handles post-hydration auth.
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"
/>
</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 Date(
new Date().getFullYear() - 16,
new Date().getMonth(),
new Date().getDate()
).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 external">Terms & Conditions</a
>
and
<a
href="/privacy-policy"
class="font-semibold text-primary hover:underline"
target="_blank"
rel="noopener noreferrer external">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>