Update version to 2.2.0 and migrate to session-based cookies

*   Bump backend package version to `2.2.0` in `package.json` and
    `package-lock.json`.
*   Replace local storage token management with secure HTTP-only
    cookies.
    *   Added cookie options to `@fastify/cookie` plugin configuration
        in `server.js` (request-time parsing, strict same-site,
        production enforcement).
    *   Updated `auth.js` routes to use `reply.setCookie` and
        `reply.clearCookie` instead of manual token handling.
    *   Added `request.headers.authorization` pre-handling hook to
        inject cookie tokens into the Authorization header for route
        handlers.
*   Updated `frontend/src/lib/stores/auth.js`:
    *   Switched token storage logic to rely solely on cookies via the
        browser (`credentials: 'include'`).
    *   Removed `localStroage` and `sessionStor`ge usage for the auth
        token.
    *   Refactored login/register flow to call `markAuthenticated()`
        immediately upon success.
    *   Updated logout to clear the backend cookie via
        `/api/auth/logout` and reset store state.
    *   Modified `checkRegistrationConfig` and other store methods to
        handle state updates correctly without local storage
        persistence.
*   Removed `localStroage` and `sessionStor`ge references from the
    frontend register page UI and validation logic.
    Update version to 2.2.0 and migrate to session-based cookies

Replace JWT token storage with HTTP-only session cookies in the backend.
Add `/session` endpoint to verify cookie-based authentication and remove
reliance on localStorage for client-side token management. Update
frontend auth store to handle cookies via `credentials: include` and
refresh tokens on 401 errors.
This commit is contained in:
2026-04-11 20:08:29 +02:00
parent 24510001b5
commit 2c0cd01ab2
10 changed files with 724 additions and 609 deletions

View File

@@ -1,14 +1,15 @@
{
"name": "edh-stats-backend",
"version": "1.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "edh-stats-backend",
"version": "1.1.0",
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
@@ -132,6 +133,26 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@fastify/cookie": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^1.0.0",
"fastify-plugin": "^5.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",

View File

@@ -15,14 +15,15 @@
"db:seed": "node src/database/seed.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"bcryptjs": "^2.4.3",
"pg": "^8.11.3",
"close-with-grace": "^1.2.0",
"dotenv": "^16.3.1",
"fastify": "^5.7.1",
"pg": "^8.11.3",
"pino-pretty": "^13.1.3",
"zod": "^3.22.4"
},

View File

@@ -109,6 +109,38 @@ const updateUsernameSchema = z.object({
})
})
const AUTH_COOKIE_NAME = 'edh_stats_token'
const ONE_WEEK_IN_SECONDS = 60 * 60 * 24 * 7
const secureCookies =
process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'
function buildCookieOptions(maxAgeSeconds) {
const base = {
path: '/',
sameSite: 'strict',
httpOnly: true,
secure: secureCookies
}
if (maxAgeSeconds) {
return {
...base,
maxAge: maxAgeSeconds
}
}
return base
}
function setAuthCookie(reply, token, remember) {
const maxAge = remember ? ONE_WEEK_IN_SECONDS : undefined
reply.setCookie(AUTH_COOKIE_NAME, token, buildCookieOptions(maxAge))
}
function clearAuthCookie(reply) {
reply.clearCookie(AUTH_COOKIE_NAME, buildCookieOptions(0))
}
export default async function authRoutes(fastify, options) {
// Initialize repository
const userRepo = new UserRepository()
@@ -204,6 +236,8 @@ export default async function authRoutes(fastify, options) {
}
)
setAuthCookie(reply, token, false)
reply.code(201).send({
message: 'User registered successfully',
user: {
@@ -246,7 +280,7 @@ export default async function authRoutes(fastify, options) {
async (request, reply) => {
try {
// LAYER 1: Schema validation
const { username, password } = loginSchema.parse(request.body)
const { username, password, remember } = loginSchema.parse(request.body)
// LAYER 2: Find user (also serves as authorization check)
const user = await userRepo.findByUsername(username)
@@ -278,10 +312,12 @@ export default async function authRoutes(fastify, options) {
username: user.username
},
{
expiresIn: request.body.remember ? '7d' : '2h'
expiresIn: remember ? '7d' : '2h'
}
)
setAuthCookie(reply, token, remember)
reply.send({
message: 'Login successful',
user: {
@@ -341,6 +377,8 @@ export default async function authRoutes(fastify, options) {
}
)
setAuthCookie(reply, token, false)
reply.send({
message: 'Token refreshed successfully',
token
@@ -400,6 +438,30 @@ export default async function authRoutes(fastify, options) {
}
)
fastify.get('/session', async (request, reply) => {
try {
await request.jwtVerify()
const user = await userRepo.findById(request.user.id)
if (!user) {
clearAuthCookie(reply)
return reply.send({ authenticated: false })
}
reply.send({
authenticated: true,
user: {
id: user.id,
username: user.username,
email: user.email,
createdAt: user.created_at
}
})
} catch (error) {
reply.send({ authenticated: false })
}
})
// Update user profile
fastify.patch(
'/me',
@@ -737,6 +799,8 @@ export default async function authRoutes(fastify, options) {
return
}
clearAuthCookie(reply)
reply.send({
message: 'Account deleted successfully'
})
@@ -749,4 +813,9 @@ export default async function authRoutes(fastify, options) {
}
}
)
fastify.post('/logout', async (request, reply) => {
clearAuthCookie(reply)
reply.send({ message: 'Logged out' })
})
}

View File

@@ -4,6 +4,7 @@ import 'dotenv/config.js'
import fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import cors from '@fastify/cors'
import cookie from '@fastify/cookie'
import jwt from '@fastify/jwt'
import closeWithGrace from 'close-with-grace'
@@ -28,6 +29,19 @@ export default async function build(opts = {}) {
// Register plugins
await app.register(cors, corsConfig)
const shouldUseSecureCookies =
process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'
await app.register(cookie, {
hook: 'onRequest',
parseOptions: {
sameSite: 'strict',
httpOnly: true,
secure: shouldUseSecureCookies,
path: '/'
}
})
// Add request logging hook
app.addHook('onRequest', async (request, reply) => {
request.startTime = Date.now()
@@ -59,6 +73,16 @@ export default async function build(opts = {}) {
secret: jwtConfig.secret
})
app.addHook('preHandler', async (request, reply) => {
if (
!request.headers.authorization &&
request.cookies &&
request.cookies.edh_stats_token
) {
request.headers.authorization = `Bearer ${request.cookies.edh_stats_token}`
}
})
// Register global rate limiting if configured
await app.register(rateLimit, {
global: true,

View File

@@ -1,12 +1,12 @@
{
"name": "edh-stats-frontend",
"version": "2.1.8",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "edh-stats-frontend",
"version": "2.1.8",
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"alpinejs": "^3.13.3",

View File

@@ -1,233 +1,185 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { writable, derived } from 'svelte/store'
import { browser } from '$app/environment'
import { goto } from '$app/navigation'
const initialState = {
token: null,
user: null,
loading: true,
allowRegistration: true
}
// Auth token management
function createAuthStore() {
const { subscribe, set, update } = writable({
token: null,
user: null,
loading: true,
allowRegistration: true
});
const { subscribe, update } = writable(initialState)
return {
subscribe,
/**
* Initialize auth store - load token from storage
*/
init: async () => {
if (!browser) return;
const token = localStorage.getItem('edh-stats-token') ||
sessionStorage.getItem('edh-stats-token');
if (token) {
try {
// Verify token with backend
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
update(state => ({ ...state, token, user: data.user, loading: false }));
} else {
// Invalid token
localStorage.removeItem('edh-stats-token');
sessionStorage.removeItem('edh-stats-token');
update(state => ({ ...state, token: null, user: null, loading: false }));
}
} catch (error) {
console.error('Auth init error:', error);
update(state => ({ ...state, loading: false }));
}
} else {
update(state => ({ ...state, loading: false }));
}
},
/**
* Login with username and password
*/
login: async (username, password, remember = false) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, remember })
});
const data = await response.json();
if (response.ok) {
// Store token
if (remember) {
localStorage.setItem('edh-stats-token', data.token);
} else {
sessionStorage.setItem('edh-stats-token', data.token);
}
update(state => ({
...state,
token: data.token,
user: data.user
}));
return { success: true };
} else {
return {
success: false,
error: data.message || 'Login failed'
};
}
} catch (error) {
console.error('Login error:', error);
return {
success: false,
error: 'Network error. Please try again.'
};
}
},
/**
* Register a new user
*/
register: async (username, email, password) => {
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
email: email || undefined,
password
})
});
const data = await response.json();
if (response.ok) {
// Store token
localStorage.setItem('edh-stats-token', data.token);
update(state => ({
...state,
token: data.token,
user: data.user
}));
return { success: true };
} else {
let errorMessage = data.message || 'Registration failed';
if (data.details && Array.isArray(data.details)) {
errorMessage = data.details.join(', ');
}
return {
success: false,
error: errorMessage
};
}
} catch (error) {
console.error('Registration error:', error);
return {
success: false,
error: 'Network error. Please try again.'
};
}
},
/**
* Logout user
*/
logout: () => {
if (browser) {
localStorage.removeItem('edh-stats-token');
sessionStorage.removeItem('edh-stats-token');
}
set({ token: null, user: null, loading: false, allowRegistration: true });
goto('/login');
},
/**
* Update the current user data in the store
*/
updateUser: (user) => {
update(state => ({ ...state, user }));
},
const markAuthenticated = (user) => {
update((state) => ({ ...state, token: 'cookie', user }))
}
/**
* Check registration config
*/
checkRegistrationConfig: async () => {
try {
const response = await fetch('/api/auth/config');
if (response.ok) {
const data = await response.json();
update(state => ({ ...state, allowRegistration: data.allowRegistration }));
}
} catch (error) {
console.error('Failed to check registration config:', error);
}
}
};
return {
subscribe,
init: async () => {
if (!browser) return
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
cache: 'no-store'
})
if (!response.ok) {
update((state) => ({ ...state, token: null, user: null, loading: false }))
return
}
const data = await response.json()
if (data.authenticated) {
update((state) => ({
...state,
token: 'cookie',
user: data.user,
loading: false
}))
} else {
update((state) => ({ ...state, token: null, user: null, loading: false }))
}
} catch (error) {
console.error('Auth init error:', error)
update((state) => ({ ...state, loading: false }))
}
},
login: async (username, password, remember = false) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, remember })
})
const data = await response.json()
if (response.ok) {
markAuthenticated(data.user)
return { success: true }
}
return {
success: false,
error: data.message || 'Login failed'
}
} catch (error) {
console.error('Login error:', error)
return {
success: false,
error: 'Network error. Please try again.'
}
}
},
register: async (username, email, password) => {
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
email: email || undefined,
password
})
})
const data = await response.json()
if (response.ok) {
markAuthenticated(data.user)
return { success: true }
}
let errorMessage = data.message || 'Registration failed'
if (data.details && Array.isArray(data.details)) {
errorMessage = data.details.join(', ')
}
return {
success: false,
error: errorMessage
}
} catch (error) {
console.error('Registration error:', error)
return {
success: false,
error: 'Network error. Please try again.'
}
}
},
logout: async () => {
if (browser) {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
})
} catch (error) {
console.error('Logout error:', error)
}
}
update((state) => ({ ...state, token: null, user: null, loading: false }))
goto('/login')
},
updateUser: (user) => {
update((state) => ({ ...state, user }))
},
checkRegistrationConfig: async () => {
try {
const response = await fetch('/api/auth/config', {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
update((state) => ({ ...state, allowRegistration: data.allowRegistration }))
}
} catch (error) {
console.error('Failed to check registration config:', error)
}
}
}
}
export const auth = createAuthStore();
export const auth = createAuthStore()
// Derived store for authentication status
export const isAuthenticated = derived(
auth,
$auth => !!$auth.token && !!$auth.user
);
export const isAuthenticated = derived(auth, ($auth) => !!$auth.token && !!$auth.user)
// Derived store for current user
export const currentUser = derived(
auth,
$auth => $auth.user
);
export const currentUser = derived(auth, ($auth) => $auth.user)
/**
* Get auth token from storage
*/
export function getAuthToken() {
if (!browser) return null;
return localStorage.getItem('edh-stats-token') ||
sessionStorage.getItem('edh-stats-token');
}
/**
* Authenticated fetch wrapper
*/
export async function authenticatedFetch(url, options = {}) {
const token = getAuthToken();
// Only set Content-Type for requests with a body
const defaultHeaders = {
...(options.body && { 'Content-Type': 'application/json' }),
...(token && { Authorization: `Bearer ${token}` })
};
const response = await fetch(url, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
if (response.status === 401) {
// Token expired or invalid, clear and redirect
auth.logout();
throw new Error('Authentication required');
}
return response;
const shouldSetJsonHeader =
options.body !== undefined &&
(typeof options.body === 'string' || options.body instanceof String)
const defaultHeaders = {
...(shouldSetJsonHeader && { 'Content-Type': 'application/json' })
}
const response = await fetch(url, {
credentials: 'include',
...options,
headers: {
...defaultHeaders,
...options.headers
}
})
if (response.status === 401) {
await auth.logout()
throw new Error('Authentication required')
}
return response
}

View File

@@ -4,11 +4,18 @@
let allowRegistration = true;
onMount(async () => {
await auth.checkRegistrationConfig();
auth.subscribe(($auth) => {
onMount(() => {
(async () => {
await auth.checkRegistrationConfig();
})();
const unsubscribe = auth.subscribe(($auth) => {
allowRegistration = $auth.allowRegistration;
});
return () => {
unsubscribe();
};
});
</script>

View File

@@ -1,388 +1,423 @@
<script>
import { auth } from '$stores/auth';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let formData = {
username: '',
email: '',
password: '',
confirmPassword: '',
terms: false
};
let errors = {};
let showPassword = false;
let showConfirmPassword = false;
let loading = false;
let serverError = '';
let successMessage = '';
let allowRegistration = true;
onMount(async () => {
await auth.checkRegistrationConfig();
auth.subscribe(($auth) => {
allowRegistration = $auth.allowRegistration;
});
});
function validateUsername() {
if (!formData.username.trim()) {
errors.username = 'Username is required';
} else if (formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
} else if (formData.username.length > 50) {
errors.username = 'Username must be less than 50 characters';
} else if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) {
errors.username = 'Username can only contain letters, numbers, underscores, and hyphens';
} else {
errors.username = '';
}
}
function validateEmail() {
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Please enter a valid email address';
} else {
errors.email = '';
}
}
function validatePassword() {
if (!formData.password) {
errors.password = 'Password is required';
} else if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
} else if (formData.password.length > 100) {
errors.password = 'Password must be less than 100 characters';
} else if (!/(?=.*[a-z])/.test(formData.password)) {
errors.password = 'Password must contain at least one lowercase letter';
} else if (!/(?=.*[A-Z])/.test(formData.password)) {
errors.password = 'Password must contain at least one uppercase letter';
} else if (!/(?=.*\d)/.test(formData.password)) {
errors.password = 'Password must contain at least one number';
} else {
errors.password = '';
}
}
function validateConfirmPassword() {
if (!formData.confirmPassword) {
errors.confirmPassword = 'Please confirm your password';
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
} else {
errors.confirmPassword = '';
}
}
function validateTerms() {
if (!formData.terms) {
errors.terms = 'You must agree to the Terms of Service';
} else {
errors.terms = '';
}
}
async function handleRegister(e) {
e.preventDefault();
// Validate all fields
validateUsername();
validateEmail();
validatePassword();
validateConfirmPassword();
validateTerms();
if (Object.values(errors).some((error) => error)) {
return;
}
loading = true;
serverError = '';
const result = await auth.register(formData.username, formData.email, formData.password);
if (result.success) {
successMessage = 'Account created successfully! Redirecting...';
setTimeout(() => {
goto('/dashboard');
}, 1000);
} else {
serverError = result.error;
}
loading = false;
}
import { auth } from "$stores/auth";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
let formData = {
username: "",
email: "",
password: "",
confirmPassword: "",
terms: false,
};
let errors = {};
let showPassword = false;
let showConfirmPassword = false;
let loading = false;
let serverError = "";
let successMessage = "";
let allowRegistration = true;
onMount(() => {
(async () => {
await auth.checkRegistrationConfig();
})();
const unsubscribe = auth.subscribe(($auth) => {
allowRegistration = $auth.allowRegistration;
});
return () => {
unsubscribe();
};
});
function validateUsername() {
if (!formData.username.trim()) {
errors.username = "Username is required";
} else if (formData.username.length < 3) {
errors.username = "Username must be at least 3 characters";
} else if (formData.username.length > 50) {
errors.username = "Username must be less than 50 characters";
} else if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) {
errors.username =
"Username can only contain letters, numbers, underscores, and hyphens";
} else {
errors.username = "";
}
}
function validateEmail() {
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = "Please enter a valid email address";
} else {
errors.email = "";
}
}
function validatePassword() {
if (!formData.password) {
errors.password = "Password is required";
} else if (formData.password.length < 8) {
errors.password = "Password must be at least 8 characters";
} else if (formData.password.length > 100) {
errors.password = "Password must be less than 100 characters";
} else if (!/(?=.*[a-z])/.test(formData.password)) {
errors.password = "Password must contain at least one lowercase letter";
} else if (!/(?=.*[A-Z])/.test(formData.password)) {
errors.password = "Password must contain at least one uppercase letter";
} else if (!/(?=.*\d)/.test(formData.password)) {
errors.password = "Password must contain at least one number";
} else {
errors.password = "";
}
}
function validateConfirmPassword() {
if (!formData.confirmPassword) {
errors.confirmPassword = "Please confirm your password";
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = "Passwords do not match";
} else {
errors.confirmPassword = "";
}
}
function validateTerms() {
if (!formData.terms) {
errors.terms = "You must agree to the Terms of Service";
} else {
errors.terms = "";
}
}
async function handleRegister(e) {
e.preventDefault();
// Validate all fields
validateUsername();
validateEmail();
validatePassword();
validateConfirmPassword();
validateTerms();
if (Object.values(errors).some((error) => error)) {
return;
}
loading = true;
serverError = "";
const result = await auth.register(
formData.username,
formData.email,
formData.password,
);
if (result.success) {
successMessage = "Account created successfully! Redirecting...";
setTimeout(() => {
goto("/dashboard");
}, 1000);
} else {
serverError = result.error;
}
loading = false;
}
</script>
<svelte:head>
<title>Register - EDH Stats Tracker</title>
<meta
name="description"
content="Create an account to track your Magic: The Gathering EDH/Commander games"
/>
<title>Register - EDH Stats Tracker</title>
<meta
name="description"
content="Create an account to track your Magic: The Gathering EDH/Commander games"
/>
</svelte:head>
<div class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-center flex-1">
<div class="max-w-md w-full space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">EDH Stats</h1>
<h2 class="text-xl text-gray-600">Create your account</h2>
</div>
<div class="flex items-center justify-center flex-1">
<div class="max-w-md w-full space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">
EDH Stats
</h1>
<h2 class="text-xl text-gray-600">Create your account</h2>
</div>
{#if !allowRegistration}
<div class="card">
<div class="text-center py-8">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">Registration Closed</h3>
<p class="mt-1 text-sm text-gray-500">
New user registration is currently disabled.
</p>
<div class="mt-6">
<a href="/login" class="btn btn-primary"> Go to Login </a>
</div>
</div>
</div>
{:else}
<!-- Registration Form -->
<div class="card">
<form class="space-y-6" on:submit={handleRegister}>
<!-- Username -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
id="username"
type="text"
required
bind:value={formData.username}
on:input={validateUsername}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.username
? 'border-red-500'
: ''}"
placeholder="Choose a username"
/>
{#if errors.username}
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
{/if}
</div>
{#if !allowRegistration}
<div class="card">
<div class="text-center py-8">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">
Registration Closed
</h3>
<p class="mt-1 text-sm text-gray-500">
New user registration is currently disabled.
</p>
<div class="mt-6">
<a href="/login" class="btn btn-primary"> Go to Login </a>
</div>
</div>
</div>
{:else}
<!-- Registration Form -->
<div class="card">
<form class="space-y-6" on:submit={handleRegister}>
<!-- Username -->
<div>
<label
for="username"
class="block text-sm font-medium text-gray-700 mb-1"
>
Username *
</label>
<input
id="username"
type="text"
required
bind:value={formData.username}
on:input={validateUsername}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.username
? 'border-red-500'
: ''}"
placeholder="Choose a username"
/>
{#if errors.username}
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
{/if}
</div>
<!-- Email (Optional) -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email (optional)
</label>
<input
id="email"
type="email"
bind:value={formData.email}
on:input={validateEmail}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.email
? 'border-red-500'
: ''}"
placeholder="your.email@example.com"
/>
{#if errors.email}
<p class="mt-1 text-sm text-red-600">{errors.email}</p>
{/if}
</div>
<!-- Email (Optional) -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-1"
>
Email (optional)
</label>
<input
id="email"
type="email"
bind:value={formData.email}
on:input={validateEmail}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.email
? 'border-red-500'
: ''}"
placeholder="your.email@example.com"
/>
{#if errors.email}
<p class="mt-1 text-sm text-red-600">{errors.email}</p>
{/if}
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password *
</label>
<div class="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
required
bind:value={formData.password}
on:input={validatePassword}
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.password
? 'border-red-500'
: ''}"
placeholder="Create a strong password"
/>
<button
type="button"
on:click={() => (showPassword = !showPassword)}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if showPassword}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
{/if}
</svg>
</button>
</div>
{#if errors.password}
<p class="mt-1 text-sm text-red-600">{errors.password}</p>
{/if}
</div>
<!-- Password -->
<div>
<label
for="password"
class="block text-sm font-medium text-gray-700 mb-1"
>
Password *
</label>
<div class="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
required
bind:value={formData.password}
on:input={validatePassword}
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.password
? 'border-red-500'
: ''}"
placeholder="Create a strong password"
/>
<button
type="button"
on:click={() => (showPassword = !showPassword)}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if showPassword}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
{/if}
</svg>
</button>
</div>
{#if errors.password}
<p class="mt-1 text-sm text-red-600">{errors.password}</p>
{/if}
</div>
<!-- Confirm Password -->
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password *
</label>
<div class="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
required
bind:value={formData.confirmPassword}
on:input={validateConfirmPassword}
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.confirmPassword
? 'border-red-500'
: ''}"
placeholder="Confirm your password"
/>
<button
type="button"
on:click={() => (showConfirmPassword = !showConfirmPassword)}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if showConfirmPassword}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
{/if}
</svg>
</button>
</div>
{#if errors.confirmPassword}
<p class="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
{/if}
</div>
<!-- Confirm Password -->
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password *
</label>
<div class="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
required
bind:value={formData.confirmPassword}
on:input={validateConfirmPassword}
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.confirmPassword
? 'border-red-500'
: ''}"
placeholder="Confirm your password"
/>
<button
type="button"
on:click={() => (showConfirmPassword = !showConfirmPassword)}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if showConfirmPassword}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
{/if}
</svg>
</button>
</div>
{#if errors.confirmPassword}
<p class="mt-1 text-sm text-red-600">
{errors.confirmPassword}
</p>
{/if}
</div>
<!-- Terms -->
<div>
<div class="flex items-start">
<input
id="terms"
type="checkbox"
bind:checked={formData.terms}
on:change={validateTerms}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mt-1"
/>
<label for="terms" class="ml-2 block text-sm text-gray-900">
I agree to the Terms of Service and Privacy Policy
</label>
</div>
{#if errors.terms}
<p class="mt-1 text-sm text-red-600">{errors.terms}</p>
{/if}
</div>
<!-- Terms -->
<div>
<div class="flex items-start">
<input
id="terms"
type="checkbox"
bind:checked={formData.terms}
on:change={validateTerms}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mt-1"
/>
<label for="terms" class="ml-2 block text-sm text-gray-900">
I agree to the Terms of Service and Privacy Policy
</label>
</div>
{#if errors.terms}
<p class="mt-1 text-sm text-red-600">{errors.terms}</p>
{/if}
</div>
<!-- Success Message -->
{#if successMessage}
<div class="rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">{successMessage}</p>
</div>
{/if}
<!-- Success Message -->
{#if successMessage}
<div class="rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">
{successMessage}
</p>
</div>
{/if}
<!-- Server Error -->
{#if serverError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{serverError}</p>
</div>
{/if}
<!-- Server Error -->
{#if serverError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{serverError}</p>
</div>
{/if}
<!-- Submit -->
<button
type="submit"
disabled={loading}
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{#if loading}
<div class="loading-spinner w-5 h-5"></div>
{:else}
Create Account
{/if}
</button>
</form>
<!-- Submit -->
<button
type="submit"
disabled={loading}
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{#if loading}
<div class="loading-spinner w-5 h-5"></div>
{:else}
Create Account
{/if}
</button>
</form>
<!-- Links -->
<div class="mt-6 text-center space-y-2">
<p class="text-sm text-gray-600">
Already have an account?
<a href="/login" class="font-medium text-indigo-600 hover:text-indigo-500">
Sign in
</a>
</p>
<p class="text-sm text-gray-600">
<a href="/" class="font-medium text-indigo-600 hover:text-indigo-500">
← Back to Home
</a>
</p>
</div>
</div>
{/if}
</div>
</div>
<!-- Links -->
<div class="mt-6 text-center space-y-2">
<p class="text-sm text-gray-600">
Already have an account?
<a
href="/login"
class="font-medium text-indigo-600 hover:text-indigo-500"
>
Sign in
</a>
</p>
<p class="text-sm text-gray-600">
<a
href="/"
class="font-medium text-indigo-600 hover:text-indigo-500"
>
← Back to Home
</a>
</p>
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: false,
darkMode: 'media',
content: [
'./src/**/*.{html,js,svelte,ts}',
'./public/**/*.html',

View File

@@ -1,5 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
const dockerBackendHost =
process.env.VITE_DOCKER_BACKEND_HOST || 'edh-stats-backend'
const proxyTarget =
process.env.VITE_PROXY_TARGET ||
(process.env.DOCKER ? `http://${dockerBackendHost}:3000` : 'http://localhost:3002')
export default defineConfig({
plugins: [sveltekit()],
@@ -7,10 +14,9 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
// Use Docker service name when running in container, localhost for local dev
target: process.env.DOCKER ? 'http://backend:3000' : 'http://localhost:3002',
target: proxyTarget,
changeOrigin: true
}
}
}
});
})