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:
1234
database/backend.log
1234
database/backend.log
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
18
src/App.js
18
src/App.js
@@ -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"
|
||||
|
||||
@@ -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.'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user