feat: Add PlayerManagement GM interface and clean repository
- Add comprehensive PlayerManagement component for GMs - Add all GM API endpoints for player CRUD operations - Add comprehensive test suite with 14 tests - Clean up .gitignore to exclude logs, backups, core dumps - Update README with setup instructions and documentation - Add setup.sh script for easy project initialization This provides a complete GM interface for managing players without the large files that were blocking the previous push.
This commit is contained in:
+8
-1
@@ -5,6 +5,7 @@ import PlayerTab from './components/PlayerTab';
|
||||
import RulesTab from './components/RulesTab';
|
||||
import BestiaryTab from './components/BestiaryTab';
|
||||
import GMKit from './components/GMKit';
|
||||
import PlayerManagement from './components/PlayerManagement';
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
|
||||
@@ -266,6 +267,12 @@ function App() {
|
||||
>
|
||||
Bestiary
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='players' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
|
||||
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'players' }); setTab('players')}}
|
||||
>
|
||||
Player Management
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='gmkit' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
|
||||
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'gmkit' }); setTab('gmkit')}}
|
||||
@@ -311,7 +318,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
|
||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
|
||||
authedPlayer={authedPlayer}
|
||||
sessionId={sessionId}
|
||||
/>}
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero'];
|
||||
|
||||
export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
const [players, setPlayers] = useState([]);
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
|
||||
// Helper to build request headers
|
||||
function buildHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) headers['x-session-id'] = sessionId;
|
||||
if (authedPlayer === 'gm') headers['x-gm-secret'] = 'bongo';
|
||||
return { ...headers, ...extra };
|
||||
}
|
||||
|
||||
function flash(msg) {
|
||||
setSaveMsg(msg);
|
||||
setTimeout(() => setSaveMsg(''), 3000);
|
||||
}
|
||||
|
||||
// Fetch players from the database
|
||||
const fetchPlayers = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/players', { headers: buildHeaders() });
|
||||
setPlayers(res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch players:', err);
|
||||
flash('Failed to fetch players');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, authedPlayer]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlayers();
|
||||
}, [fetchPlayers]);
|
||||
|
||||
if (authedPlayer !== 'gm') return (
|
||||
<div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30">
|
||||
<h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2>
|
||||
<p className="text-red-200">Player Management is only accessible to Game Masters. Please log in as a GM.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// GM action handlers
|
||||
async function gmSetXP(name, xp) {
|
||||
console.log('PlayerManagement: gmSetXP', { name, xp });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/set-xp',
|
||||
{ playerName: name, xp: parseInt(xp) },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmSetXP response', res.status, res.data);
|
||||
flash(`Set XP for ${name} to ${xp}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetXP failed', e.response?.data || e.message);
|
||||
flash(`Failed to set XP for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmSetXPSpent(name, xpSpent) {
|
||||
console.log('PlayerManagement: gmSetXPSpent', { name, xpSpent });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/set-xp-spent',
|
||||
{ playerName: name, xpSpent: parseInt(xpSpent) },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmSetXPSpent response', res.status, res.data);
|
||||
flash(`Set XP Spent for ${name} to ${xpSpent}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetXPSpent failed', e.response?.data || e.message);
|
||||
flash(`Failed to set XP Spent for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmAddOrUpdatePlayer(name, rp, pw) {
|
||||
console.log('PlayerManagement: gmAddOrUpdatePlayer', { name, rp, pwProvided: !!pw });
|
||||
try {
|
||||
const resPost = await axios.post('/api/players/gm/add-or-update',
|
||||
{ name, requisitionPoints: parseInt(rp), password: pw || '1234' },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmAddOrUpdatePlayer POST response', resPost.status, resPost.data);
|
||||
flash(`Added/Updated player ${name} with ${rp} RP`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmAddOrUpdatePlayer failed', e.response?.data || e.message);
|
||||
flash(`Failed to add/update player ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmSetRP(name, rp) {
|
||||
console.log('PlayerManagement: gmSetRP', { name, rp });
|
||||
try {
|
||||
const url = `/api/players/gm/set-rp`;
|
||||
const payload = { playerName: name, requisitionPoints: parseInt(rp) };
|
||||
const config = { headers: buildHeaders() };
|
||||
|
||||
console.log('PlayerManagement: Making request to', url, 'with payload', payload, 'and config', config);
|
||||
|
||||
const res = await axios.post(url, payload, config);
|
||||
console.log('PlayerManagement: gmSetRP response', res.status, res.data);
|
||||
|
||||
flash(`Set RP for ${name} to ${rp}`);
|
||||
|
||||
// Refresh players list to show updated RP
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetRP failed', e.response?.data || e.message);
|
||||
flash(`Failed to set RP for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmSetRenown(name, renown) {
|
||||
console.log('PlayerManagement: gmSetRenown', { name, renown });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/set-renown',
|
||||
{ playerName: name, renown },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmSetRenown response', res.status, res.data);
|
||||
flash(`Set Renown for ${name} to ${renown}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetRenown failed', e.response?.data || e.message);
|
||||
flash(`Failed to set Renown for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmResetPlayerPw(name, pw) {
|
||||
console.log('PlayerManagement: gmResetPlayerPw', { name, pwProvided: !!pw });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/reset-password',
|
||||
{ playerName: name, newPassword: pw || '1234' },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmResetPlayerPw response', res.status, res.data);
|
||||
flash(`Reset password for ${name}`);
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmResetPlayerPw failed', e.response?.data || e.message);
|
||||
flash(`Failed to reset password for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmDeletePlayer(name) {
|
||||
if (!window.confirm(`Are you sure you want to delete player ${name}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.delete(`/api/players/gm/delete/${encodeURIComponent(name)}`,
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmDeletePlayer response', res.status, res.data);
|
||||
flash(`Deleted player ${name}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmDeletePlayer failed', e.response?.data || e.message);
|
||||
flash(`Failed to delete player ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Component for adding new players
|
||||
function GmAddPlayer({ onAdd }) {
|
||||
const [name, setName] = useState('');
|
||||
const [rp, setRp] = useState('50');
|
||||
const [pw, setPw] = useState('');
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<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">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
type="text"
|
||||
placeholder="Player Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
type="number"
|
||||
placeholder="Requisition Points"
|
||||
value={rp}
|
||||
onChange={e => setRp(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
type="password"
|
||||
placeholder="Password (default: 1234)"
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-green-600 hover:bg-green-500 px-4 py-2 text-white text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
if (name.trim()) {
|
||||
onAdd(name.trim(), rp, pw || '1234');
|
||||
setName('');
|
||||
setRp('50');
|
||||
setPw('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for setting RP
|
||||
function GmSetRP({ name, currentRP, onSet }) {
|
||||
const [rp, setRp] = useState(currentRP?.toString() || '0');
|
||||
|
||||
// Update state when currentRP prop changes
|
||||
useEffect(() => {
|
||||
setRp(currentRP?.toString() || '0');
|
||||
}, [currentRP]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
type="number"
|
||||
value={rp}
|
||||
onChange={e => setRp(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-blue-600 hover:bg-blue-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, rp)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for setting XP
|
||||
function GmSetXP({ name, currentXP, onSet }) {
|
||||
const [xp, setXp] = useState(currentXP?.toString() || '0');
|
||||
|
||||
// Update state when currentXP prop changes
|
||||
useEffect(() => {
|
||||
setXp(currentXP?.toString() || '0');
|
||||
}, [currentXP]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
type="number"
|
||||
value={xp}
|
||||
onChange={e => setXp(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-purple-600 hover:bg-purple-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, xp)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for setting XP Spent
|
||||
function GmSetXPSpent({ name, currentXPSpent, onSet }) {
|
||||
const [xpSpent, setXpSpent] = useState(currentXPSpent?.toString() || '0');
|
||||
|
||||
// Update state when currentXPSpent prop changes
|
||||
useEffect(() => {
|
||||
setXpSpent(currentXPSpent?.toString() || '0');
|
||||
}, [currentXPSpent]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
type="number"
|
||||
value={xpSpent}
|
||||
onChange={e => setXpSpent(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-indigo-600 hover:bg-indigo-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, xpSpent)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for setting Renown
|
||||
function GmSetRenown({ name, currentRenown, onSet }) {
|
||||
const [renown, setRenown] = useState(currentRenown || 'None');
|
||||
|
||||
// Update state when currentRenown prop changes
|
||||
useEffect(() => {
|
||||
setRenown(currentRenown || 'None');
|
||||
}, [currentRenown]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<select
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
value={renown}
|
||||
onChange={e => setRenown(e.target.value)}
|
||||
>
|
||||
{RANK_ORDER.map(rank => (
|
||||
<option key={rank} value={rank} className="bg-slate-800">{rank}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="rounded bg-yellow-600 hover:bg-yellow-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, renown)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for resetting password
|
||||
function GmResetPW({ name, onReset }) {
|
||||
const [pw, setPw] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white placeholder-white/50 text-sm flex-1 min-w-0"
|
||||
type="password"
|
||||
placeholder="New password (default: 1234)"
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-orange-600 hover:bg-orange-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
onReset(name, pw || '1234');
|
||||
setPw('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk XP giving
|
||||
function BulkXPGiver({ onGive }) {
|
||||
const [amount, setAmount] = useState('100');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-purple-600/80 hover:bg-purple-600 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Give ${amount} XP to all ${players.length} players?`)) {
|
||||
onGive(parseInt(amount));
|
||||
flash(`Gave ${amount} XP to all players`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Give XP to All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk XP setting
|
||||
function BulkXPSetter({ onSet }) {
|
||||
const [amount, setAmount] = useState('1000');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-purple-500/80 hover:bg-purple-500 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Set all ${players.length} players to ${amount} XP?`)) {
|
||||
onSet(parseInt(amount));
|
||||
flash(`Set all players to ${amount} XP`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set All XP
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk RP giving
|
||||
function BulkRPGiver({ onGive }) {
|
||||
const [amount, setAmount] = useState('10');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600/80 hover:bg-blue-600 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Give ${amount} RP to all ${players.length} players?`)) {
|
||||
onGive(parseInt(amount));
|
||||
flash(`Gave ${amount} RP to all players`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Give RP to All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk RP setting
|
||||
function BulkRPSetter({ onSet }) {
|
||||
const [amount, setAmount] = useState('50');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-blue-500/80 hover:bg-blue-500 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Set all ${players.length} players to ${amount} RP?`)) {
|
||||
onSet(parseInt(amount));
|
||||
flash(`Set all players to ${amount} RP`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set All RP
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-xl p-6 border border-white/10">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-white">Player Management</h2>
|
||||
<p className="text-sm text-slate-300 mb-6">Manage player accounts, requisition points, experience, and renown.</p>
|
||||
|
||||
{saveMsg && (
|
||||
<div className="mb-4 p-3 rounded bg-green-500/20 border border-green-500/30 text-green-300 text-sm">
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Player */}
|
||||
<GmAddPlayer onAdd={gmAddOrUpdatePlayer} />
|
||||
|
||||
{/* Players List */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Current Players ({players.length})</h3>
|
||||
|
||||
{players.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">
|
||||
No players found. Add some players above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{players.map(player => (
|
||||
<div key={player.name} className="bg-white/3 rounded-lg p-4 border border-white/5">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Player Info Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-white text-lg">{player.name}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{player.renown || 'None'} • RP: {player.requisitionPoints || 0}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded bg-red-600 hover:bg-red-500 px-3 py-1.5 text-white text-sm font-medium transition-colors"
|
||||
onClick={() => gmDeletePlayer(player.name)}
|
||||
>
|
||||
Delete Player
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Total XP:</span> {player.xp || 0}
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">XP Spent:</span> {player.xpSpent || 0}
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Available XP:</span> {(player.xp || 0) - (player.xpSpent || 0)}
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Character:</span> {player.charName || 'Unnamed'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Controls Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* RP Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Requisition Points</label>
|
||||
<GmSetRP
|
||||
name={player.name}
|
||||
currentRP={player.requisitionPoints}
|
||||
onSet={gmSetRP}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XP Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Experience Points</label>
|
||||
<GmSetXP
|
||||
name={player.name}
|
||||
currentXP={player.xp}
|
||||
onSet={gmSetXP}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XP Spent Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">XP Spent</label>
|
||||
<GmSetXPSpent
|
||||
name={player.name}
|
||||
currentXPSpent={player.xpSpent}
|
||||
onSet={gmSetXPSpent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Renown Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Renown Level</label>
|
||||
<GmSetRenown
|
||||
name={player.name}
|
||||
currentRenown={player.renown}
|
||||
onSet={gmSetRenown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Reset */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Password Reset</label>
|
||||
<GmResetPW
|
||||
name={player.name}
|
||||
onReset={gmResetPlayerPw}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 p-4 bg-white/3 rounded-lg border border-white/5">
|
||||
<h4 className="text-sm font-medium text-white mb-3">Quick Actions</h4>
|
||||
|
||||
{/* Bulk XP Actions */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Bulk XP Management</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BulkXPGiver onGive={(amount) => {
|
||||
players.forEach(player => {
|
||||
const currentXP = player.xp || 0;
|
||||
gmSetXP(player.name, (currentXP + amount).toString());
|
||||
});
|
||||
}} />
|
||||
<BulkXPSetter onSet={(amount) => {
|
||||
players.forEach(player => {
|
||||
gmSetXP(player.name, amount.toString());
|
||||
});
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk RP Actions */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Bulk RP Management</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BulkRPGiver onGive={(amount) => {
|
||||
players.forEach(player => {
|
||||
const currentRP = player.requisitionPoints || 0;
|
||||
gmSetRP(player.name, (currentRP + amount).toString());
|
||||
});
|
||||
}} />
|
||||
<BulkRPSetter onSet={(amount) => {
|
||||
players.forEach(player => {
|
||||
gmSetRP(player.name, amount.toString());
|
||||
});
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Quick Actions */}
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Other Actions</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600/80 hover:bg-blue-600 text-white transition-colors"
|
||||
onClick={fetchPlayers}
|
||||
>
|
||||
Refresh Players
|
||||
</button>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white transition-colors"
|
||||
onClick={() => {
|
||||
players.forEach(player => {
|
||||
if ((player.requisitionPoints || 0) < 10) {
|
||||
gmSetRP(player.name, '50');
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Set Low RP Players to 50
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import axios from 'axios';
|
||||
import PlayerManagement from '../components/PlayerManagement';
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios;
|
||||
|
||||
// Mock window.confirm
|
||||
const mockConfirm = jest.fn();
|
||||
Object.defineProperty(window, 'confirm', {
|
||||
value: mockConfirm,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('PlayerManagement Component', () => {
|
||||
const mockSessionId = 'test-session-123';
|
||||
const mockAuthedPlayer = 'gm';
|
||||
|
||||
// Mock player data
|
||||
const mockPlayers = [
|
||||
{
|
||||
name: 'TestPlayer',
|
||||
requisitionPoints: 50,
|
||||
xp: 1000,
|
||||
xpSpent: 200,
|
||||
renown: 'Respected',
|
||||
charName: 'Brother Testicus'
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
mockConfirm.mockReturnValue(true);
|
||||
|
||||
// Mock successful API responses
|
||||
mockedAxios.get.mockResolvedValue({ data: mockPlayers });
|
||||
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
|
||||
mockedAxios.delete.mockResolvedValue({ status: 200, data: { success: true } });
|
||||
});
|
||||
|
||||
test('renders access denied for non-GM users', () => {
|
||||
render(<PlayerManagement authedPlayer="player1" sessionId={mockSessionId} />);
|
||||
|
||||
expect(screen.getByText('Access Denied')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Player Management is only accessible to Game Masters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders player management interface for GM', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
expect(screen.getByText('Player Management')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Manage player accounts, requisition points/)).toBeInTheDocument();
|
||||
|
||||
// Wait for players to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('fetches and displays players on mount', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
// Wait for API call
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('/api/players', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Check if player is displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Respected • RP: 50')).toBeInTheDocument();
|
||||
expect(screen.getByText('1000')).toBeInTheDocument(); // Total XP
|
||||
expect(screen.getByText('200')).toBeInTheDocument(); // XP Spent
|
||||
expect(screen.getByText('800')).toBeInTheDocument(); // Available XP
|
||||
});
|
||||
});
|
||||
|
||||
test('creates new player', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Player Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in new player form
|
||||
const nameInput = screen.getByPlaceholderText('Player Name');
|
||||
const rpInput = screen.getByPlaceholderText('Requisition Points');
|
||||
const pwInput = screen.getByPlaceholderText('Password (default: 1234)');
|
||||
const addButton = screen.getByText('Add Player');
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'NewPlayer' } });
|
||||
fireEvent.change(rpInput, { target: { value: '75' } });
|
||||
fireEvent.change(pwInput, { target: { value: 'testpass' } });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/add-or-update',
|
||||
{
|
||||
name: 'NewPlayer',
|
||||
requisitionPoints: 75,
|
||||
password: 'testpass'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('sets requisition points for player', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find RP management section and use current RP value (50)
|
||||
const rpInputs = screen.getAllByDisplayValue('50');
|
||||
const rpInput = rpInputs.find(input => input.type === 'number');
|
||||
const rpSetButtons = screen.getAllByText('Set');
|
||||
const rpSetButton = rpSetButtons[0]; // First Set button should be for RP
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(rpSetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/set-rp',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
requisitionPoints: 50
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('sets experience points for player', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find XP management section and use current XP value (1000)
|
||||
const xpInputs = screen.getAllByDisplayValue('1000');
|
||||
const xpInput = xpInputs.find(input => input.type === 'number');
|
||||
const xpSetButtons = screen.getAllByText('Set');
|
||||
const xpSetButton = xpSetButtons[1]; // Second Set button should be for XP
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(xpSetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/set-xp',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
xp: 1000
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('sets XP spent for player', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find XP Spent management section and use current value (200)
|
||||
const xpSpentInputs = screen.getAllByDisplayValue('200');
|
||||
const xpSpentInput = xpSpentInputs.find(input => input.type === 'number');
|
||||
const xpSpentSetButtons = screen.getAllByText('Set');
|
||||
const xpSpentSetButton = xpSpentSetButtons[2]; // Third Set button should be for XP Spent
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(xpSpentSetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/set-xp-spent',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
xpSpent: 200
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('sets renown for player', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find renown dropdown and use current value (Respected)
|
||||
const renownSelect = screen.getByDisplayValue('Respected');
|
||||
const renownSetButtons = screen.getAllByText('Set');
|
||||
const renownSetButton = renownSetButtons[3]; // Fourth Set button should be for Renown
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(renownSetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/set-renown',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
renown: 'Respected'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resets password for player', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find password reset section
|
||||
const passwordInput = screen.getByPlaceholderText('New password (default: 1234)');
|
||||
const resetButton = screen.getByText('Reset');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'newpass123' } });
|
||||
fireEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/reset-password',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
newPassword: 'newpass123'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('deletes player with confirmation', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByText('Delete Player');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Check confirmation dialog
|
||||
expect(mockConfirm).toHaveBeenCalledWith(
|
||||
'Are you sure you want to delete player TestPlayer? This cannot be undone.'
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith(
|
||||
'/api/players/gm/delete/TestPlayer',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('bulk gives XP to all players', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find bulk XP giver
|
||||
const bulkXPInput = screen.getByDisplayValue('100');
|
||||
const giveXPButton = screen.getByText('Give XP to All');
|
||||
|
||||
fireEvent.change(bulkXPInput, { target: { value: '250' } });
|
||||
fireEvent.click(giveXPButton);
|
||||
|
||||
// Check confirmation
|
||||
expect(mockConfirm).toHaveBeenCalledWith('Give 250 XP to all 1 players?');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/set-xp',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
xp: 1250 // 1000 + 250
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('bulk gives RP to all players', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find bulk RP giver
|
||||
const bulkRPInput = screen.getByDisplayValue('10');
|
||||
const giveRPButton = screen.getByText('Give RP to All');
|
||||
|
||||
fireEvent.change(bulkRPInput, { target: { value: '25' } });
|
||||
fireEvent.click(giveRPButton);
|
||||
|
||||
// Check confirmation
|
||||
expect(mockConfirm).toHaveBeenCalledWith('Give 25 RP to all 1 players?');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/gm/set-rp',
|
||||
{
|
||||
playerName: 'TestPlayer',
|
||||
requisitionPoints: 75 // 50 + 25
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-session-id': mockSessionId,
|
||||
'x-gm-secret': 'bongo'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('handles API errors gracefully', async () => {
|
||||
// Mock API error
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Server error' } }
|
||||
});
|
||||
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Try to set RP and expect error handling
|
||||
const rpInputs = screen.getAllByDisplayValue('50');
|
||||
const rpInput = rpInputs.find(input => input.type === 'number');
|
||||
const rpSetButtons = screen.getAllByText('Set');
|
||||
const rpSetButton = rpSetButtons[0];
|
||||
|
||||
fireEvent.change(rpInput, { target: { value: '100' } });
|
||||
fireEvent.click(rpSetButton);
|
||||
|
||||
// Should show error message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to set RP for TestPlayer: Server error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshes players list', async () => {
|
||||
render(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByText('Refresh Players');
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should make another API call
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user