Fix login UI: add user dropdown selector, fix build syntax error

- Replace username text input with dropdown selector for player login
- Fix unescaped apostrophe in MissionSimTab.jsx (Emperor's -> double quotes)
- Fix em-dash encoding issues in MissionSimTab.jsx
- Update player password to 1234 in .env
This commit is contained in:
2026-06-23 14:43:19 +02:00
parent 182afab282
commit 254ba5bd1d
9 changed files with 1442 additions and 53 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,17 @@ module.exports = {
apps: [
{
name: 'deathwatch-server',
script: './server.js',
cwd: __dirname,
script: '/home/alex/bin/start-dwroller.sh',
cwd: '/nas/git/dwroller/database',
watch: false,
restart_delay: 5000,
max_restarts: 10,
max_restarts: 9999,
max_memory_restart: '500M',
instances: 1,
exec_mode: 'fork',
error_file: '/home/alex/.pm2/logs/dwroller-error.log',
out_file: '/home/alex/.pm2/logs/dwroller-out.log',
merge_logs: true,
env: {
NODE_ENV: 'production',
PORT: 5000,

View File

@@ -9,41 +9,48 @@ router.post('/login', async (req, res) => {
console.log('Login attempt for player:', name);
if (!name || !password) {
return res.status(400).json({ error: 'Name and password required' });
return res.status(400).json({ error: 'Please enter both username and password' });
}
// Check if player exists first
const player = await playerHelpers.getByName(name);
if (!player) {
// Return generic message for security (don't reveal if user exists)
return res.status(401).json({ error: 'Invalid username or password' });
}
// Special handling for GM user
if (name.toLowerCase() === 'gm') {
const gmPassword = process.env.GM_PASSWORD || 'defaultpassword';
const gmPassword = process.env.GM_PASSWORD || 'bongo';
if (password !== gmPassword) {
return res.status(401).json({ error: 'Invalid password' });
return res.status(401).json({ error: 'Invalid username or password' });
}
} else {
// For regular players, use environment variable or default
const playerPassword = process.env.PLAYER_PASSWORD || 'defaultpassword';
if (password !== playerPassword) {
return res.status(401).json({ error: 'Invalid password' });
return res.status(401).json({ error: 'Invalid username or password' });
}
}
const player = await playerHelpers.getByName(name);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
// Generate a session ID with expiration
const sessionId = `session_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Generate a simple session ID (in production, use proper session management)
const sessionId = `session_${name}_${Date.now()}`;
// Store session in database with expiration
await require('../sessionModel').createSession(sessionId, { playerName: name, isGM: name.toLowerCase() === 'gm' }, expiresAt);
logToFile('API: Player login', name, 'success');
res.json({
success: true,
sessionId,
player: { name: player.name }
player: { name: player.name },
expiresAt: expiresAt.toISOString()
});
} catch (error) {
console.error('Login error:', error);
logToFile('API: Failed to login player', req.body?.name, error);
res.status(500).json({ error: String(error) });
res.status(500).json({ error: 'Login failed. Please try again later.' });
}
});
@@ -156,7 +163,8 @@ router.post('/', async (req, res) => {
// --- GM Endpoints (require x-gm-secret header) ---
function requireGM(req, res) {
if (req.headers['x-gm-secret'] !== 'bongo') {
const gmSecret = process.env.GM_SECRET || 'bongo';
if (req.headers['x-gm-secret'] !== gmSecret) {
res.status(403).json({ error: 'Unauthorized' });
return false;
}
@@ -242,15 +250,24 @@ router.post('/gm/set-renown', async (req, res) => {
router.post('/gm/add-or-update', async (req, res) => {
if (!requireGM(req, res)) return;
try {
const { name, requisitionPoints, password } = req.body;
const { name, requisitionPoints, password, charName } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
let player = await playerHelpers.getByName(name);
if (!player) {
await playerHelpers.create({ name, pw: password || '1234', tabInfo: { rp: parseInt(requisitionPoints) || 0 } });
await playerHelpers.create({
name,
pw: password || '1234',
tabInfo: {
rp: parseInt(requisitionPoints) || 0,
charName: charName || name
}
});
logToFile('API: GM added player', name);
} else {
await updateTabInfo(name, { rp: parseInt(requisitionPoints) || 0 });
const updatedTabInfo = { rp: parseInt(requisitionPoints) || 0 };
if (charName) updatedTabInfo.charName = charName;
await updateTabInfo(name, updatedTabInfo);
if (password) {
await playerHelpers.update(name, { rollerInfo: player.rollerInfo, shopInfo: player.shopInfo, tabInfo: player.tabInfo, pw: password, pwHash: '' });
}

View File

@@ -173,6 +173,34 @@ app.post('/api/copy-bestiary', (req, res) => {
}
});
// Local Ollama narrator — used by MissionSimTab callCopilot()
app.post('/api/narrate', express.json({ limit: '4mb' }), async (req, res) => {
try {
const { prompt, systemPrompt } = req.body || {};
if (!prompt) return res.status(400).json({ error: 'Missing prompt' });
const ollamaBase = process.env.OLLAMA_BASE || 'http://localhost:11434';
const model = process.env.NARRATOR_MODEL || 'qwen3.5:latest';
const messages = [];
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
messages.push({ role: 'user', content: `/no_think ${prompt}` });
const response = await fetch(`${ollamaBase}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: false, think: false }),
});
if (!response.ok) {
const errText = await response.text();
return res.status(502).json({ error: 'Ollama error', detail: errText });
}
const data = await response.json();
const text = data.message?.content || data.response || '';
return res.json({ text });
} catch (err) {
console.error('Narrator error:', err);
return res.status(500).json({ error: 'Narrator failed', detail: err.message });
}
});
// Serve static files from build directory (React app)
const buildDir = path.join(__dirname, '..', 'build');
app.use(express.static(buildDir));

View File

@@ -1,10 +1,54 @@
const mongoose = require('mongoose');
const { sessionHelpers, logToFile } = require('./mariadb');
const sessionSchema = new mongoose.Schema({
sessionId: { type: String, required: true, unique: true },
playerName: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
expiresAt: { type: Date, required: true },
});
async function createSession(sessionId, data, expiresAt) {
try {
// Check if session already exists
const existing = await sessionHelpers.get(sessionId);
if (existing) {
await sessionHelpers.update(sessionId, data);
return existing;
}
await sessionHelpers.create(sessionId, data, expiresAt);
logToFile('Session created:', sessionId);
} catch (error) {
console.error('Error creating session:', error);
logToFile('Session creation error:', error);
throw error;
}
}
module.exports = mongoose.model('Session', sessionSchema);
async function validateSession(sessionId) {
try {
const session = await sessionHelpers.get(sessionId);
if (!session) return null;
// Check expiration
if (session.expires_at && new Date(session.expires_at) < new Date()) {
await sessionHelpers.delete(sessionId);
logToFile('Session expired:', sessionId);
return null;
}
return session;
} catch (error) {
console.error('Error validating session:', error);
logToFile('Session validation error:', error);
return null;
}
}
async function deleteSession(sessionId) {
try {
await sessionHelpers.delete(sessionId);
logToFile('Session deleted:', sessionId);
} catch (error) {
console.error('Error deleting session:', error);
logToFile('Session deletion error:', error);
}
}
module.exports = {
createSession,
validateSession,
deleteSession
};

View File

@@ -240,14 +240,16 @@ function App() {
</div>
) : (
<div className="flex flex-col sm:flex-row gap-2">
<input
className="rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-white placeholder-slate-400 text-sm"
type="text"
placeholder="Username"
value={loginName}
onChange={e=>setLoginName(e.target.value)}
onKeyPress={handleKeyPress}
/>
<select
className="rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-white text-sm"
value={loginName}
onChange={e=>setLoginName(e.target.value)}
>
<option value="">Select user...</option>
{players.filter(p => p.name !== 'gm').map(p => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
<input
className="rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-white placeholder-slate-400 text-sm"
type="password"

View File

@@ -77,9 +77,9 @@ const ENEMY_THEMES = {
flavor: 'The swarm descends upon you, a tide of chitin and chitin-clawed hunger.',
sceneNames: ['The Swarm Approaches', 'First Contact', 'The Hive Mind Awakens', 'The Tyrant Rises', 'Extermination', 'The Last Stand'],
descriptions: [
'A distant tremor grows into a roar the swarm is upon you. Wave after wave of chitin and claws, driven by a hunger older than the Imperium itself.',
'A distant tremor grows into a roar - the swarm is upon you. Wave after wave of chitin and claws, driven by a hunger older than the Imperium itself.',
'The first wave hits with terrifying speed. Hormagaunts pour over the ridge, their screeching filling the air as they close in from every direction.',
'Through the chaos, a larger shape emerges a Tyranid Warrior, its carapace gleaming with the intelligence of the Hive Mind. It directs the swarm with terrifying purpose.',
'Through the chaos, a larger shape emerges - a Tyranid Warrior, its carapace gleaming with the intelligence of the Hive Mind. It directs the swarm with terrifying purpose.',
'The ground shakes as the Hive Tyrant rises to its full height. A creature of pure destruction, it commands the swarm with the full might of the Hive Mind.',
'The swarm thins, but the Tyrant remains. Its carapace is thick, its claws deadly. The Astartes stand firm, but the cost of victory will be high.',
'One by one, the creatures fall. The swarm is broken. But the Hive Mind will send more. The Astartes stand victorious, but the war is far from over.'
@@ -95,7 +95,7 @@ const ENEMY_THEMES = {
'The first exchange of fire is brutal. Chaos Space Marines return fire with devastating accuracy, their bolters roaring as they push forward.',
'The battle intensifies as more enemies pour through the breach. The Astartes hold the line, but the cost of holding the position grows with each passing moment.',
'A Chaos Champion steps forward, his power weapon crackling with dark energy. He challenges the Astartes to single combat, his eyes burning with corrupted fervor.',
'The Champion falls, his corrupted armor shattered. But the battle is far from over more enemies pour through the breach, their numbers seemingly endless.',
'The Champion falls, his corrupted armor shattered. But the battle is far from over - more enemies pour through the breach, their numbers seemingly endless.',
"The last of the corrupted ones falls. The Astartes stand victorious, but the cost of victory is high. The Emperor's light shines through the darkness."
]
},
@@ -108,7 +108,7 @@ const ENEMY_THEMES = {
'The alien threat emerges from the shadows, their weapons trained on the Astartes. The Tau Commander stands at the head of his forces, his Crisis Suit gleaming with advanced technology.',
'The first exchange of fire is brutal. The Tau Commander\'s Crisis Suit returns fire with devastating accuracy, its plasma cannon roaring as it pushes forward.',
'The battle intensifies as more enemies pour through the breach. The Astartes hold the line, but the cost of holding the position grows with each passing moment.',
'The Tau Commander falls, his Crisis Suit shattered. But the battle is far from over more enemies pour through the breach, their numbers seemingly endless.',
'The Tau Commander falls, his Crisis Suit shattered. But the battle is far from over - more enemies pour through the breach, their numbers seemingly endless.',
'The last of the xenos falls. The Astartes stand victorious, but the cost of victory is high. The Emperor\'s light shines through the darkness.',
'The alien threat is broken. The Astartes stand victorious, but the war is far from over. The Emperor\'s light shines through the darkness.'
]
@@ -123,7 +123,7 @@ const ENEMY_THEMES = {
'The first exchange of fire is brutal. Ork Boys return fire with devastating accuracy, their shootas roaring as they push forward.',
'The battle intensifies as more Orks pour through the breach. The Astartes hold the line, but the cost of holding the position grows with each passing moment.',
'An Ork Nob steps forward, his power klaw crackling with dark energy. He challenges the Astartes to single combat, his eyes burning with Ork fervor.',
'The Nob falls, his power klaw shattered. But the battle is far from over more Orks pour through the breach, their numbers seemingly endless.',
'The Nob falls, his power klaw shattered. But the battle is far from over - more Orks pour through the breach, their numbers seemingly endless.',
'The last of the Orks falls. The Astartes stand victorious, but the cost of victory is high. The Emperor\'s light shines through the darkness.'
]
}

View File

@@ -81,16 +81,16 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
}
}
async function gmAddOrUpdatePlayer(name, rp, pw) {
console.log('PlayerManagement: gmAddOrUpdatePlayer', { name, rp, pwProvided: !!pw });
async function gmAddOrUpdatePlayer(name, rp, pw, charName) {
console.log('PlayerManagement: gmAddOrUpdatePlayer', { name, rp, pwProvided: !!pw, charName });
try {
const defaultPassword = process.env.REACT_APP_PLAYER_PASSWORD || 'defaultpassword';
const resPost = await axios.post('/api/players/gm/add-or-update',
{ name, requisitionPoints: parseInt(rp), password: pw || defaultPassword },
{ name, requisitionPoints: parseInt(rp), password: pw || defaultPassword, charName: charName || name },
{ headers: buildHeaders() }
);
console.log('PlayerManagement: gmAddOrUpdatePlayer POST response', resPost.status, resPost.data);
flash(`Added/Updated player ${name} with ${rp} RP`);
flash(`Added/Updated player ${name}${charName ? ` (as ${charName})` : ''} with ${rp} RP`);
// Refresh players list
await fetchPlayers();
@@ -179,13 +179,14 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
// Component for adding new players
function GmAddPlayer({ onAdd }) {
const [name, setName] = useState('');
const [charName, setCharName] = useState('');
const [rp, setRp] = useState('50');
const [pw, setPw] = useState('');
return (
<div className="bg-slate-800 rounded-lg p-4 border border-slate-600">
<h4 className="text-lg font-medium text-white mb-3">Add New Player</h4>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<input
className="rounded border border-slate-600 bg-slate-800 px-3 py-2 text-white placeholder-slate-400 text-sm"
type="text"
@@ -193,6 +194,13 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
value={name}
onChange={e => setName(e.target.value)}
/>
<input
className="rounded border border-slate-600 bg-slate-800 px-3 py-2 text-white placeholder-slate-400 text-sm"
type="text"
placeholder="Character Name (e.g. Brother Lucian)"
value={charName}
onChange={e => setCharName(e.target.value)}
/>
<input
className="rounded border border-slate-600 bg-slate-800 px-3 py-2 text-white placeholder-slate-400 text-sm"
type="number"
@@ -212,8 +220,9 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
onClick={() => {
if (name.trim()) {
const defaultPassword = process.env.REACT_APP_PLAYER_PASSWORD || 'defaultpassword';
onAdd(name.trim(), rp, pw || defaultPassword);
onAdd(name.trim(), rp, pw || defaultPassword, charName.trim() || name.trim());
setName('');
setCharName('');
setRp('50');
setPw('');
}

View File

@@ -69,7 +69,8 @@ describe('Login Functionality', () => {
data: {
success: true,
sessionId: 'session_gm_12345',
player: { name: 'gm' }
player: { name: 'gm' },
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
},
status: 200
});
@@ -87,7 +88,7 @@ describe('Login Functionality', () => {
const loginButton = screen.getByRole('button', { name: /login|enter/i });
// Fill in login form with GM credentials (test credentials)
const testGmPassword = process.env.GM_PASSWORD || 'defaultpassword';
const testGmPassword = 'bongo'; // GM password from .env
fireEvent.change(nameInput, { target: { value: 'gm' } });
fireEvent.change(passwordInput, { target: { value: testGmPassword } });
@@ -115,7 +116,7 @@ describe('Login Functionality', () => {
mockedAxios.post.mockRejectedValueOnce({
response: {
status: 401,
data: { error: 'Invalid password' }
data: { error: 'Invalid username or password' }
},
message: 'Request failed with status code 401'
});
@@ -142,10 +143,10 @@ describe('Login Functionality', () => {
test('login with non-existent player fails', async () => {
mockedAxios.post.mockRejectedValueOnce({
response: {
status: 404,
data: { error: 'Player not found' }
status: 401,
data: { error: 'Invalid username or password' }
},
message: 'Request failed with status code 404'
message: 'Request failed with status code 401'
});
render(<App />);
@@ -158,7 +159,7 @@ describe('Login Functionality', () => {
const passwordInput = screen.getByPlaceholderText(/password/i);
const loginButton = screen.getByRole('button', { name: /login|enter/i });
const testPlayerPassword = process.env.PLAYER_PASSWORD || 'defaultpassword';
const testPlayerPassword = '1234'; // Player password from .env
fireEvent.change(nameInput, { target: { value: 'nonexistent' } });
fireEvent.change(passwordInput, { target: { value: testPlayerPassword } });
fireEvent.click(loginButton);
@@ -167,4 +168,52 @@ describe('Login Functionality', () => {
expect(screen.getByText(/login failed/i)).toBeInTheDocument();
});
});
test('login with player account', async () => {
mockedAxios.post.mockResolvedValueOnce({
data: {
success: true,
sessionId: 'session_anders_12345',
player: { name: 'anders' },
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
},
status: 200
});
render(<App />);
// Wait for players list to load
await waitFor(() => {
expect(screen.getByText(/anders/i)).toBeInTheDocument();
});
// Find login inputs
const nameInput = screen.getByPlaceholderText(/player name|username/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
const loginButton = screen.getByRole('button', { name: /login|enter/i });
// Fill in login form with player credentials
const testPlayerPassword = '1234'; // Player password from .env
fireEvent.change(nameInput, { target: { value: 'anders' } });
fireEvent.change(passwordInput, { target: { value: testPlayerPassword } });
// Click login button
fireEvent.click(loginButton);
// Verify login was called with correct parameters
await waitFor(() => {
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/players/login',
{
name: 'anders',
password: testPlayerPassword
}
);
});
// Verify success message appears
await waitFor(() => {
expect(screen.getByText(/login successful/i)).toBeInTheDocument();
}, { timeout: 3000 });
});
});