Moves GM player management to PlayerTab

Centralizes all GM controls—add, update, delete player, set RP or renown, reset player passwords—into the PlayerTab for a more intuitive workflow. Removes GM panel logic and duplication from the shop view, clarifies GM access, and improves error feedback on player list loading. Adds a backend health check endpoint and minor UI polish.
This commit is contained in:
2025-08-15 11:12:49 +02:00
parent 90f2a94974
commit b47065edbf
14 changed files with 494 additions and 138 deletions

View File

@@ -0,0 +1,24 @@
name: Server Healthcheck
on:
workflow_dispatch:
schedule:
- cron: '*/15 * * * *' # every 15 minutes
jobs:
healthcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: |
cd database
npm ci --silent || true
- name: Run healthcheck
run: |
cd database
node healthcheck.js

View File

@@ -161,3 +161,22 @@ Connected to MongoDB
[2025-08-15T08:12:28.154Z] API: Fetch all players (public) Found 3 players
[2025-08-15T08:12:35.062Z] SESSION: Logout success dc5481ec7357cc3b25cfaa2d10bbbbe65496c6645eb0368bb5369d7f13d732a7
[2025-08-15T08:12:35.100Z] API: Fetch all players (public) Found 3 players
[2025-08-15T08:48:12.845Z] API: Fetch all players (public) Found 3 players
[2025-08-15T08:48:15.381Z] SESSION: Login success christoffer 0a26d571b01ed1c2ba2e1ab7fd56cab27434d3e5ca65b2b4a3a7ae68c2f9e4e7
[2025-08-15T08:48:15.392Z] SESSION: Validate success 0a26d571b01ed1c2ba2e1ab7fd56cab27434d3e5ca65b2b4a3a7ae68c2f9e4e7 christoffer
[2025-08-15T08:49:04.328Z] SESSION: Logout success 0a26d571b01ed1c2ba2e1ab7fd56cab27434d3e5ca65b2b4a3a7ae68c2f9e4e7
[2025-08-15T08:49:12.186Z] API: Fetch all players (public) Found 3 players
[2025-08-15T08:49:20.129Z] SESSION: Login success christoffer 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad
[2025-08-15T08:49:20.138Z] API: Fetch all players (public) Found 3 players
[2025-08-15T08:49:20.142Z] SESSION: Validate success 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad christoffer
[2025-08-15T09:07:28.709Z] SESSION: Validate success 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad christoffer
[2025-08-15T09:07:30.722Z] SESSION: Logout success 7ca595896cf4e1ab7ffa90ee2334e71593aaa80380b22e1c0108d6388424faad
[2025-08-15T09:07:36.074Z] API: Fetch all players (public) Found 3 players
[2025-08-15T09:07:44.071Z] SESSION: Login success christoffer 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844
[2025-08-15T09:07:44.079Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer
[2025-08-15T09:08:57.772Z] API: Fetch all players (public) Found 3 players
[2025-08-15T09:09:30.592Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer
[2025-08-15T09:09:31.684Z] API: Fetch all players (public) Found 3 players
[2025-08-15T09:10:12.600Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer
[2025-08-15T09:11:44.183Z] SESSION: Validate success 6cc910f09effb8c44ece418cc44b69fcb17501b7070656b4e1ba0cf95fff4844 christoffer
[2025-08-15T09:12:00.469Z] API: Fetch all players (public) Found 3 players

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Deathwatch Roller API
After=network.target
[Service]
Type=simple
User={{RUN_AS_USER}}
WorkingDirectory={{REPO_DIR}}/database
ExecStart=/usr/bin/node {{REPO_DIR}}/database/server.js
Restart=always
RestartSec=5
EnvironmentFile=/etc/default/deathwatch-server
StandardOutput=journal
StandardError=journal
SyslogIdentifier=deathwatch-server
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./install-systemd.sh /absolute/path/to/repo username
# Example: ./install-systemd.sh /home/alex/git/dwroller alex
if [ "$#" -ne 2 ]; then
echo "Usage: $0 /absolute/path/to/repo username"
exit 2
fi
REPO_DIR=$1
RUN_AS_USER=$2
SERVICE_NAME=deathwatch-server
TEMPLATE=${REPO_DIR}/database/deploy/deathwatch.service.template
TARGET=/etc/systemd/system/${SERVICE_NAME}.service
ENV_FILE=/etc/default/${SERVICE_NAME}
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root or with sudo"
exit 3
fi
if [ ! -f "$TEMPLATE" ]; then
echo "Template not found: $TEMPLATE"
exit 4
fi
# Replace placeholders
sed "s|{{REPO_DIR}}|${REPO_DIR}|g; s|{{RUN_AS_USER}}|${RUN_AS_USER}|g" "$TEMPLATE" > "$TARGET"
# Create an environment file with defaults if not present
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
# deathwatch-server env defaults
PORT=5000
NODE_ENV=production
# You can add other environment variables here, e.g.:
# DATABASE_FILE=/home/you/deathwatch.sqlite
EOF
chown root:root "$ENV_FILE"
chmod 0644 "$ENV_FILE"
fi
# Reload systemd and enable service
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}
echo "Service ${SERVICE_NAME} installed and (re)started. Use: systemctl status ${SERVICE_NAME}"

21
database/healthcheck.js Normal file
View File

@@ -0,0 +1,21 @@
// Simple healthcheck script for local use and systemd
// Exit code 0 = OK, non-zero = failure
const { playerHelpers, db } = require('./sqlite-db');
async function run() {
try {
const players = playerHelpers.getAll();
console.log(`Players count: ${players.length}`);
// optional: print first few names
console.log(players.slice(0, 10).map(p => p.name));
// Close DB and exit 0
db.close();
process.exit(0);
} catch (err) {
console.error('Healthcheck failed:', err && err.message ? err.message : err);
try { db.close(); } catch(e){}
process.exit(2);
}
}
run();

14
database/pm2.config.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
apps: [
{
name: 'deathwatch-server',
script: './server.js',
cwd: __dirname,
watch: false,
env: {
NODE_ENV: 'production',
PORT: 5000
}
}
]
};

View File

@@ -32,6 +32,18 @@ app.get('/', (req, res) => {
res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.');
});
// Health endpoint checks DB connectivity and players
app.get('/api/health', (req, res) => {
try {
const { playerHelpers } = require('./sqlite-db');
const players = playerHelpers.getAll();
res.json({ ok: true, players: players.length, sample: players.slice(0,5).map(p=>p.name) });
} catch (err) {
console.error('Healthcheck error', err);
res.status(500).json({ ok: false, error: err && err.message ? err.message : String(err) });
}
});
// Use routes
app.use('/api/players', playerRoutes);
app.use('/api/sessions', sessionRoutes);
@@ -42,4 +54,9 @@ app.listen(PORT, '0.0.0.0', () => {
console.log('Using SQLite database');
});
// Keep the process alive reliably under systemd (avoid accidental exit)
if (process.stdin && typeof process.stdin.resume === 'function') {
process.stdin.resume();
}
module.exports = app;

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,3 @@
import './App.css';
import DeathwatchRoller from './components/DeathwatchRoller';
import RequisitionShop from './components/RequisitionShop';
@@ -16,19 +15,26 @@ function App() {
const [loginPw, setLoginPw] = useState('');
const [loginMsg, setLoginMsg] = useState('');
const [players, setPlayers] = useState([]);
const [playersError, setPlayersError] = useState('');
// Global GM state (moved from PlayerTab)
const GM_PASSWORD = 'bongo'
const [gmOpen, setGmOpen] = useState(() => {
try { return JSON.parse(localStorage.getItem('dw:gm:session')) || false } catch { return false }
});
const [gmPassInput, setGmPassInput] = useState('');
// Fetch player list for login
useEffect(() => {
async function fetchPlayers() {
try {
const res = await axios.get('/api/players');
setPlayers(res.data);
} catch {
setPlayers([]);
}
// Fetch player list for login (exposed so UI can refresh)
async function fetchPlayers() {
try {
setPlayersError('')
const res = await axios.get('/api/players');
setPlayers(res.data || []);
} catch (err) {
console.error('fetchPlayers failed', err);
setPlayers([]);
setPlayersError('Failed to load users — is the backend running? Check console/network.');
}
fetchPlayers();
}, []);
}
// Validate session on mount/refresh
useEffect(() => {
@@ -134,6 +140,17 @@ function App() {
setTimeout(() => setLoginMsg(''), 3000);
}
function handleGmUnlock() {
if ((gmPassInput || '').trim() === GM_PASSWORD) {
setGmOpen(true);
localStorage.setItem('dw:gm:session', JSON.stringify(true));
} else {
setGmOpen(false);
localStorage.setItem('dw:gm:session', JSON.stringify(false));
}
setGmPassInput('');
}
return (
<div className="App min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Persistent Header */}
@@ -180,6 +197,18 @@ function App() {
{loginMsg && <span className="ml-2 text-xs px-2 py-1 rounded bg-red-500/20 border border-red-500/30 text-red-300">{loginMsg}</span>}
</div>
)}
{/* GM quick unlock in header */}
<div className="ml-4 hidden sm:flex items-center gap-2">
<input
className="rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
type="password"
placeholder="GM Password"
value={gmPassInput}
onChange={e=>setGmPassInput(e.target.value)}
onKeyPress={(e)=>{ if (e.key==='Enter') handleGmUnlock() }}
/>
<button onClick={handleGmUnlock} className="rounded-lg px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-white text-sm">GM</button>
</div>
</div>
</div>
@@ -212,9 +241,19 @@ function App() {
{/* User Info Card for easy access */}
{!authedPlayer && (
<div className="mb-6 p-4 rounded-lg bg-blue-900/20 border border-blue-500/30">
<h3 className="text-sm font-semibold text-blue-300 mb-2">Available Users</h3>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-blue-300">Available Users</h3>
<div className="flex items-center gap-2">
<button onClick={fetchPlayers} className="text-xs px-2 py-1 rounded bg-slate-700/30 hover:bg-slate-700/50">Refresh</button>
</div>
</div>
{playersError ? (
<div className="text-xs text-red-400 mb-2">
{playersError}
</div>
) : null}
<div className="flex flex-wrap gap-2 text-xs">
{players.map(player => (
{players && players.length > 0 ? players.map(player => (
<button
key={player.name}
onClick={() => {setLoginName(player.name); setLoginPw('1234');}}
@@ -222,13 +261,15 @@ function App() {
>
{player.name}
</button>
))}
)) : (
<div className="text-xs text-blue-200/60">No users found. Click Refresh or check backend.</div>
)}
</div>
<p className="text-xs text-blue-300/70 mt-2">Password for all users: <code className="bg-blue-800/40 px-1 rounded">1234</code></p>
</div>
)}
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : <PlayerTab authedPlayer={authedPlayer} sessionId={sessionId} />}
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : <PlayerTab authedPlayer={authedPlayer} sessionId={sessionId} gmOpen={gmOpen} setGmOpen={setGmOpen} />}
</div>
</div>
);

View File

@@ -5,8 +5,7 @@ import { debug, info, warn, logApiCall, logApiError, logUserAction } from '../ut
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer';
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1';
const GM_PASSWORD = 'bongo';
function safeGet(key) {
try {
@@ -314,6 +313,7 @@ function PlayerTab({ authedPlayer, sessionId }) {
// Added GM Panel button and Requisition Points display
const [gmOpen, setGmOpen] = useState(false);
const [gmPassInput, setGmPassInput] = useState('');
// Define shopAuthed for use in the component
const shopAuthed = getShopAuthed();
@@ -330,6 +330,161 @@ function PlayerTab({ authedPlayer, sessionId }) {
safeSet('dw:gm:session', gmOpen);
}, [gmOpen]);
// GM unlock handler (simple client-side check)
function handleGmUnlock() {
if ((gmPassInput || '').trim() === GM_PASSWORD) {
setGmOpen(true);
flash('GM unlocked');
} else {
flash('Bad GM password');
}
setGmPassInput('');
}
// GM delete handler
async function gmDeletePlayer(name) {
try {
logApiCall('PlayerTab', 'DELETE', `/api/players/${name}`)
await axios.delete(`/api/players/${name}`, { headers: { 'x-session-id': sessionId || '' } });
const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } });
setPlayers(res.data);
flash('Player deleted');
} catch (e) {
logApiError('PlayerTab', 'DELETE', `/api/players/${name}`, e);
flash('Failed to delete player');
}
}
// Early return if not authenticated
if (!authedPlayer && !gmOpen) {
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Player Tab</h1>
<p className="text-slate-400">Please log in to access player data</p>
</div>
{/* Small GM login panel under global login */}
<div className="mx-auto max-w-md bg-white/5 rounded-xl p-4 border border-white/10">
<div className="text-sm font-medium mb-2">GM Login</div>
<div className="flex gap-2">
<input
type="password"
className="flex-1 rounded border border-white/10 bg-white/10 px-2 py-1"
placeholder="GM Password"
value={gmPassInput}
onChange={e => setGmPassInput(e.target.value)}
/>
<button onClick={handleGmUnlock} className="px-3 py-1.5 rounded bg-amber-600 hover:bg-amber-500">Unlock</button>
</div>
<div className="text-xs opacity-70 mt-2">GM can create/delete users, set RP and reset passwords.</div>
</div>
</div>
</section>
);
}
// GM management components
function GmAddPlayer({ onAdd }) {
const [name, setName] = useState('')
const [rp, setRp] = useState(10)
const [pw, setPw] = useState('')
return (
<div className="grid grid-cols-1 sm:grid-cols-5 gap-2">
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" placeholder="Player name" value={name} onChange={e=>setName(e.target.value)} />
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={rp} onChange={e=>setRp(parseInt(e.target.value||'0'))} />
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="password" placeholder="Password" value={pw} onChange={e=>setPw(e.target.value)} />
<button onClick={()=>{ onAdd(name, rp, pw); setName(''); setRp(10); setPw('') }} className="rounded-xl px-3 py-2 bg-amber-600 hover:bg-amber-500">Add/Update</button>
<div className="self-center text-xs opacity-70">Add or overwrite by name</div>
</div>
)
}
function GmSetRP({ name, onSet }) {
const [rp, setRp] = useState('')
return (
<div className="flex gap-2">
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm w-20" type="number" placeholder="RP" value={rp} onChange={e=>setRp(e.target.value)} />
<button onClick={()=>{ onSet(name, parseInt(rp||'0')); setRp('') }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set RP</button>
</div>
)
}
function GmSetRenown({ name, value, onSet }) {
const [renown, setRenown] = useState(value)
return (
<div className="flex gap-2">
<select className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm w-20" value={renown} onChange={e=>setRenown(e.target.value)}>
{RANK_ORDER.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<button onClick={()=>{ onSet(name, renown); setRenown(value) }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set Renown</button>
</div>
)
}
function GmResetPW({ name, onReset }) {
const [pw, setPw] = useState('')
return (
<div className="flex gap-2">
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm" type="password" placeholder="New PW" value={pw} onChange={e=>setPw(e.target.value)} />
<button onClick={()=>{ onReset(name, pw); setPw('') }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Reset PW</button>
</div>
)
}
// GM action handlers
async function gmAddOrUpdatePlayer(name, rp, pw) {
try {
logApiCall('PlayerTab', 'POST', '/api/players')
await axios.post('/api/players', { name, rp, pw }, { headers: { 'x-session-id': sessionId || '' } });
const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } });
setPlayers(res.data);
flash('Player added/updated');
} catch (e) {
logApiError('PlayerTab', 'POST', '/api/players', e);
flash('Failed to add player');
}
}
async function gmSetRP(name, rp) {
try {
logApiCall('PlayerTab', 'PUT', `/api/players/${name}/rp`)
await axios.put(`/api/players/${name}/rp`, { rp }, { headers: { 'x-session-id': sessionId || '' } });
const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } });
setPlayers(res.data);
flash('RP updated');
} catch (e) {
logApiError('PlayerTab', 'PUT', `/api/players/${name}/rp`, e);
flash('Failed to set RP');
}
}
async function gmSetRenown(name, renown) {
try {
logApiCall('PlayerTab', 'PUT', `/api/players/${name}/renown`)
await axios.put(`/api/players/${name}/renown`, { renown }, { headers: { 'x-session-id': sessionId || '' } });
const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } });
setPlayers(res.data);
flash('Renown updated');
} catch (e) {
logApiError('PlayerTab', 'PUT', `/api/players/${name}/renown`, e);
flash('Failed to set renown');
}
}
async function gmResetPlayerPw(name, pw) {
try {
logApiCall('PlayerTab', 'PUT', `/api/players/${name}/pw`)
await axios.put(`/api/players/${name}/pw`, { pw }, { headers: { 'x-session-id': sessionId || '' } });
const res = await axios.get('/api/players', { headers: { 'x-session-id': sessionId || '' } });
setPlayers(res.data);
flash('Password reset');
} catch (e) {
logApiError('PlayerTab', 'PUT', `/api/players/${name}/pw`, e);
flash('Failed to reset password');
}
}
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero']
function normalizeRank(r) { const s = String(r||'').trim(); const found = RANK_ORDER.find(x=>x.toLowerCase()===s.toLowerCase()); return found || (s? s : 'None') }
function renownClass(r) { const rr = normalizeRank(r); if (rr==='Respected') return 'bg-emerald-700'; if (rr==='Distinguished') return 'bg-indigo-700'; if (rr==='Famed') return 'bg-purple-700'; if (rr==='Hero') return 'bg-rose-700'; return 'bg-slate-700' }
// No per-tab login/logout; handled globally
// Early return if not authenticated
@@ -341,6 +496,23 @@ function PlayerTab({ authedPlayer, sessionId }) {
<h1 className="text-2xl font-bold mb-4">Player Tab</h1>
<p className="text-slate-400">Please log in to access player data</p>
</div>
{/* Small GM login panel under global login */}
<div className="mx-auto max-w-md bg-white/5 rounded-xl p-4 border border-white/10">
<div className="text-sm font-medium mb-2">GM Login</div>
<div className="flex gap-2">
<input
type="password"
className="flex-1 rounded border border-white/10 bg-white/10 px-2 py-1"
placeholder="GM Password"
value={gmPassInput}
onChange={e => setGmPassInput(e.target.value)}
/>
<button onClick={handleGmUnlock} className="px-3 py-1.5 rounded bg-amber-600 hover:bg-amber-500">Unlock</button>
</div>
<div className="text-xs opacity-70 mt-2">GM can create/delete users, set RP and reset passwords.</div>
</div>
</div>
</section>
);
@@ -350,6 +522,24 @@ function PlayerTab({ authedPlayer, sessionId }) {
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-6">
{/* Small GM login panel (visible under global header) */}
{!gmOpen && (
<div className="mx-auto max-w-md bg-white/5 rounded-xl p-4 border border-white/10">
<div className="text-sm font-medium mb-2">GM Login</div>
<div className="flex gap-2">
<input
type="password"
className="flex-1 rounded border border-white/10 bg-white/10 px-2 py-1"
placeholder="GM Password"
value={gmPassInput}
onChange={e => setGmPassInput(e.target.value)}
/>
<button onClick={handleGmUnlock} className="px-3 py-1.5 rounded bg-amber-600 hover:bg-amber-500">Unlock</button>
</div>
<div className="text-xs opacity-70 mt-2">GM can create/delete users, set RP and reset passwords.</div>
</div>
)}
{/* Character Name & Player Name */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-white/5 rounded-xl p-3 border border-white/10">
<div>
@@ -653,6 +843,64 @@ function PlayerTab({ authedPlayer, sessionId }) {
<textarea className="w-full h-24 rounded border border-white/10 bg-white/10 px-2 py-1" value={notes} onChange={e=>setNotes(e.target.value)} />
</div>
{/* GM Panel - Player Management */}
{gmOpen && (
<div className="bg-white/5 rounded-xl p-4 border border-white/10 mt-4">
<div className="font-semibold text-lg mb-4">Player Management</div>
{/* Add/Update Player */}
<div className="mb-4">
<div className="text-sm font-medium mb-2">Add or Update Player</div>
<GmAddPlayer onAdd={gmAddOrUpdatePlayer} />
</div>
{/* Set RP */}
<div className="mb-4">
<div className="text-sm font-medium mb-2">Set Requisition Points (RP)</div>
{players.map(player => (
<div key={player.name} className="flex items-center gap-2 mb-2">
<span className="text-sm">{player.name}</span>
<GmSetRP name={player.name} onSet={gmSetRP} />
</div>
))}
</div>
{/* Set Renown */}
<div className="mb-4">
<div className="text-sm font-medium mb-2">Set Renown</div>
{players.map(player => (
<div key={player.name} className="flex items-center gap-2 mb-2">
<span className="text-sm">{player.name}</span>
<GmSetRenown name={player.name} value={player.renown} onSet={gmSetRenown} />
</div>
))}
</div>
{/* Reset Password */}
<div className="mb-4">
<div className="text-sm font-medium mb-2">Reset Password</div>
{players.map(player => (
<div key={player.name} className="flex items-center gap-2 mb-2">
<span className="text-sm">{player.name}</span>
<GmResetPW name={player.name} onReset={gmResetPlayerPw} />
</div>
))}
</div>
{/* Delete Player */}
<div className="mb-4">
<div className="text-sm font-medium mb-2">Delete Player</div>
{players.map(player => (
<div key={player.name} className="flex items-center gap-2 mb-2">
<span className="text-sm">{player.name}</span>
<button onClick={() => gmDeletePlayer(player.name)} className="text-xs px-2 py-1 rounded bg-red-600 hover:bg-red-500">
Delete
</button>
</div>
))}
</div>
</div>
)}
</div>
</section>
);

View File

@@ -323,73 +323,16 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
))}
</div>
{/* GM Panel */}
{/* GM Panel moved to PlayerTab - GM controls (add/set RP/renown/reset PW) available in PlayerTab when GM is logged in */}
<div className="border-t border-white/10 pt-4">
<button
onClick={() => setGmOpen(!gmOpen)}
className="px-3 py-2 rounded bg-amber-600 hover:bg-amber-500 text-sm font-medium"
>
{gmOpen ? 'Hide' : 'Show'} GM Panel
</button>
{gmOpen && !gmUnlocked && (
<div className="mt-3 p-3 rounded bg-amber-900/20 border border-amber-500/30">
<div className="flex gap-2">
<input
className="rounded border border-white/10 bg-white/10 px-2 py-1"
type="password"
placeholder="GM Password"
value={gmPassInput}
onChange={e => setGmPassInput(e.target.value)}
/>
<button
onClick={handleGmUnlock}
className="px-3 py-1 rounded bg-amber-600 hover:bg-amber-500"
>
Unlock
</button>
</div>
<div className="p-3 rounded bg-amber-900/20 border border-amber-500/30">
<div className="text-amber-300 font-semibold mb-2">
GM Panel
</div>
)}
{gmOpen && gmUnlocked && (
<div className="mt-3 space-y-4">
<div className="p-3 rounded bg-amber-900/20 border border-amber-500/30">
<div className="text-amber-300 font-semibold mb-2">Add/Update Player</div>
<GmAddPlayer onAdd={addPlayer} />
</div>
<div className="p-3 rounded bg-red-900/20 border border-red-500/30">
<div className="text-red-300 font-semibold mb-2">Manage Players</div>
<div className="space-y-2">
{players.map(player => (
<div key={player.name} className="flex items-center gap-3 p-2 rounded bg-black/20">
<div className="flex-1">
<div className="font-medium">{player.name}</div>
<div className="text-xs text-slate-400">
RP: {player.rp || 0} | Renown: {player.renown || 'None'}
</div>
</div>
<GmSetRP name={player.name} onSet={setPlayerRP} />
<GmSetRenown name={player.name} value={player.renown || 'None'} onSet={setPlayerRenown} />
<GmResetPW name={player.name} onReset={resetPlayerPw} />
<button
onClick={() => deletePlayer(player.name)}
className="px-2 py-1 rounded bg-red-600 hover:bg-red-500 text-xs"
>
Delete
</button>
</div>
))}
</div>
</div>
<div className="p-3 rounded bg-purple-900/20 border border-purple-500/30">
<div className="text-purple-300 font-semibold mb-2">GM Settings</div>
<GmChangePassword onSet={newPw => setGmPassword(newPw)} />
</div>
<div className="text-sm text-amber-200">
GM controls have been moved to the PlayerTab. When logged in as GM, you can add players, set RP and renown, reset passwords, and manage items.
</div>
)}
</div>
</div>
</>
)}
@@ -397,49 +340,8 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
);
}
function GmAddPlayer({ onAdd }) {
const [name, setName] = useState('')
const [rp, setRp] = useState(10)
const [pw, setPw] = useState('')
return (
<div className="grid grid-cols-1 sm:grid-cols-5 gap-2">
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" placeholder="Player name" value={name} onChange={e=>setName(e.target.value)} />
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={rp} onChange={e=>setRp(parseInt(e.target.value||'0'))} />
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="password" placeholder="Password" value={pw} onChange={e=>setPw(e.target.value)} />
<button onClick={()=>{ onAdd(name, rp, pw); setName(''); setRp(10); setPw('') }} className="rounded-xl px-3 py-2 bg-amber-600 hover:bg-amber-500">Add/Update</button>
<div className="self-center text-xs opacity-70">Add or overwrite by name</div>
</div>
)
}
function GmSetRP({ name, onSet }) {
const [rp, setRp] = useState('')
return (
<div className="flex gap-2">
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm w-20" type="number" placeholder="RP" value={rp} onChange={e=>setRp(e.target.value)} />
<button onClick={()=>{ onSet(name, parseInt(rp||'0')); setRp('') }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set RP</button>
</div>
)
}
function GmSetRenown({ name, value, onSet }) {
const [renown, setRenown] = useState(value)
return (
<div className="flex gap-2">
<select className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm w-20" value={renown} onChange={e=>setRenown(e.target.value)}>
{RANK_ORDER.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<button onClick={()=>{ onSet(name, renown); setRenown(value) }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set Renown</button>
</div>
)
}
function GmResetPW({ name, onReset }) {
const [pw, setPw] = useState('')
return (
<div className="flex gap-2">
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm" type="password" placeholder="New PW" value={pw} onChange={e=>setPw(e.target.value)} />
<button onClick={()=>{ onReset(name, pw); setPw('') }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Reset PW</button>
</div>
)
}
// GM subcomponents (GmAddPlayer, GmSetRP, GmSetRenown, GmResetPW, GmEditItemMeta, GmChangePassword)
// were moved to PlayerTab.jsx to centralize player management.
function GmEditItemMeta({ item, onSet }) {
const [req, setReq] = useState(item.req ?? item.cost ?? 0)
@@ -448,19 +350,9 @@ function GmEditItemMeta({ item, onSet }) {
<div className="flex items-center gap-1">
<input className="w-16 rounded border border-white/10 bg-white/10 px-2 py-1 text-xs" type="number" min={0} value={req} onChange={e=>setReq(e.target.value)} />
<select className="rounded border border-white/10 bg-white/10 px-2 py-1 text-xs" value={renown} onChange={e=>setRenown(e.target.value)}>
{RANK_ORDER.map(r => <option key={r} value={r}>{r}</option>)}
{['None','Respected','Distinguished','Famed','Hero'].map(r => <option key={r} value={r}>{r}</option>)}
</select>
<button onClick={()=>onSet(item.id, parseInt(req||'0'), renown)} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set</button>
</div>
)
}
function GmChangePassword({ onSet }){
const [pw, setPw] = useState('')
return (
<div className="flex items-center gap-2 text-xs">
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1" type="password" placeholder="New GM password (not persisted)" value={pw} onChange={e=>setPw(e.target.value)} />
<button onClick={()=>{ onSet(pw); setPw('') }} className="px-2 py-1 rounded bg-amber-600 hover:bg-amber-500">Set Password</button>
</div>
)
}

View File

@@ -30,6 +30,7 @@ module.exports = {
500: '#e11d48',
},
amber: {
600: '#b45309',
500: '#d97706',
},
},

11
tests/healthcheck.test.js Normal file
View File

@@ -0,0 +1,11 @@
const { spawnSync } = require('child_process');
const path = require('path');
describe('database healthcheck', () => {
it('should run healthcheck script and return players', () => {
const script = path.resolve(__dirname, '../database/healthcheck.js');
const res = spawnSync('node', [script], { encoding: 'utf8', cwd: path.resolve(__dirname, '..') });
console.log(res.stdout);
expect(res.status).toBe(0);
}, 20000);
});