Browse Source

add registration

master
Stephanie Gredell 3 weeks ago
parent
commit
eeced22ea4
  1. 130
      backend/src/controllers/auth.controller.ts
  2. 24
      backend/src/middleware/validation.ts
  3. 5
      backend/src/routes/auth.routes.ts
  4. 2
      backend/src/routes/settings.routes.ts
  5. 2
      frontend/src/App.tsx
  6. 2
      frontend/src/components/Footer/Footer.tsx
  7. 387
      frontend/src/components/Navbar/Navbar.tsx
  8. 18
      frontend/src/pages/LoginPage.tsx
  9. 185
      frontend/src/pages/RegisterPage.tsx
  10. 3
      frontend/src/services/apiClient.ts

130
backend/src/controllers/auth.controller.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import { db } from '../config/database.js';
import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword } from '../services/auth.service.js';
import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword, hashPassword } from '../services/auth.service.js';
import { env } from '../config/env.js';
import jwt from 'jsonwebtoken';
@ -150,6 +150,134 @@ export async function logout(req: AuthRequest, res: Response) { @@ -150,6 +150,134 @@ export async function logout(req: AuthRequest, res: Response) {
}
}
export async function register(req: AuthRequest, res: Response) {
try {
const { username, password, dateOfBirth } = req.body;
if (!username || !password || !dateOfBirth) {
return res.status(400).json({
success: false,
error: {
code: 'MISSING_FIELDS',
message: 'Username, password, and date of birth are required'
}
});
}
// Validate password length
if (password.length < 8) {
return res.status(400).json({
success: false,
error: {
code: 'WEAK_PASSWORD',
message: 'Password must be at least 8 characters long'
}
});
}
// Validate date of birth and age
const birthDate = new Date(dateOfBirth);
if (isNaN(birthDate.getTime())) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_DATE',
message: 'Invalid date of birth'
}
});
}
// Check if user is at least 18 years old
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
const dayDiff = today.getDate() - birthDate.getDate();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
actualAge--;
}
if (actualAge < 18) {
return res.status(400).json({
success: false,
error: {
code: 'AGE_RESTRICTION',
message: 'You must be at least 18 years old to register'
}
});
}
// Check if username already exists
const existing = await db.execute({
sql: 'SELECT id FROM users WHERE username = ?',
args: [username]
});
if (existing.rows.length > 0) {
return res.status(409).json({
success: false,
error: {
code: 'USERNAME_EXISTS',
message: 'Username already exists'
}
});
}
// Hash password
const passwordHash = await hashPassword(password);
// Insert user with 'user' role (never 'admin' for public registration)
const result = await db.execute({
sql: 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
args: [username, passwordHash, 'user']
});
// Get created user
const newUserId = Number(result.lastInsertRowid);
const newUser = await db.execute({
sql: 'SELECT id, username, role FROM users WHERE id = ?',
args: [newUserId]
});
// Automatically log in the newly registered user
const { accessToken, refreshToken } = await createTokens(
newUserId,
username
);
// Set refresh token as httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.status(201).json({
success: true,
data: {
user: {
id: newUser.rows[0].id,
username: newUser.rows[0].username,
role: newUser.rows[0].role || 'user'
},
accessToken,
refreshToken
}
});
} catch (error: any) {
console.error('Register error:', error);
res.status(500).json({
success: false,
error: {
code: 'REGISTER_ERROR',
message: 'An error occurred during registration'
}
});
}
}
export async function getCurrentUser(req: AuthRequest, res: Response) {
try {
if (!req.userId) {

24
backend/src/middleware/validation.ts

@ -6,6 +6,30 @@ export const loginSchema = z.object({ @@ -6,6 +6,30 @@ export const loginSchema = z.object({
password: z.string().min(8)
});
export const registerSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8),
dateOfBirth: z.string().refine((dateStr) => {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return false;
// Check if user is at least 18 years old
const today = new Date();
const age = today.getFullYear() - date.getFullYear();
const monthDiff = today.getMonth() - date.getMonth();
const dayDiff = today.getDate() - date.getDate();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
actualAge--;
}
return actualAge >= 18;
}, {
message: 'You must be at least 18 years old to register'
})
});
export const addChannelSchema = z.object({
channelInput: z.string().min(1)
});

5
backend/src/routes/auth.routes.ts

@ -1,11 +1,12 @@ @@ -1,11 +1,12 @@
import { Router } from 'express';
import { login, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js';
import { login, register, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { validateRequest, loginSchema } from '../middleware/validation.js';
import { validateRequest, loginSchema, registerSchema } from '../middleware/validation.js';
import { loginLimiter } from '../middleware/rateLimiter.js';
const router = Router();
router.post('/register', loginLimiter, validateRequest(registerSchema), register);
router.post('/login', loginLimiter, validateRequest(loginSchema), login);
router.post('/refresh', refresh);
router.post('/logout', authMiddleware, logout);

2
backend/src/routes/settings.routes.ts

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import { Router } from 'express';
import { heartbeat, getConnectionStats } from '../controllers/settings.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { adminMiddleware } from '../middleware/admin.js';
import { optionalAuthMiddleware } from '../middleware/optionalAuth.js';
const router = Router();

2
frontend/src/App.tsx

@ -19,6 +19,7 @@ const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module = @@ -19,6 +19,7 @@ const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module =
const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage })));
const SettingsProfilesAdminPage = lazy(() => import('./pages/SettingsProfilesAdminPage').then(module => ({ default: module.SettingsProfilesAdminPage })));
const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage })));
const RegisterPage = lazy(() => import('./pages/RegisterPage').then(module => ({ default: module.RegisterPage })));
// Loading fallback component
const PageLoader = () => (
@ -67,6 +68,7 @@ function App() { @@ -67,6 +68,7 @@ function App() {
))}
{/* Keep non-app routes separate */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/admin"
element={

2
frontend/src/components/Footer/Footer.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
export function Footer() {
return (
<footer className="bg-muted border-t border-border mt-16">
<footer className="bg-muted border-t border-border mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex items-center justify-center">
<p className="text-center text-sm text-muted-foreground">
© 2025 Rainbows, Cupcakes and Unicorns. Free fun for all children. No ads, no logins, no worries! 🎓

387
frontend/src/components/Navbar/Navbar.tsx

@ -6,196 +6,199 @@ import { APPS } from '../../config/apps'; @@ -6,196 +6,199 @@ import { APPS } from '../../config/apps';
import { OptimizedImage } from '../OptimizedImage/OptimizedImage';
export function Navbar() {
const { isAuthenticated, logout, isAdmin } = useAuth();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { channels } = useChannels();
// Detect current app from registry
const getCurrentApp = (pathname: string) => {
return APPS.find(app => pathname === app.link || pathname.startsWith(app.link + '/'));
};
const currentApp = getCurrentApp(location.pathname);
const isVideoApp = currentApp?.id === 'videos';
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
// Sync search input with URL params
useEffect(() => {
setSearchInput(searchParams.get('search') || '');
}, [searchParams]);
const handleLogout = async () => {
await logout();
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newParams = new URLSearchParams(searchParams);
if (searchInput) {
newParams.set('search', searchInput);
} else {
newParams.delete('search');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('sort', e.target.value);
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams);
if (e.target.value) {
newParams.set('channel', e.target.value);
} else {
newParams.delete('channel');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleClearFilters = () => {
setSearchInput('');
setSearchParams(new URLSearchParams());
};
const hasFilters = searchParams.get('search') || searchParams.get('channel') ||
(searchParams.get('sort') && searchParams.get('sort') !== 'newest');
return (
<>
<header className="bg-white border-b-4 border-primary sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-4 py-5">
<div className="flex items-center gap-3 justify-between">
<Link to="/" className="flex items-center gap-3">
<OptimizedImage
src="/rainbow.png"
alt="Rainbow"
className="h-10 w-10 md:h-12 md:w-12 object-contain"
width={48}
height={48}
loading="eager"
fetchPriority="high"
/>
<h1 className="text-3xl md:text-4xl font-bold text-foreground" style={{ fontFamily: "'Butterfly Kids', cursive" }}>Rainbows, Cupcakes & Unicorns</h1>
<OptimizedImage
src="/cupcake.png"
alt="Cupcake"
className="h-10 w-10 md:h-12 md:w-12 object-contain"
width={48}
height={48}
loading="eager"
fetchPriority="high"
/>
</Link>
<div className="flex items-center gap-3">
<Link
to="/"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${
location.pathname === '/'
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Home
</Link>
{isAdmin && (
<Link
to="/admin"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${
location.pathname.startsWith('/admin')
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Admin
</Link>
)}
{isAuthenticated ? (
<button
onClick={handleLogout}
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Logout
</button>
) : (
<Link
to="/login"
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Login
</Link>
)}
</div>
</div>
</div>
</header>
{isVideoApp && (
<div className="bg-muted border-b border-border">
<div className="max-w-5xl mx-auto px-4 py-4">
<div className="flex flex-col sm:flex-row gap-4 items-stretch sm:items-center">
<form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 max-w-md">
<input
type="text"
placeholder="Search videos..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1 px-4 py-2 border border-border rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors text-sm font-semibold"
>
🔍
</button>
</form>
<div className="flex gap-2 flex-wrap sm:flex-nowrap">
<select
value={searchParams.get('sort') || 'newest'}
onChange={handleSortChange}
className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Most Popular</option>
</select>
<select
value={searchParams.get('channel') || ''}
onChange={handleChannelChange}
className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">All Channels</option>
{channels.map(channel => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
{hasFilters && (
<button
onClick={handleClearFilters}
className="px-4 py-2 border border-border rounded-full bg-white text-sm hover:bg-muted transition-colors whitespace-nowrap"
>
Clear Filters
</button>
)}
</div>
</div>
</div>
</div>
)}
</>
);
const { isAuthenticated, logout, isAdmin } = useAuth();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { channels } = useChannels();
// Detect current app from registry
const getCurrentApp = (pathname: string) => {
return APPS.find(app => pathname === app.link || pathname.startsWith(app.link + '/'));
};
const currentApp = getCurrentApp(location.pathname);
const isVideoApp = currentApp?.id === 'videos';
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
// Sync search input with URL params
useEffect(() => {
setSearchInput(searchParams.get('search') || '');
}, [searchParams]);
const handleLogout = async () => {
await logout();
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newParams = new URLSearchParams(searchParams);
if (searchInput) {
newParams.set('search', searchInput);
} else {
newParams.delete('search');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('sort', e.target.value);
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams);
if (e.target.value) {
newParams.set('channel', e.target.value);
} else {
newParams.delete('channel');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleClearFilters = () => {
setSearchInput('');
setSearchParams(new URLSearchParams());
};
const hasFilters = searchParams.get('search') || searchParams.get('channel') ||
(searchParams.get('sort') && searchParams.get('sort') !== 'newest');
return (
<>
<header className="bg-white border-b-4 border-primary sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-4 py-5">
<div className="flex items-center gap-3 justify-between">
<Link to="/" className="flex items-center gap-3">
<OptimizedImage
src="/rainbow.png"
alt="Rainbow"
className="h-10 w-10 md:h-12 md:w-12 object-contain"
width={48}
height={48}
loading="eager"
fetchPriority="high"
/>
<h1 className="text-3xl md:text-4xl font-bold text-foreground" style={{ fontFamily: "'Butterfly Kids', cursive" }}>Rainbows, Cupcakes & Unicorns</h1>
<OptimizedImage
src="/cupcake.png"
alt="Cupcake"
className="h-10 w-10 md:h-12 md:w-12 object-contain"
width={48}
height={48}
loading="eager"
fetchPriority="high"
/>
</Link>
<div className="flex items-center gap-3">
<Link
to="/"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname === '/'
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Home
</Link>
{!isAuthenticated && (
<Link
to="/register"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname === '/register'
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Sign In / Register
</Link>
)}
{isAdmin && (
<Link
to="/admin"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname.startsWith('/admin')
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Admin
</Link>
)}
{isAuthenticated && (
<button
onClick={handleLogout}
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Logout
</button>
)}
</div>
</div>
</div>
</header>
{isVideoApp && (
<div className="bg-muted border-b border-border">
<div className="max-w-5xl mx-auto px-4 py-4">
<div className="flex flex-col sm:flex-row gap-4 items-stretch sm:items-center">
<form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 max-w-md">
<input
type="text"
placeholder="Search videos..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1 px-4 py-2 border border-border rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors text-sm font-semibold"
>
🔍
</button>
</form>
<div className="flex gap-2 flex-wrap sm:flex-nowrap">
<select
value={searchParams.get('sort') || 'newest'}
onChange={handleSortChange}
className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Most Popular</option>
</select>
<select
value={searchParams.get('channel') || ''}
onChange={handleChannelChange}
className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">All Channels</option>
{channels.map(channel => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
{hasFilters && (
<button
onClick={handleClearFilters}
className="px-4 py-2 border border-border rounded-full bg-white text-sm hover:bg-muted transition-colors whitespace-nowrap"
>
Clear Filters
</button>
)}
</div>
</div>
</div>
</div>
)}
</>
);
}

18
frontend/src/pages/LoginPage.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function LoginPage() {
@ -27,7 +27,7 @@ export function LoginPage() { @@ -27,7 +27,7 @@ export function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4 py-8">
<div className="flex items-start justify-center bg-background px-4 pt-12 pb-8">
<div className="w-full max-w-md bg-card rounded-3xl shadow-lg overflow-hidden border border-border">
<div className="px-8 pt-8 pb-6 text-center border-b border-border">
<h1 className="text-2xl font-bold text-foreground mb-2">Admin Login</h1>
@ -75,10 +75,22 @@ export function LoginPage() { @@ -75,10 +75,22 @@ export function LoginPage() {
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-xl font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md"
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-xl font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md mb-4"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
<Link
to="/register"
className="text-primary hover:underline font-semibold"
>
Sign up
</Link>
</p>
</div>
</form>
</div>
</div>

185
frontend/src/pages/RegisterPage.tsx

@ -0,0 +1,185 @@ @@ -0,0 +1,185 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function RegisterPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (!dateOfBirth) {
setError('Date of birth is required');
return;
}
// Validate age on frontend as well
const birthDate = new Date(dateOfBirth);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
const dayDiff = today.getDate() - birthDate.getDate();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
actualAge--;
}
if (actualAge < 18) {
setError('You must be at least 18 years old to register');
return;
}
setLoading(true);
try {
const { authApi } = await import('../services/apiClient');
const response: any = await authApi.register(username, password, dateOfBirth);
// Registration endpoint returns tokens and user data, same as login
// Use login function to set user and token in auth context
// This ensures consistent state management
await login(username, password);
// Navigate to home page
navigate('/');
} catch (err: any) {
setError(err.error?.message || 'Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="flex items-start justify-center bg-background px-4 pt-12 pb-8">
<div className="w-full max-w-md bg-card rounded-3xl shadow-lg overflow-hidden border border-border">
<div className="px-8 pt-8 pb-6 text-center border-b border-border">
<h1 className="text-2xl font-bold text-foreground mb-2">Create Account</h1>
<p className="text-sm text-muted-foreground">Sign up to get started</p>
</div>
<form onSubmit={handleSubmit} className="px-8 py-8">
{error && (
<div className="mb-6 p-3 bg-destructive/10 text-destructive border border-destructive/20 rounded-xl text-sm">
{error}
</div>
)}
<div className="mb-5">
<label htmlFor="username" className="block mb-2 text-sm font-semibold text-foreground">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
minLength={3}
maxLength={50}
autoFocus
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
<p className="mt-1 text-xs text-muted-foreground">Must be at least 3 characters</p>
</div>
<div className="mb-5">
<label htmlFor="password" className="block mb-2 text-sm font-semibold text-foreground">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
minLength={8}
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
<p className="mt-1 text-xs text-muted-foreground">Must be at least 8 characters</p>
</div>
<div className="mb-5">
<label htmlFor="confirmPassword" className="block mb-2 text-sm font-semibold text-foreground">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
required
minLength={8}
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
</div>
<div className="mb-6">
<label htmlFor="dateOfBirth" className="block mb-2 text-sm font-semibold text-foreground">
Date of Birth
</label>
<input
id="dateOfBirth"
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
disabled={loading}
required
max={new Date(new Date().setFullYear(new Date().getFullYear() - 18)).toISOString().split('T')[0]}
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
<p className="mt-1 text-xs text-muted-foreground">You must be at least 18 years old to register</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-xl font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md mb-4"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Have an account?{' '}
<Link
to="/login"
className="text-primary hover:underline font-semibold"
>
Sign in here
</Link>
</p>
</div>
</form>
</div>
</div>
);
}

3
frontend/src/services/apiClient.ts

@ -87,6 +87,9 @@ api.interceptors.response.use( @@ -87,6 +87,9 @@ api.interceptors.response.use(
// Auth API
export const authApi = {
register: (username: string, password: string, dateOfBirth: string) =>
api.post('/auth/register', { username, password, dateOfBirth }),
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),

Loading…
Cancel
Save