registration + account sync

This commit is contained in:
2026-04-25 13:02:42 +02:00
parent 5d1e82761a
commit 76618af58a
13 changed files with 744 additions and 37 deletions
+4 -4
View File
@@ -8,17 +8,17 @@ JWT_EXPIRES_IN=8h
DATABASE_URL=file:./dev.db
# Game Server Database (MSSQL)
GAME_SERVER_HOST=192.168.1.100
GAME_SERVER_HOST=10.23.19.205
GAME_SERVER_PORT=1433
GAME_SERVER_USER=sa
GAME_SERVER_PASSWORD=your_game_server_password
GAME_SERVER_PASSWORD=q159753Q
GAME_SERVER_DATABASE=lin2db
# Game World Database (MSSQL)
GAME_WORLD_HOST=192.168.1.100
GAME_WORLD_HOST=10.23.19.205
GAME_WORLD_PORT=1433
GAME_WORLD_USER=sa
GAME_WORLD_PASSWORD=your_game_server_password
GAME_WORLD_PASSWORD=q159753Q
GAME_WORLD_DATABASE=lin2world
# Server Info
+90
View File
@@ -0,0 +1,90 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LA2 Eternal Portal is a web portal for a Lineage 2 private server. It consists of an Express API and a React SPA that manage website user accounts, link them to existing game server accounts, and display character data.
## Architecture
This is a monorepo with two packages:
- **`api/`** — Express.js API server (TypeScript, Node 18+)
- **`react-client/`** — React SPA (Vite, TypeScript)
### Dual Database Architecture
The API talks to two completely different databases:
1. **Portal Database (SQLite)** — Stores website users, game account links, and cached character metadata. Accessed via Prisma ORM (`api/prisma/schema.prisma`). File path controlled by `DATABASE_URL` env var. This is the core database and is available in all environments.
2. **Game Server Databases (MSSQL)** — External production-only database containing live Lineage 2 game server data. **Not available in local development.** Accessed via raw SQL through the `mssql` driver (`api/src/services/gameServerService.ts`) only when env vars are configured. Two connections:
- `lin2db` — account and character tables
- `lin2world` — builder_account table
Game server connection config lives in `api/src/config/gameServer.ts` and is read from env vars (`GAME_SERVER_*`, `GAME_WORLD_*`). Code that touches MSSQL should gracefully handle connection failures because the game server is not reachable outside production.
### Auth Flow
- Portal uses JWT (8h expiry). Token is generated on login, stored in `localStorage`, and sent via `Authorization: Bearer` header.
- The API has two middleware functions in `api/src/middleware/auth.ts`: `protectRoute` (validates JWT) and `adminOnly` (checks `role === 'ADMIN'`).
- React auth state is managed by Zustand (`react-client/src/hooks/useAuth.ts`).
### API Routing Structure
| Route | File | Notes |
|-------|------|-------|
| `/api/auth` | `api/src/routes/auth.routes.ts` | login, register, me, link game account |
| `/api/users` | `api/src/routes/user.routes.ts` | user/character CRUD (Prisma/SQLite) |
| `/api/admin` | `api/src/routes/admin.routes.ts` | admin-only user management |
| `/api/characters` | `api/src/routes/characters.routes.ts` | character routes (mostly stub) |
Business logic for game server DB operations is in `api/src/services/gameServerService.ts`. Business logic for portal DB operations is split between `api/src/controllers/auth.controller.ts` and `api/src/controllers/user.controller.ts`.
## Commands
All commands are run from the respective package directory (`api/` or `react-client/`).
### API (`cd api/`)
```bash
npm run dev # Start dev server with tsx watch (port 3001)
npm run build # Compile TypeScript to ./dist
npm run start # Run compiled JS (production)
npm run migrate # Run Prisma migrations
```
### React Client (`cd react-client/`)
```bash
npm run dev # Start Vite dev server (port 5173)
npm run build # Type-check and build for production
npm run preview # Preview production build locally
```
Note: `npm run lint` is defined in `react-client/package.json` but ESLint is not actually installed/configured in the current lockfile.
### Docker (from repo root)
```bash
docker compose build # Build API and frontend images
docker compose up -d # Start both services
docker compose logs -f api # Tail API logs
docker compose logs -f react # Tail frontend logs
```
The API container runs `tsx watch` in development. The React container is a multi-stage build that compiles the SPA and serves it via nginx.
## Important Files
- `api/prisma/schema.prisma` — Portal DB schema (SQLite). Models: `WebsiteAccount`, `GameAccount`, `GameCharacter`.
- `api/src/config/gameServer.ts` — MSSQL connection config and server metadata.
- `react-client/vite.config.ts` — Vite config with path alias `@/` pointing to `src/` and a dev proxy for `/api` to `localhost:3001`.
- `.env.example` — Required environment variables for local development.
## Code Patterns
- Controllers are inconsistent in style: some export plain async functions (`auth.controller.ts`), others export a controller object (`user.controller.ts`). Prefer plain async functions for new code.
- MSSQL queries use template strings with parameterized inputs via `.input()`. Never interpolate user values directly into query strings.
- Passwords for the game server are stored as plain binary (`Buffer.from(password)`) in the `user_auth` table, not hashed.
- The `gameServerService.ts` connection pools are lazily initialized module-level singletons. They reconnect automatically if the connection drops.
+79
View File
@@ -288,6 +288,85 @@ sudo systemctl enable la2eternal.service
sudo systemctl start la2eternal.service
```
## Testing (Docker)
### Smoke Test Script
A `test/smoke-test.sh` script is included to verify the Docker deployment works end-to-end.
**Run the smoke test after starting Docker:**
```bash
# Make sure containers are running first
sudo docker compose up -d
# Run the smoke test
chmod +x test/smoke-test.sh
./test/smoke-test.sh
```
**What the smoke test verifies:**
1. API container is reachable on port 3001
2. Frontend container is reachable on port 5173
3. API `/auth/register` accepts valid registration (alphanumeric username only)
4. API `/auth/login` returns a JWT token
5. API `/auth/me` returns the authenticated user profile
6. API `/auth/game-account` returns game account status for the authenticated user
### Manual Docker Verification
```bash
# 1. Build and start all services
sudo docker compose build
sudo docker compose up -d
# 2. Wait for API to be ready
sleep 5
# 3. Verify API is up
curl -s http://localhost:3001/health || echo "API health check not configured — try registering a test user instead"
# 4. Test registration (username must be alphanumeric)
curl -s -X POST http://localhost:3001/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"testuser123","email":"test@example.com","password":"testpass123"}'
# 5. Test login
curl -s -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"testpass123"}'
# 6. Verify frontend is serving
curl -s -o /dev/null -w "%{http_code}" http://localhost:5173
# Expected: 200
```
### Running Tests in Docker (API)
```bash
# Access the API container shell
sudo docker exec -it la2_portal_api sh
# Inside the container, run type checks
npx tsc --noEmit
# Exit the container shell
exit
```
### Running Tests in Docker (Frontend)
```bash
# Access the React container shell (if using dev image)
sudo docker exec -it la2_portal_fe sh
# Inside the container, run the build to verify no TypeScript errors
npm run build
# Exit the container shell
exit
```
## Maintenance Commands
```bash
+3 -1
View File
@@ -6,7 +6,9 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "npx tsx src/index.ts",
"migrate": "npx prisma migrate dev"
"migrate": "npx prisma migrate dev",
"test": "echo 'No unit tests configured. Use ../test/smoke-test.sh for Docker smoke testing.'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^5.6.0",
+132 -10
View File
@@ -25,6 +25,8 @@ async function getWorldPool(): Promise<mssql.ConnectionPool> {
return worldPool;
}
const USERNAME_REGEX = /^[a-zA-Z0-9]+$/;
interface AuthRequest extends Request {
user?: {
id: number;
@@ -35,18 +37,18 @@ interface AuthRequest extends Request {
// Login endpoint
export const login = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { username, password } = req.body;
const { email, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// Find user by username or email
const user = await prisma.websiteAccount.findFirst({
where: {
OR: [
{ username: username },
{ email: username }
{ username: email },
{ email: email }
]
}
});
@@ -77,12 +79,16 @@ export const login = async (req: AuthRequest, res: Response, next: NextFunction)
// Register endpoint - creates both website account and game server account
export const register = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { username, email, password, gameAccountName } = req.body;
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (!USERNAME_REGEX.test(username)) {
return res.status(400).json({ error: 'Username must contain only English letters and numbers' });
}
// Check if username exists
const existingUser = await prisma.websiteAccount.findUnique({
where: { username }
@@ -121,14 +127,11 @@ export const register = async (req: AuthRequest, res: Response, next: NextFuncti
}
});
// Determine game account name (can be same as username or custom)
const accountName = gameAccountName || username;
let gameAccount = null;
// Try to create game server account if configured
try {
gameAccount = await createGameServerAccount(accountName, password, email, websiteUser.id);
gameAccount = await createGameServerAccount(username, password, email, websiteUser.id);
} catch (gameError) {
console.warn('Failed to create game server account:', gameError);
// Don't fail registration if game server is unavailable
@@ -366,3 +369,122 @@ export const linkGameAccount = async (req: AuthRequest, res: Response, next: Nex
next(error);
}
};
// Get current user's game account status
export const getGameAccountStatus = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = (req as any).user?.id;
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const gameAccount = await prisma.gameAccount.findFirst({
where: { websiteUserId: userId }
});
if (!gameAccount) {
return res.json({ hasGameAccount: false, accountName: null, existsOnServer: false });
}
try {
const pool = await getDbPool();
const result = await pool.request()
.input('account', mssql.VarChar, gameAccount.accountName)
.query('SELECT COUNT(*) as count FROM lin2db.dbo.user_account WHERE account = @account');
const existsOnServer = result.recordset[0].count > 0;
res.json({
hasGameAccount: true,
accountName: gameAccount.accountName,
existsOnServer
});
} catch {
res.json({
hasGameAccount: true,
accountName: gameAccount.accountName,
existsOnServer: false
});
}
} catch (error) {
next(error);
}
};
// Create a game server account for the current user
export const createMyGameAccount = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = (req as any).user?.id;
const { password } = req.body;
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!password) {
return res.status(400).json({ error: 'Password is required' });
}
// Enforce one game account per user
const existingLink = await prisma.gameAccount.findFirst({
where: { websiteUserId: userId }
});
if (existingLink) {
return res.status(400).json({ error: 'You already have a game server account' });
}
const user = await prisma.websiteAccount.findUnique({
where: { id: userId }
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const gameAccount = await createGameServerAccount(user.username, password, user.email, userId);
res.status(201).json({
message: 'Game account created successfully',
gameAccount: {
accountName: gameAccount.accountName,
ssn: gameAccount.ssn
}
});
} catch (error) {
next(error);
}
};
// Update game server password for the current user
export const updateGamePassword = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = (req as any).user?.id;
const { password } = req.body;
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!password) {
return res.status(400).json({ error: 'Password is required' });
}
const gameAccount = await prisma.gameAccount.findFirst({
where: { websiteUserId: userId }
});
if (!gameAccount) {
return res.status(404).json({ error: 'Game account not linked' });
}
const pool = await getDbPool();
await pool.request()
.input('account', mssql.VarChar, gameAccount.accountName)
.input('password', mssql.Binary, Buffer.from(password))
.query('UPDATE lin2db.dbo.user_auth SET password = @password WHERE account = @account');
res.json({ message: 'Password updated successfully' });
} catch (error) {
next(error);
}
};
+4 -1
View File
@@ -1,5 +1,5 @@
import { Router } from 'express';
import { register, login, getMe, linkGameAccount } from '../controllers/auth.controller';
import { register, login, getMe, linkGameAccount, getGameAccountStatus, createMyGameAccount, updateGamePassword } from '../controllers/auth.controller';
import { protectRoute } from '../middleware/auth';
const router = Router();
@@ -11,5 +11,8 @@ router.post('/login', login);
// Protected routes
router.get('/me', protectRoute, getMe);
router.post('/link-game-account', protectRoute, linkGameAccount);
router.get('/game-account', protectRoute, getGameAccountStatus);
router.post('/game-account', protectRoute, createMyGameAccount);
router.put('/game-account/password', protectRoute, updateGamePassword);
export default router;
+5
View File
@@ -14,6 +14,11 @@ services:
GAME_SERVER_USER: ${GAME_SERVER_USER:-sa}
GAME_SERVER_PASSWORD: ${GAME_SERVER_PASSWORD:-}
GAME_SERVER_DB: ${GAME_SERVER_DB:-lin2db}
GAME_WORLD_HOST: ${GAME_WORLD_HOST:-127.0.0.1}
GAME_WORLD_PORT: ${GAME_WORLD_PORT:-1433}
GAME_WORLD_USER: ${GAME_WORLD_USER:-sa}
GAME_WORLD_PASSWORD: ${GAME_WORLD_PASSWORD:-}
GAME_WORLD_DB: ${GAME_WORLD_DB:-lin2world}
ports:
- "3001:3001"
volumes:
+3 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"test": "echo 'No unit tests configured. Use ../test/smoke-test.sh for Docker smoke testing.'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
+7
View File
@@ -4,6 +4,7 @@ import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
import AddCharacterPage from './pages/AddCharacterPage';
import GameServerPage from './pages/GameServerPage';
import { useAuth } from './hooks/useAuth';
function App() {
@@ -29,6 +30,12 @@ function App() {
isAuthenticated ? <AddCharacterPage /> : <Navigate to="/login" />
}
/>
<Route
path="/dashboard/game-server"
element={
isAuthenticated ? <GameServerPage /> : <Navigate to="/login" />
}
/>
</Routes>
</BrowserRouter>
);
+57 -20
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../utils/api';
import { useCharacters } from '../hooks/useCharacters';
@@ -24,16 +24,39 @@ interface GameCharacter {
createdAt: string;
}
interface GameAccountInfo {
hasGameAccount: boolean;
accountName: string | null;
existsOnServer: boolean;
}
function DashboardPage() {
const navigate = useNavigate();
const { characters, loading, fetchCharacters } = useCharacters();
const [showCreateForm, setShowCreateForm] = useState(false);
const [createError, setCreateError] = useState('');
const [userId, setUserId] = useState('');
const [gameAccount, setGameAccount] = useState<GameAccountInfo | null>(null);
const [accountLoading, setAccountLoading] = useState(true);
useEffect(() => {
const fetchGameAccount = async () => {
try {
setAccountLoading(true);
const response = await api.get<GameAccountInfo>('/auth/game-account');
setGameAccount(response.data);
} catch {
setGameAccount({ hasGameAccount: false, accountName: null, existsOnServer: false });
} finally {
setAccountLoading(false);
}
};
fetchGameAccount();
}, []);
const handleDelete = async (charName: string) => {
if (!confirm(`Are you sure you want to delete character "${charName}"?`)) return;
try {
const userId = gameAccount?.accountName || '';
await api.delete(`/users/me/characters/${charName}`, { data: { userId } });
fetchCharacters();
} catch {
@@ -52,13 +75,13 @@ function DashboardPage() {
return;
}
if (!userId) {
setCreateError('User account is required');
if (!gameAccount?.accountName) {
setCreateError('Game server account is required');
return;
}
try {
await api.post('/users/me/characters', { name, userId });
await api.post('/users/me/characters', { name, userId: gameAccount.accountName });
fetchCharacters();
setShowCreateForm(false);
} catch {
@@ -79,6 +102,9 @@ function DashboardPage() {
<button className="btn-primary" onClick={() => setShowCreateForm(true)}>
+ New Character
</button>
<button className="btn-secondary" onClick={() => navigate('/dashboard/game-server')}>
Game Server
</button>
<button className="btn-secondary" onClick={() => navigate('/')}>
Back to Home
</button>
@@ -86,23 +112,34 @@ function DashboardPage() {
</header>
<div className="dashboard-content">
{/* User Account Input */}
{/* Game Server Account Info */}
<div className="card" style={{ marginBottom: '24px' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label htmlFor="userId">Game Server Account</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
id="userId"
type="text"
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="Enter your game server account name"
style={{ flex: 1 }}
/>
<button className="btn-secondary" onClick={fetchCharacters}>
Load Characters
</button>
</div>
<label>Game Server Account</label>
{accountLoading ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '0.95rem' }}>Loading account status...</div>
) : gameAccount?.hasGameAccount && gameAccount.existsOnServer ? (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<input
type="text"
value={gameAccount.accountName || ''}
disabled
style={{ flex: 1, background: 'var(--surface)', color: 'var(--text-secondary)', cursor: 'not-allowed' }}
/>
<button className="btn-secondary" onClick={fetchCharacters}>
Load Characters
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.95rem' }}>
No game server account linked.
</span>
<button className="btn-primary" onClick={() => navigate('/dashboard/game-server')}>
Create Game Account
</button>
</div>
)}
</div>
</div>
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../utils/api';
interface GameAccountStatus {
hasGameAccount: boolean;
accountName: string | null;
existsOnServer: boolean;
}
function GameServerPage() {
const navigate = useNavigate();
const [status, setStatus] = useState<GameAccountStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createConfirm, setCreateConfirm] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newConfirm, setNewConfirm] = useState('');
const fetchStatus = async () => {
try {
setLoading(true);
const response = await api.get<GameAccountStatus>('/auth/game-account');
setStatus(response.data);
setError('');
} catch {
setError('Failed to load game account status');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (createPassword !== createConfirm) {
setError('Passwords do not match');
return;
}
if (createPassword.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await api.post('/auth/game-account', { password: createPassword });
setCreatePassword('');
setCreateConfirm('');
setSuccess('Game account created successfully!');
fetchStatus();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to create game account');
}
};
const handleUpdatePassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (newPassword !== newConfirm) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await api.put('/auth/game-account/password', { password: newPassword });
setNewPassword('');
setNewConfirm('');
setSuccess('Password updated successfully!');
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to update password');
}
};
const renderContent = () => {
if (loading) {
return <div className="loading">Loading game account status...</div>;
}
if (!status?.hasGameAccount || !status?.existsOnServer) {
return (
<>
<div style={{ marginBottom: '20px', color: 'var(--text-secondary)' }}>
<p>
Your game server account will be created with the same username as your web account.
</p>
<p>Account name: <strong>{status?.accountName || 'Your username'}</strong></p>
</div>
<form onSubmit={handleCreate}>
<div className="form-group">
<label htmlFor="create-password">Password</label>
<input
id="create-password"
type="password"
value={createPassword}
onChange={e => setCreatePassword(e.target.value)}
placeholder="Minimum 6 characters"
required
minLength={6}
/>
</div>
<div className="form-group">
<label htmlFor="create-confirm">Confirm Password</label>
<input
id="create-confirm"
type="password"
value={createConfirm}
onChange={e => setCreateConfirm(e.target.value)}
placeholder="Re-enter your password"
required
minLength={6}
/>
</div>
<button className="btn-primary" type="submit">
Create Game Account
</button>
</form>
</>
);
}
return (
<>
<div style={{ marginBottom: '24px' }}>
<p style={{ color: 'var(--text-secondary)' }}>
Game account: <strong>{status.accountName}</strong>
</p>
<p style={{ color: 'var(--success)', fontSize: '0.9rem' }}>Account is active on the game server.</p>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', marginBottom: '24px' }} />
<h3 style={{ marginBottom: '16px' }}>Update Game Password</h3>
<form onSubmit={handleUpdatePassword}>
<div className="form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Minimum 6 characters"
required
minLength={6}
/>
</div>
<div className="form-group">
<label htmlFor="new-confirm">Confirm New Password</label>
<input
id="new-confirm"
type="password"
value={newConfirm}
onChange={e => setNewConfirm(e.target.value)}
placeholder="Re-enter new password"
required
minLength={6}
/>
</div>
<button className="btn-primary" type="submit">
Update Password
</button>
</form>
</>
);
};
return (
<div className="dashboard">
<header className="dashboard-header">
<div>
<h1>Game Server</h1>
<p style={{ color: 'var(--text-secondary)', marginTop: '4px', fontSize: '0.95rem' }}>
Manage your Lineage II game server account
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button className="btn-secondary" onClick={() => navigate('/dashboard')}>
Back to Dashboard
</button>
</div>
</header>
<div className="dashboard-content">
<div className="card" style={{ maxWidth: '560px' }}>
{(error || success) && (
<div
style={{
padding: '12px 16px',
borderRadius: 'var(--radius-md)',
marginBottom: '20px',
fontSize: '0.9rem',
...(error
? {
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
color: 'var(--error)'
}
: {
background: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
color: 'var(--success)'
})
}}
>
{error || success}
</div>
)}
{renderContent()}
</div>
</div>
</div>
);
}
export default GameServerPage;
+6
View File
@@ -15,6 +15,12 @@ function RegisterPage() {
e.preventDefault();
setError('');
const USERNAME_REGEX = /^[a-zA-Z0-9]+$/;
if (!USERNAME_REGEX.test(username)) {
setError('Username must contain only English letters and numbers');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
set -e
API_URL="http://localhost:3001"
FE_URL="http://localhost:5173"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
passed=0
failed=0
log_pass() {
echo -e "${GREEN}PASS${NC}: $1"
((passed++)) || true
}
log_fail() {
echo -e "${RED}FAIL${NC}: $1"
((failed++)) || true
}
log_info() {
echo -e "${YELLOW}INFO${NC}: $1"
}
echo "========================================"
echo " LA2 Eternal Portal - Docker Smoke Test"
echo "========================================"
echo ""
# 1. Check API container is running
log_info "Checking API container..."
if curl -s -o /dev/null -w "%{http_code}" "$API_URL/api/auth/register" | grep -q "400\|404\|405\|200"; then
log_pass "API is reachable on port 3001"
else
log_fail "API is not reachable on port 3001"
fi
# 2. Check Frontend container is running
log_info "Checking Frontend container..."
FE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$FE_URL" || echo "000")
if [ "$FE_CODE" = "200" ] || [ "$FE_CODE" = "301" ]; then
log_pass "Frontend is reachable on port 5173 (HTTP $FE_CODE)"
else
log_fail "Frontend is not reachable on port 5173 (HTTP $FE_CODE)"
fi
# 3. Test registration with invalid username (should fail)
log_info "Testing registration validation (invalid username)..."
REG_INVALID=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/api/auth/register" \
-H "Content-Type: application/json" \
-d '{"username":"bad@user!","email":"bad@example.com","password":"testpass123"}' || echo "000")
REG_INVALID_CODE=$(echo "$REG_INVALID" | tail -n1)
if [ "$REG_INVALID_CODE" = "400" ]; then
log_pass "Registration rejects invalid username (alphanumeric validation)"
else
log_fail "Registration did not reject invalid username (HTTP $REG_INVALID_CODE)"
fi
# 4. Test registration with valid username
UNIQUE_USER="testuser$(date +%s)"
log_info "Testing registration with valid username: $UNIQUE_USER..."
REG_RESULT=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/api/auth/register" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$UNIQUE_USER\",\"email\":\"$UNIQUE_USER@example.com\",\"password\":\"testpass123\"}" || echo "000")
REG_CODE=$(echo "$REG_RESULT" | tail -n1)
if [ "$REG_CODE" = "201" ]; then
log_pass "Registration succeeds with valid username"
else
log_fail "Registration failed (HTTP $REG_CODE) — user may already exist"
fi
# 5. Test login
log_info "Testing login..."
LOGIN_RESULT=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/api/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$UNIQUE_USER@example.com\",\"password\":\"testpass123\"}" || echo "000")
LOGIN_CODE=$(echo "$LOGIN_RESULT" | tail -n1)
TOKEN=$(echo "$LOGIN_RESULT" | sed '$d' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
if [ "$LOGIN_CODE" = "200" ] && [ -n "$TOKEN" ]; then
log_pass "Login returns JWT token"
else
log_fail "Login failed (HTTP $LOGIN_CODE)"
fi
# 6. Test /auth/me with token
if [ -n "$TOKEN" ]; then
log_info "Testing /auth/me..."
ME_RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/auth/me" \
-H "Authorization: Bearer $TOKEN" || echo "000")
ME_CODE=$(echo "$ME_RESULT" | tail -n1)
if [ "$ME_CODE" = "200" ]; then
log_pass "/auth/me returns authenticated user profile"
else
log_fail "/auth/me failed (HTTP $ME_CODE)"
fi
# 7. Test /auth/game-account with token
log_info "Testing /auth/game-account..."
GA_RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/auth/game-account" \
-H "Authorization: Bearer $TOKEN" || echo "000")
GA_CODE=$(echo "$GA_RESULT" | tail -n1)
if [ "$GA_CODE" = "200" ]; then
log_pass "/auth/game-account returns game account status"
else
log_fail "/auth/game-account failed (HTTP $GA_CODE)"
fi
fi
echo ""
echo "========================================"
echo " Results: $passed passed, $failed failed"
echo "========================================"
if [ "$failed" -gt 0 ]; then
exit 1
fi