Add ALLOW_REGISTRATION flag and dynamic UI

This commit is contained in:
2026-01-15 06:30:48 +01:00
parent 3e474059f5
commit 7671bb05e4
5 changed files with 755 additions and 491 deletions

View File

@@ -10,6 +10,9 @@ HOST=0.0.0.0
JWT_SECRET=your-super-secure-jwt-secret-key-change-this-in-production JWT_SECRET=your-super-secure-jwt-secret-key-change-this-in-production
SESSION_SECRET=your-session-secret-change-this-in-production SESSION_SECRET=your-session-secret-change-this-in-production
# User Registration
ALLOW_REGISTRATION=true
# Database # Database
DATABASE_PATH=/app/database/data/edh-stats.db DATABASE_PATH=/app/database/data/edh-stats.db
DATABASE_BACKUP_PATH=/app/database/data/backups DATABASE_BACKUP_PATH=/app/database/data/backups
@@ -26,4 +29,4 @@ RATE_LIMIT_MAX=100
# Monitoring # Monitoring
HEALTH_CHECK_ENABLED=true HEALTH_CHECK_ENABLED=true
METRICS_ENABLED=false METRICS_ENABLED=false

View File

@@ -37,4 +37,8 @@ export const securityConfig = {
usernameMinLength: 3, usernameMinLength: 3,
commanderNameMinLength: 2, commanderNameMinLength: 2,
maxNotesLength: 1000 maxNotesLength: 1000
} }
export const registrationConfig = {
allowRegistration: process.env.ALLOW_REGISTRATION !== 'false'
}

View File

@@ -1,12 +1,18 @@
// Authentication routes // Authentication routes
import { z } from 'zod' import { z } from 'zod'
import User from '../models/User.js' import User from '../models/User.js'
import { registrationConfig } from '../config/jwt.js'
// Validation schemas // Validation schemas
const registerSchema = z.object({ const registerSchema = z.object({
username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_-]+$/, { username: z
message: 'Username can only contain letters, numbers, underscores, and hyphens' .string()
}), .min(3)
.max(50)
.regex(/^[a-zA-Z0-9_-]+$/, {
message:
'Username can only contain letters, numbers, underscores, and hyphens'
}),
password: z.string().min(8).max(100), password: z.string().min(8).max(100),
email: z.string().email().optional() email: z.string().email().optional()
}) })
@@ -26,326 +32,382 @@ const updateProfileSchema = z.object({
}) })
export default async function authRoutes(fastify, options) { export default async function authRoutes(fastify, options) {
// Public endpoint to check if registration is allowed
fastify.get('/config', async (request, reply) => {
return {
allowRegistration: registrationConfig.allowRegistration
}
})
// Register new user // Register new user
fastify.post('/register', { fastify.post(
config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } '/register',
}, async (request, reply) => { {
try { config: { rateLimit: { max: 3, timeWindow: '15 minutes' } }
// Validate input },
const validatedData = registerSchema.parse(request.body) async (request, reply) => {
try {
// Create user // Check if registration is allowed
const user = await User.create(validatedData) if (!registrationConfig.allowRegistration) {
reply.code(403).send({
// Generate JWT token error: 'Registration Disabled',
const token = await reply.jwtSign({ message: 'User registration is currently disabled'
id: user.id, })
username: user.username return
}, { }
expiresIn: '15m'
}) // Validate input
const validatedData = registerSchema.parse(request.body)
reply.code(201).send({
message: 'User registered successfully', // Create user
user: { const user = await User.create(validatedData)
id: user.id,
username: user.username, // Generate JWT token
email: user.email, const token = await reply.jwtSign(
created_at: user.created_at {
}, id: user.id,
token username: user.username
}) },
{
} catch (error) { expiresIn: '15m'
if (error instanceof z.ZodError) { }
reply.code(400).send({ )
error: 'Validation Error',
message: 'Invalid input data', reply.code(201).send({
details: error.errors.map(e => e.message) message: 'User registered successfully',
}) user: {
} else if (error.message.includes('already exists')) { id: user.id,
reply.code(400).send({ username: user.username,
error: 'Registration Failed', email: user.email,
message: error.message created_at: user.created_at
}) },
} else { token
fastify.log.error('Registration error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to register user'
}) })
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map((e) => e.message)
})
} else if (error.message.includes('already exists')) {
reply.code(400).send({
error: 'Registration Failed',
message: error.message
})
} else {
fastify.log.error('Registration error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to register user'
})
}
} }
} }
}) )
// Login user // Login user
fastify.post('/login', { fastify.post(
config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } '/login',
}, async (request, reply) => { {
try { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } }
const { username, password } = loginSchema.parse(request.body) },
async (request, reply) => {
// Find user try {
const user = await User.findByUsername(username) const { username, password } = loginSchema.parse(request.body)
if (!user) {
reply.code(401).send({ // Find user
error: 'Authentication Failed', const user = await User.findByUsername(username)
message: 'Invalid username or password' if (!user) {
}) reply.code(401).send({
return error: 'Authentication Failed',
} message: 'Invalid username or password'
})
// Verify password return
const isValidPassword = await User.verifyPassword(password, user.password_hash) }
if (!isValidPassword) {
reply.code(401).send({ // Verify password
error: 'Authentication Failed', const isValidPassword = await User.verifyPassword(
message: 'Invalid username or password' password,
}) user.password_hash
return )
} if (!isValidPassword) {
reply.code(401).send({
// Generate JWT token error: 'Authentication Failed',
const token = await reply.jwtSign({ message: 'Invalid username or password'
id: user.id, })
username: user.username return
}, { }
expiresIn: '15m'
}) // Generate JWT token
const token = await reply.jwtSign(
reply.send({ {
message: 'Login successful', id: user.id,
user: { username: user.username
id: user.id, },
username: user.username, {
email: user.email expiresIn: '15m'
}, }
token )
})
reply.send({
} catch (error) { message: 'Login successful',
if (error instanceof z.ZodError) { user: {
reply.code(400).send({ id: user.id,
error: 'Validation Error', username: user.username,
message: 'Invalid input data', email: user.email
details: error.errors.map(e => e.message) },
}) token
} else {
fastify.log.error('Login error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to authenticate user'
}) })
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map((e) => e.message)
})
} else {
fastify.log.error('Login error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to authenticate user'
})
}
} }
} }
}) )
// Refresh token // Refresh token
fastify.post('/refresh', { fastify.post(
config: { '/refresh',
rateLimit: { max: 20, timeWindow: '15 minutes' } {
} config: {
}, async (request, reply) => { rateLimit: { max: 20, timeWindow: '15 minutes' }
try { }
await request.jwtVerify() },
async (request, reply) => {
const user = await User.findById(request.user.id) try {
if (!user) { await request.jwtVerify()
const user = await User.findById(request.user.id)
if (!user) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'User not found'
})
return
}
// Generate new token
const token = await reply.jwtSign(
{
id: user.id,
username: user.username
},
{
expiresIn: '15m'
}
)
reply.send({
message: 'Token refreshed successfully',
token
})
} catch (error) {
reply.code(401).send({ reply.code(401).send({
error: 'Authentication Failed', error: 'Authentication Failed',
message: 'User not found' message: 'Invalid or expired token'
}) })
return
} }
// Generate new token
const token = await reply.jwtSign({
id: user.id,
username: user.username
}, {
expiresIn: '15m'
})
reply.send({
message: 'Token refreshed successfully',
token
})
} catch (error) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Invalid or expired token'
})
} }
}) )
// Get current user profile // Get current user profile
fastify.get('/me', { fastify.get(
preHandler: [async (request, reply) => { '/me',
try { {
await request.jwtVerify() preHandler: [
} catch (err) { async (request, reply) => {
reply.code(401).send({ try {
error: 'Unauthorized', await request.jwtVerify()
message: 'Invalid or expired token' } catch (err) {
}) reply.code(401).send({
} error: 'Unauthorized',
}] message: 'Invalid or expired token'
}, async (request, reply) => { })
try { }
const user = await User.findById(request.user.id)
if (!user) {
reply.code(404).send({
error: 'Not Found',
message: 'User not found'
})
return
}
reply.send({
user: {
id: user.id,
username: user.username,
email: user.email,
created_at: user.created_at
} }
}) ]
},
} catch (error) { async (request, reply) => {
fastify.log.error('Get profile error:', error) try {
reply.code(500).send({ const user = await User.findById(request.user.id)
error: 'Internal Server Error', if (!user) {
message: 'Failed to get user profile' reply.code(404).send({
}) error: 'Not Found',
message: 'User not found'
})
return
}
reply.send({
user: {
id: user.id,
username: user.username,
email: user.email,
created_at: user.created_at
}
})
} catch (error) {
fastify.log.error('Get profile error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to get user profile'
})
}
} }
}) )
// Update user profile // Update user profile
fastify.patch('/me', { fastify.patch(
preHandler: [async (request, reply) => { '/me',
try { {
await request.jwtVerify() preHandler: [
} catch (err) { async (request, reply) => {
reply.code(401).send({ try {
error: 'Unauthorized', await request.jwtVerify()
message: 'Invalid or expired token' } catch (err) {
}) reply.code(401).send({
} error: 'Unauthorized',
}] message: 'Invalid or expired token'
}, async (request, reply) => { })
try { }
const validatedData = updateProfileSchema.parse(request.body)
const updated = await User.updateProfile(request.user.id, validatedData)
if (!updated) {
reply.code(400).send({
error: 'Update Failed',
message: 'No valid fields to update'
})
return
}
const user = await User.findById(request.user.id)
reply.send({
message: 'Profile updated successfully',
user: {
id: user.id,
username: user.username,
email: user.email,
updated_at: user.updated_at
} }
}) ]
},
} catch (error) { async (request, reply) => {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map(e => e.message)
})
} else if (error.message.includes('already exists')) {
reply.code(400).send({
error: 'Update Failed',
message: error.message
})
} else {
fastify.log.error('Update profile error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update profile'
})
}
}
})
// Change password
fastify.post('/change-password', {
preHandler: [async (request, reply) => {
try { try {
await request.jwtVerify() const validatedData = updateProfileSchema.parse(request.body)
} catch (err) {
reply.code(401).send({ const updated = await User.updateProfile(request.user.id, validatedData)
error: 'Unauthorized',
message: 'Invalid or expired token' if (!updated) {
}) reply.code(400).send({
} error: 'Update Failed',
}], message: 'No valid fields to update'
config: { rateLimit: { max: 3, timeWindow: '1 hour' } } })
}, async (request, reply) => { return
try { }
const { currentPassword, newPassword } = changePasswordSchema.parse(request.body)
const user = await User.findById(request.user.id)
// Verify current password
const user = await User.findByUsername(request.user.username) reply.send({
if (!user) { message: 'Profile updated successfully',
reply.code(404).send({ user: {
error: 'Not Found', id: user.id,
message: 'User not found' username: user.username,
}) email: user.email,
return updated_at: user.updated_at
} }
const isValidPassword = await User.verifyPassword(currentPassword, user.password_hash)
if (!isValidPassword) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Current password is incorrect'
})
return
}
// Update password
const updated = await User.updatePassword(request.user.id, newPassword)
if (!updated) {
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update password'
})
return
}
reply.send({
message: 'Password changed successfully'
})
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map(e => e.message)
})
} else {
fastify.log.error('Change password error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to change password'
}) })
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map((e) => e.message)
})
} else if (error.message.includes('already exists')) {
reply.code(400).send({
error: 'Update Failed',
message: error.message
})
} else {
fastify.log.error('Update profile error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update profile'
})
}
} }
} }
}) )
}
// Change password
fastify.post(
'/change-password',
{
preHandler: [
async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}
],
config: { rateLimit: { max: 3, timeWindow: '1 hour' } }
},
async (request, reply) => {
try {
const { currentPassword, newPassword } = changePasswordSchema.parse(
request.body
)
// Verify current password
const user = await User.findByUsername(request.user.username)
if (!user) {
reply.code(404).send({
error: 'Not Found',
message: 'User not found'
})
return
}
const isValidPassword = await User.verifyPassword(
currentPassword,
user.password_hash
)
if (!isValidPassword) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Current password is incorrect'
})
return
}
// Update password
const updated = await User.updatePassword(request.user.id, newPassword)
if (!updated) {
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update password'
})
return
}
reply.send({
message: 'Password changed successfully'
})
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map((e) => e.message)
})
} else {
fastify.log.error('Change password error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to change password'
})
}
}
}
)
}

View File

@@ -1,34 +1,82 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" class="h-full bg-gray-50"> <html lang="en" class="h-full bg-gray-50">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDH Stats Tracker</title> <title>EDH Stats Tracker</title>
<meta name="description" content="Track your Magic: The Gathering EDH/Commander games and statistics"> <meta
<link rel="stylesheet" href="/css/styles.css"> name="description"
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> content="Track your Magic: The Gathering EDH/Commander games and statistics"
</head> />
<body class="h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <link rel="stylesheet" href="/css/styles.css" />
<div class="max-w-md w-full space-y-8 text-center"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-4">EDH Stats</h1> </head>
<p class="text-xl text-gray-600 mb-8">Track your Commander games and statistics</p> <body
class="h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"
<div class="space-y-4"> >
<a href="/login.html" class="btn btn-primary w-full"> <div class="max-w-md w-full space-y-8 text-center" x-data="indexApp()">
🎮 Login to Track Games <h1 class="text-4xl font-bold font-mtg text-edh-primary mb-4">
</a> EDH Stats
<a href="/register.html" class="btn btn-secondary w-full"> </h1>
📝 Create New Account <p class="text-xl text-gray-600 mb-8">
</a> Track your Commander games and statistics
</div> </p>
<div class="text-sm text-gray-500"> <div class="space-y-4">
<p>Built with Fastify, SQLite, and Alpine.js</p> <a href="/login.html" class="btn btn-primary w-full">
<p>Ready to track your Commander victories!</p> 🎮 Login to Track Games
</div> </a>
<a
x-show="allowRegistration"
href="/register.html"
class="btn btn-secondary w-full"
>
📝 Create New Account
</a>
</div>
<div class="text-sm text-gray-500">
<p>Built with Fastify, SQLite, and Alpine.js</p>
<p>Ready to track your Commander victories!</p>
</div>
</div> </div>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script
</body> defer
</html> src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script>
function indexApp() {
return {
allowRegistration: true,
async init() {
await this.checkRegistrationConfig()
},
async checkRegistrationConfig() {
try {
const response = await fetch('/api/auth/config')
if (response.ok) {
const data = await response.json()
this.allowRegistration = data.allowRegistration
} else {
// Default to true if endpoint fails
this.allowRegistration = true
}
} catch (error) {
console.error('Failed to check registration config:', error)
// Default to true if request fails
this.allowRegistration = true
}
}
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('indexApp', indexApp)
})
</script>
</body>
</html>

View File

@@ -1,167 +1,314 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" class="h-full bg-gray-50"> <html lang="en" class="h-full bg-gray-50">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - EDH Stats Tracker</title> <title>Login - EDH Stats Tracker</title>
<meta name="description" content="Login to track your Magic: The Gathering EDH/Commander games"> <meta
<link rel="stylesheet" href="/css/styles.css"> name="description"
</head> content="Login to track your Magic: The Gathering EDH/Commander games"
<body class="h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> />
<div class="max-w-md w-full space-y-8" x-data="loginForm()"> <link rel="stylesheet" href="/css/styles.css" />
</head>
<!-- Header --> <body
<div class="text-center"> class="h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">EDH Stats</h1> >
<h2 class="text-xl text-gray-600">Sign in to your account</h2> <div class="max-w-md w-full space-y-8" x-data="loginWithRegistration()">
</div> <!-- 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">Sign in to your account</h2>
</div>
<!-- Login Form --> <!-- Login Form -->
<div class="card"> <div class="card">
<form class="space-y-6" @submit.prevent="handleLogin"> <form class="space-y-6" @submit.prevent="handleLogin">
<!-- Username Field --> <!-- Username Field -->
<div> <div>
<label for="username" class="form-label">Username</label> <label for="username" class="form-label">Username</label>
<div class="relative"> <div class="relative">
<input <input
id="username" id="username"
name="username" name="username"
type="text" type="text"
required required
x-model="formData.username" x-model="formData.username"
@input="validateUsername()" @input="validateUsername()"
:class="errors.username ? 'border-red-500 focus:ring-red-500' : ''" :class="errors.username ? 'border-red-500 focus:ring-red-500' : ''"
class="form-input pl-10" class="form-input pl-10"
placeholder="Enter your username" placeholder="Enter your username"
> />
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> >
</svg> <svg
</div> class="h-5 w-5 text-gray-400"
</div> fill="none"
<p x-show="errors.username" x-text="errors.username" class="form-error"></p> stroke="currentColor"
</div> viewBox="0 0 24 24"
>
<!-- Password Field --> <path
<div> stroke-linecap="round"
<label for="password" class="form-label">Password</label> stroke-linejoin="round"
<div class="relative"> stroke-width="2"
<input d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
id="password" ></path>
name="password" </svg>
:type="showPassword ? 'text' : 'password'" </div>
required
x-model="formData.password"
@input="validatePassword()"
:class="errors.password ? 'border-red-500 focus:ring-red-500' : ''"
class="form-input pl-10 pr-10"
placeholder="Enter your password"
>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 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>
</div>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg x-show="!showPassword" class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
<svg x-show="showPassword" class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
</button>
</div>
<p x-show="errors.password" x-text="errors.password" class="form-error"></p>
</div>
<!-- Remember Me -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
x-model="formData.remember"
class="h-4 w-4 text-edh-accent focus:ring-edh-accent border-gray-300 rounded"
>
<label for="remember" class="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div class="text-sm">
<a href="#" class="font-medium text-edh-accent hover:text-edh-primary">
Forgot password?
</a>
</div>
</div>
<!-- Error Message -->
<div x-show="serverError" x-transition class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-800" x-text="serverError"></p>
</div>
</div>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="loading"
class="btn btn-primary w-full flex justify-center items-center space-x-2"
:class="{ 'opacity-50 cursor-not-allowed': loading }"
>
<svg x-show="!loading" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
<svg x-show="loading" class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="loading ? 'Signing in...' : 'Sign in'"></span>
</button>
</div>
<!-- Register Link -->
<div class="text-center">
<p class="text-sm text-gray-600">
Don't have an account?
<a href="/register.html" class="font-medium text-edh-accent hover:text-edh-primary">
Sign up
</a>
</p>
</div>
</form>
</div>
<!-- Test Credentials (for development) -->
<div class="card bg-blue-50 border-blue-200">
<h3 class="text-sm font-medium text-blue-800 mb-2">Test Credentials (Development)</h3>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>Username:</strong> testuser</p>
<p><strong>Password:</strong> password123</p>
<p><strong>Username:</strong> magictg</p>
<p><strong>Password:</strong> password123</p>
</div> </div>
<p
x-show="errors.username"
x-text="errors.username"
class="form-error"
></p>
</div>
<!-- Password Field -->
<div>
<label for="password" class="form-label">Password</label>
<div class="relative">
<input
id="password"
name="password"
:type="showPassword ? 'text' : 'password'"
required
x-model="formData.password"
@input="validatePassword()"
:class="errors.password ? 'border-red-500 focus:ring-red-500' : ''"
class="form-input pl-10 pr-10"
placeholder="Enter your password"
/>
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<svg
class="h-5 w-5 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>
</div>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
x-show="!showPassword"
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
</svg>
<svg
x-show="showPassword"
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
</svg>
</button>
</div>
<p
x-show="errors.password"
x-text="errors.password"
class="form-error"
></p>
</div>
<!-- Remember Me -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
x-model="formData.remember"
class="h-4 w-4 text-edh-accent focus:ring-edh-accent border-gray-300 rounded"
/>
<label for="remember" class="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div class="text-sm">
<a
href="#"
class="font-medium text-edh-accent hover:text-edh-primary"
>
Forgot password?
</a>
</div>
</div>
<!-- Error Message -->
<div
x-show="serverError"
x-transition
class="rounded-md bg-red-50 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-800" x-text="serverError"></p>
</div>
</div>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="loading"
class="btn btn-primary w-full flex justify-center items-center space-x-2"
:class="{ 'opacity-50 cursor-not-allowed': loading }"
>
<svg
x-show="!loading"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
></path>
</svg>
<svg
x-show="loading"
class="animate-spin h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span x-text="loading ? 'Signing in...' : 'Sign in'"></span>
</button>
</div>
<!-- Register Link -->
<div class="text-center" x-show="allowRegistration">
<p class="text-sm text-gray-600">
Don't have an account?
<a
href="/register.html"
class="font-medium text-edh-accent hover:text-edh-primary"
>
Sign up
</a>
</p>
</div>
</form>
</div>
<!-- Test Credentials (for development) -->
<div class="card bg-blue-50 border-blue-200">
<h3 class="text-sm font-medium text-blue-800 mb-2">
Test Credentials (Development)
</h3>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>Username:</strong> testuser</p>
<p><strong>Password:</strong> password123</p>
<p><strong>Username:</strong> magictg</p>
<p><strong>Password:</strong> password123</p>
</div> </div>
</div>
</div> </div>
<!-- Scripts --> <!-- Scripts -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script
defer
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
</body> <script>
</html> function loginWithRegistration() {
return {
...loginForm(),
allowRegistration: true,
async init() {
// Check registration config
await this.checkRegistrationConfig()
// Call parent init if it exists
if (typeof super.init === 'function') {
super.init()
}
},
async checkRegistrationConfig() {
try {
const response = await fetch('/api/auth/config')
if (response.ok) {
const data = await response.json()
this.allowRegistration = data.allowRegistration
} else {
this.allowRegistration = true
}
} catch (error) {
console.error('Failed to check registration config:', error)
this.allowRegistration = true
}
}
}
}
</script>
</body>
</html>