registration + account sync
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Executable
+121
@@ -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
|
||||
Reference in New Issue
Block a user