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:
25
backend/package-lock.json
generated
25
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: false,
|
||||
darkMode: 'media',
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
'./public/**/*.html',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user