From b47065edbf3b2491d55dd0b81084575a3358a742 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 15 Aug 2025 11:12:49 +0200 Subject: [PATCH] Moves GM player management to PlayerTab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/server-healthcheck.yml | 24 ++ database/backend.log | 19 ++ database/deploy/deathwatch.service.template | 18 ++ database/deploy/install-systemd.sh | 50 ++++ database/healthcheck.js | 21 ++ database/pm2.config.js | 14 ++ database/server.js | 17 ++ database/sqlite/deathwatch.db-shm | Bin 0 -> 32768 bytes database/sqlite/deathwatch.db-wal | Bin 0 -> 94792 bytes src/App.js | 73 ++++-- src/components/PlayerTab.jsx | 256 +++++++++++++++++++- src/components/RequisitionShop.jsx | 128 +--------- tailwind.config.js | 1 + tests/healthcheck.test.js | 11 + 14 files changed, 494 insertions(+), 138 deletions(-) create mode 100644 .github/workflows/server-healthcheck.yml create mode 100644 database/deploy/deathwatch.service.template create mode 100644 database/deploy/install-systemd.sh create mode 100644 database/healthcheck.js create mode 100644 database/pm2.config.js create mode 100644 database/sqlite/deathwatch.db-shm create mode 100644 database/sqlite/deathwatch.db-wal create mode 100644 tests/healthcheck.test.js diff --git a/.github/workflows/server-healthcheck.yml b/.github/workflows/server-healthcheck.yml new file mode 100644 index 0000000..0f3d4a4 --- /dev/null +++ b/.github/workflows/server-healthcheck.yml @@ -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 diff --git a/database/backend.log b/database/backend.log index 5d762ff..2cbb59e 100644 --- a/database/backend.log +++ b/database/backend.log @@ -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 diff --git a/database/deploy/deathwatch.service.template b/database/deploy/deathwatch.service.template new file mode 100644 index 0000000..9e96b5a --- /dev/null +++ b/database/deploy/deathwatch.service.template @@ -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 diff --git a/database/deploy/install-systemd.sh b/database/deploy/install-systemd.sh new file mode 100644 index 0000000..3eb26be --- /dev/null +++ b/database/deploy/install-systemd.sh @@ -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" < 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(); diff --git a/database/pm2.config.js b/database/pm2.config.js new file mode 100644 index 0000000..3208e20 --- /dev/null +++ b/database/pm2.config.js @@ -0,0 +1,14 @@ +module.exports = { + apps: [ + { + name: 'deathwatch-server', + script: './server.js', + cwd: __dirname, + watch: false, + env: { + NODE_ENV: 'production', + PORT: 5000 + } + } + ] +}; diff --git a/database/server.js b/database/server.js index c6202de..0aecb7a 100755 --- a/database/server.js +++ b/database/server.js @@ -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; diff --git a/database/sqlite/deathwatch.db-shm b/database/sqlite/deathwatch.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..5a9538a3d4da923ab72c9b073902521a73235e75 GIT binary patch literal 32768 zcmeI)Jx)SF5CGsoK*c{mL0)M`W$6(-fW5spfXbSlgbokjA-o0$;37C{j4^gvurc{2 zne4uo&CKLGz%<+2DACPqMGPi+_A(z{`!~3Z)JwV%;U^5!_V)(zR6O8009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfIwA&R-D9D)d@5wP)pz-_spF}KSs3%(3e0pfp#3lSqx%aZ32x65FkKc-vXW7u6rKC zxZZbT{RmVS=;mJOi@1!kjam>OK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk N1PBlyK;VB1yaRL_BkTYG literal 0 HcmV?d00001 diff --git a/database/sqlite/deathwatch.db-wal b/database/sqlite/deathwatch.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..67d623d9ba89e04f2a11a57d86890e64ce434e19 GIT binary patch literal 94792 zcmeI*UyNK;9S89Fv)gvNe=furiQ7UM1x##*^Y@-}M%VaqfzwbX2lhQ!Irj1umC9w6@^SaKZ@=Qj>puV0KYi@cuN{5l z(eX4iwsHLV^Dq7KTa6q4Ih_V;+1W~VK7GLh0uX=z1Rwwb2tWV=5P$##AOL}#D=<~7 zAFj4TyS=z{XM19@TA!^hpSJgem3BQHRM{I??-!`s9{JF}m#_SD*%#P1b+nS*HuLn% zyQaT5^}^KA$*-5+i3bEA009U<00Izz00bcLKNRRR_8+KprjO5YE;V(+QxUZ$(Q5BR z^o3LDFO22fG9n9G7)$aXw2%d}f^)7VrOsLFBfYP&`tSZ*>>Z)eYWPzti|xCXVhk&d z`x#;K7}3XQev;@Gr7g~yisbX_RhyRHJNiB?b!Yj`tVX9qKg3dqv8e7TieYj4BAx zR7b_7ql82rWnQRMxl&FS-l;-S=5^%C5NW98!fP%sv`E!TKbi5QDEo?B8{b&u+EC}p zv9idyG}S@cG=Vft3X_~CRpJ#@o(K|Tw3bJgTW4bK4I|tY1*e26$w?$6;VL^&emRMBFpnNF<10?YA%0?VP!p?X=MNd-nFd@vypn+skLBb{Wd5J}ZhBZ!rO zFd>+wJ{H;(G6>0|b;K9ZdG9wVp0=UAsXW$Vv?;V1EUz3ND6bsqG-|y`PV>cM^dfj} zd=N@mN~{pVXy=s@LffFCCFvB@!i1NWqioS_4OZPLpH9jzNin~xVGw{Hd~*Y*jTM~E_7J%^PJ=OZHO*a6cJ3eFx`2xrn zK)%41^9A1cm;B4a4}SF7^8EtiGmn+|0_*7l$QRfizbH@z1Rwwb2tWV=5P$##AOL~4 zK>+IrhK4-u{(OOF-|^YQ>ZzsI&=+_ct}^@x2tWV=5P$##AOHafKmY>UR{(v1z0()y z%NKa^#8Z!d{PkNe@Ad_z{}fB*y_009U<00Izz!2eBPa5?|R&->hQ z`2u4lV=s5`r{Dd?O*e{PohkDLYT4OJc0PT<0|F3$00bZa0SG_<0uX=z1Rwx`ohvX^ zs~@hmL%Y4WbZ7hEWVJq9T|RB^2`lY-I;g(f!H*pJZR^YfuRUM(1@=uHMeg9vopn?W z0SG_<0uX=z1R$_81$IF0;K2ix&h+s)&Xu>)NNsW-MWEKH7_A{8FwR9#G<`I?wN?|Z ziDN8KPbkqv&b1ayL~D!t8ml)Zx7a&Eqt)=IRurBkOVS6WE;6QnP^wtqvd++;; z*U!zi&==UkRfDHM00Izz00bZa0SG_<0ub0e0_Y3uoxVU{?%*eXKX>-&#lQZ#+ZV`A zAa`*0T+=ub2tWV=5P$##AOHafKmY;|xMTtsn>)C^7UtmKGgV9I7UNAtwZq*%wwzh0&D34$QQVv*KvFk1Rwwb2tWV=5P$##AOL~gAb@oQ zduJU%U%tS?-yi+bxvA^EhrYmWxQuZO5P$##AOHafKmY;|fB*!xEP%ei-subULwe_}sa#Ty^4!GGCyUovmc&(-%A-009U<00Izz00bZa0SG_< z0ub1_0#mj6;c7dy+lxzgwkId6_1Wt3X?sstY1h+1_2mx!j!eDiAHVhkWnW<5)KTOP z?%Y{NRF_A{+W3&6 zPGh`Rq#W@x?=Q5-jhXTrD_R>LGSs`Z3eT=3_PJU(L z{(xUO)M?aulT4)HG5T}@a+A(Mr7R`L9o%y6;AKBqzNvDvf4KWOg)+wJ{H;(G6>0|b;K9ZdGBStyO&WL8nqk7I@1%u8Rl5- zEO$CpKz+_FwNZ-HqSJ+8Dg>demn`S0dB>>m%nB)U$qbiL#B^r# zqAzgK*9@Kj0SG_<0uX=z1Rwwb2tZ&r39R0WYwCojB5F;d)!vEd3#ZawSh~%$jL52P-(|T2ktqwLa4Q-MyeMu+94deYt~2vL`+zU%u}CZeJiff!x8}bXDV6 zAOHafKmY;|fB*y_009U<;1UUJju!kcxq}W5(a(-;c#&StT lQ)n|^AG(q9rjlm9Hd~*YSYM2hpG@mBc;2F<$ { + 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 (
{/* Persistent Header */} @@ -180,6 +197,18 @@ function App() { {loginMsg && {loginMsg}}
)} + {/* GM quick unlock in header */} +
+ setGmPassInput(e.target.value)} + onKeyPress={(e)=>{ if (e.key==='Enter') handleGmUnlock() }} + /> + +
@@ -212,9 +241,19 @@ function App() { {/* User Info Card for easy access */} {!authedPlayer && (
-

Available Users

+
+

Available Users

+
+ +
+
+ {playersError ? ( +
+ {playersError} +
+ ) : null}
- {players.map(player => ( + {players && players.length > 0 ? players.map(player => ( - ))} + )) : ( +
No users found. Click Refresh or check backend.
+ )}

Password for all users: 1234

)} - {tab==='roller' ? : tab==='shop' ? : } + {tab==='roller' ? : tab==='shop' ? : } ); diff --git a/src/components/PlayerTab.jsx b/src/components/PlayerTab.jsx index 96bb3ed..f8413ee 100755 --- a/src/components/PlayerTab.jsx +++ b/src/components/PlayerTab.jsx @@ -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,7 +313,8 @@ 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 ( +
+
+
+

Player Tab

+

Please log in to access player data

+
+ + {/* Small GM login panel under global login */} +
+
GM Login
+
+ setGmPassInput(e.target.value)} + /> + +
+
GM can create/delete users, set RP and reset passwords.
+
+ +
+
+ ); + } + + // GM management components + function GmAddPlayer({ onAdd }) { + const [name, setName] = useState('') + const [rp, setRp] = useState(10) + const [pw, setPw] = useState('') + return ( +
+ setName(e.target.value)} /> + setRp(parseInt(e.target.value||'0'))} /> + setPw(e.target.value)} /> + +
Add or overwrite by name
+
+ ) + } + function GmSetRP({ name, onSet }) { + const [rp, setRp] = useState('') + return ( +
+ setRp(e.target.value)} /> + +
+ ) + } + function GmSetRenown({ name, value, onSet }) { + const [renown, setRenown] = useState(value) + return ( +
+ + +
+ ) + } + function GmResetPW({ name, onReset }) { + const [pw, setPw] = useState('') + return ( +
+ setPw(e.target.value)} /> + +
+ ) + } + + // 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 }) {

Player Tab

Please log in to access player data

+ + {/* Small GM login panel under global login */} +
+
GM Login
+
+ setGmPassInput(e.target.value)} + /> + +
+
GM can create/delete users, set RP and reset passwords.
+
+ ); @@ -349,7 +521,25 @@ function PlayerTab({ authedPlayer, sessionId }) { return (
- + + {/* Small GM login panel (visible under global header) */} + {!gmOpen && ( +
+
GM Login
+
+ setGmPassInput(e.target.value)} + /> + +
+
GM can create/delete users, set RP and reset passwords.
+
+ )} + {/* Character Name & Player Name */}
@@ -653,6 +843,64 @@ function PlayerTab({ authedPlayer, sessionId }) {