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:
24
.github/workflows/server-healthcheck.yml
vendored
Normal file
24
.github/workflows/server-healthcheck.yml
vendored
Normal 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
|
||||||
@@ -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: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.062Z] SESSION: Logout success dc5481ec7357cc3b25cfaa2d10bbbbe65496c6645eb0368bb5369d7f13d732a7
|
||||||
[2025-08-15T08:12:35.100Z] API: Fetch all players (public) Found 3 players
|
[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
|
||||||
|
|||||||
18
database/deploy/deathwatch.service.template
Normal file
18
database/deploy/deathwatch.service.template
Normal 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
|
||||||
50
database/deploy/install-systemd.sh
Normal file
50
database/deploy/install-systemd.sh
Normal 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
21
database/healthcheck.js
Normal 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
14
database/pm2.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'deathwatch-server',
|
||||||
|
script: './server.js',
|
||||||
|
cwd: __dirname,
|
||||||
|
watch: false,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -32,6 +32,18 @@ app.get('/', (req, res) => {
|
|||||||
res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.');
|
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
|
// Use routes
|
||||||
app.use('/api/players', playerRoutes);
|
app.use('/api/players', playerRoutes);
|
||||||
app.use('/api/sessions', sessionRoutes);
|
app.use('/api/sessions', sessionRoutes);
|
||||||
@@ -42,4 +54,9 @@ app.listen(PORT, '0.0.0.0', () => {
|
|||||||
console.log('Using SQLite database');
|
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;
|
module.exports = app;
|
||||||
|
|||||||
BIN
database/sqlite/deathwatch.db-shm
Normal file
BIN
database/sqlite/deathwatch.db-shm
Normal file
Binary file not shown.
BIN
database/sqlite/deathwatch.db-wal
Normal file
BIN
database/sqlite/deathwatch.db-wal
Normal file
Binary file not shown.
63
src/App.js
63
src/App.js
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import DeathwatchRoller from './components/DeathwatchRoller';
|
import DeathwatchRoller from './components/DeathwatchRoller';
|
||||||
import RequisitionShop from './components/RequisitionShop';
|
import RequisitionShop from './components/RequisitionShop';
|
||||||
@@ -16,19 +15,26 @@ function App() {
|
|||||||
const [loginPw, setLoginPw] = useState('');
|
const [loginPw, setLoginPw] = useState('');
|
||||||
const [loginMsg, setLoginMsg] = useState('');
|
const [loginMsg, setLoginMsg] = useState('');
|
||||||
const [players, setPlayers] = 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
|
// Fetch player list for login (exposed so UI can refresh)
|
||||||
useEffect(() => {
|
|
||||||
async function fetchPlayers() {
|
async function fetchPlayers() {
|
||||||
try {
|
try {
|
||||||
|
setPlayersError('')
|
||||||
const res = await axios.get('/api/players');
|
const res = await axios.get('/api/players');
|
||||||
setPlayers(res.data);
|
setPlayers(res.data || []);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('fetchPlayers failed', err);
|
||||||
setPlayers([]);
|
setPlayers([]);
|
||||||
|
setPlayersError('Failed to load users — is the backend running? Check console/network.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchPlayers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Validate session on mount/refresh
|
// Validate session on mount/refresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,6 +140,17 @@ function App() {
|
|||||||
setTimeout(() => setLoginMsg(''), 3000);
|
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 (
|
return (
|
||||||
<div className="App min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
<div className="App min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||||
{/* Persistent Header */}
|
{/* 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>}
|
{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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,9 +241,19 @@ function App() {
|
|||||||
{/* User Info Card for easy access */}
|
{/* User Info Card for easy access */}
|
||||||
{!authedPlayer && (
|
{!authedPlayer && (
|
||||||
<div className="mb-6 p-4 rounded-lg bg-blue-900/20 border border-blue-500/30">
|
<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">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{players.map(player => (
|
{players && players.length > 0 ? players.map(player => (
|
||||||
<button
|
<button
|
||||||
key={player.name}
|
key={player.name}
|
||||||
onClick={() => {setLoginName(player.name); setLoginPw('1234');}}
|
onClick={() => {setLoginName(player.name); setLoginPw('1234');}}
|
||||||
@@ -222,13 +261,15 @@ function App() {
|
|||||||
>
|
>
|
||||||
{player.name}
|
{player.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)) : (
|
||||||
|
<div className="text-xs text-blue-200/60">No users found. Click Refresh or check backend.</div>
|
||||||
|
)}
|
||||||
</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>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { debug, info, warn, logApiCall, logApiError, logUserAction } from '../ut
|
|||||||
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer';
|
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer';
|
||||||
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1';
|
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1';
|
||||||
|
|
||||||
|
const GM_PASSWORD = 'bongo';
|
||||||
|
|
||||||
|
|
||||||
function safeGet(key) {
|
function safeGet(key) {
|
||||||
try {
|
try {
|
||||||
@@ -314,6 +313,7 @@ function PlayerTab({ authedPlayer, sessionId }) {
|
|||||||
|
|
||||||
// Added GM Panel button and Requisition Points display
|
// Added GM Panel button and Requisition Points display
|
||||||
const [gmOpen, setGmOpen] = useState(false);
|
const [gmOpen, setGmOpen] = useState(false);
|
||||||
|
const [gmPassInput, setGmPassInput] = useState('');
|
||||||
|
|
||||||
// Define shopAuthed for use in the component
|
// Define shopAuthed for use in the component
|
||||||
const shopAuthed = getShopAuthed();
|
const shopAuthed = getShopAuthed();
|
||||||
@@ -330,6 +330,161 @@ function PlayerTab({ authedPlayer, sessionId }) {
|
|||||||
safeSet('dw:gm:session', gmOpen);
|
safeSet('dw:gm:session', gmOpen);
|
||||||
}, [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
|
// No per-tab login/logout; handled globally
|
||||||
|
|
||||||
// Early return if not authenticated
|
// Early return if not authenticated
|
||||||
@@ -341,6 +496,23 @@ function PlayerTab({ authedPlayer, sessionId }) {
|
|||||||
<h1 className="text-2xl font-bold mb-4">Player Tab</h1>
|
<h1 className="text-2xl font-bold mb-4">Player Tab</h1>
|
||||||
<p className="text-slate-400">Please log in to access player data</p>
|
<p className="text-slate-400">Please log in to access player data</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</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">
|
<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="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 */}
|
{/* 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 className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-white/5 rounded-xl p-3 border border-white/10">
|
||||||
<div>
|
<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)} />
|
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -323,73 +323,16 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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="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>
|
<div className="text-amber-300 font-semibold mb-2">
|
||||||
<GmAddPlayer onAdd={addPlayer} />
|
GM Panel
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-amber-200">
|
||||||
<div className="p-3 rounded bg-red-900/20 border border-red-500/30">
|
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 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>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -397,49 +340,8 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GmAddPlayer({ onAdd }) {
|
// GM subcomponents (GmAddPlayer, GmSetRP, GmSetRenown, GmResetPW, GmEditItemMeta, GmChangePassword)
|
||||||
const [name, setName] = useState('')
|
// were moved to PlayerTab.jsx to centralize player management.
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GmEditItemMeta({ item, onSet }) {
|
function GmEditItemMeta({ item, onSet }) {
|
||||||
const [req, setReq] = useState(item.req ?? item.cost ?? 0)
|
const [req, setReq] = useState(item.req ?? item.cost ?? 0)
|
||||||
@@ -448,19 +350,9 @@ function GmEditItemMeta({ item, onSet }) {
|
|||||||
<div className="flex items-center gap-1">
|
<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)} />
|
<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)}>
|
<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>
|
</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>
|
<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>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ module.exports = {
|
|||||||
500: '#e11d48',
|
500: '#e11d48',
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
|
600: '#b45309',
|
||||||
500: '#d97706',
|
500: '#d97706',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
11
tests/healthcheck.test.js
Normal file
11
tests/healthcheck.test.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user