mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
ban system
This commit is contained in:
@@ -27,6 +27,7 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
||||
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
|
||||
import BannedOverlay from './components/BannedOverlay.jsx';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
@@ -235,6 +236,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
<AlertFeed />
|
||||
<TurnAlertListener />
|
||||
<ModeGateOverlay />
|
||||
<BannedOverlay />
|
||||
<HelpOverlay
|
||||
visible={helpVisible}
|
||||
layout={layout}
|
||||
|
||||
@@ -10,7 +10,18 @@ const MODES = [
|
||||
];
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { session, lockRover, setMode, requestControl, setCommunityGoal, adminLogs } = useSession();
|
||||
const {
|
||||
session,
|
||||
lockRover,
|
||||
setMode,
|
||||
requestControl,
|
||||
setCommunityGoal,
|
||||
adminLogs,
|
||||
moderation,
|
||||
banUser,
|
||||
timeoutUser,
|
||||
unbanUser,
|
||||
} = useSession();
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
const health = session?.health || null;
|
||||
@@ -135,6 +146,12 @@ export default function AdminPanel() {
|
||||
)}
|
||||
/>
|
||||
<ReplaySnapshotHealth health={health} />
|
||||
<ModerationPanel
|
||||
moderation={moderation}
|
||||
onBan={banUser}
|
||||
onTimeout={timeoutUser}
|
||||
onUnban={unbanUser}
|
||||
/>
|
||||
<AdminIpLogPanel entries={adminLogs} />
|
||||
</section>
|
||||
);
|
||||
@@ -202,6 +219,200 @@ function ReplaySnapshotHealth({ health }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationPanel({ moderation, onBan, onTimeout, onUnban }) {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [timeoutMinutes, setTimeoutMinutes] = useState(30);
|
||||
const [reason, setReason] = useState('');
|
||||
const users = moderation?.users || [];
|
||||
const normalized = filter.trim().toLowerCase();
|
||||
const filtered = normalized
|
||||
? users.filter((user) => {
|
||||
const label = user.nicknames?.[user.nicknames.length - 1] || user.id || '';
|
||||
return (
|
||||
label.toLowerCase().includes(normalized) ||
|
||||
String(user.id).toLowerCase().includes(normalized) ||
|
||||
String(user.lastSocketId || '').toLowerCase().includes(normalized)
|
||||
);
|
||||
})
|
||||
: users;
|
||||
|
||||
const handleBan = async (user) => {
|
||||
try {
|
||||
await onBan({ userId: user.id }, reason.trim() || null);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeout = async (user) => {
|
||||
const durationMs = Math.max(1, Number(timeoutMinutes) || 0) * 60 * 1000;
|
||||
try {
|
||||
await onTimeout({ userId: user.id }, durationMs, reason.trim() || null);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async (user) => {
|
||||
try {
|
||||
await onUnban({ userId: user.id });
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="panel-muted text-xs uppercase">Moderation</span>
|
||||
<span className="text-slate-500">{filtered.length} users</span>
|
||||
</div>
|
||||
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<input
|
||||
className="field-input flex-1 min-w-[10rem] text-xs"
|
||||
placeholder="Search users"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="field-input flex-1 min-w-[12rem] text-xs"
|
||||
placeholder="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-0.25 text-xs text-slate-400">
|
||||
<span>Timeout (min)</span>
|
||||
<input
|
||||
className="field-input w-16 text-xs"
|
||||
type="number"
|
||||
min="1"
|
||||
value={timeoutMinutes}
|
||||
onChange={(event) => setTimeoutMinutes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No users tracked yet.</p>
|
||||
) : (
|
||||
filtered
|
||||
.slice()
|
||||
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
|
||||
.map((user) => {
|
||||
const label = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
|
||||
const ban = user.ban || null;
|
||||
const isAdmin = Boolean(user.admin);
|
||||
return (
|
||||
<div key={user.id} className="surface-muted space-y-0.25 text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<span className="text-slate-100">{label}</span>
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
|
||||
{user.id.slice(0, 6)}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<span className="rounded bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
{ban && (
|
||||
<span className="rounded bg-red-500/30 px-1 text-[0.7rem] text-red-200">
|
||||
{ban.expiresAt ? 'Timeout' : 'Banned'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.25">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-[0.7rem]"
|
||||
onClick={() => handleBan(user)}
|
||||
disabled={isAdmin}
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-[0.7rem]"
|
||||
onClick={() => handleTimeout(user)}
|
||||
disabled={isAdmin}
|
||||
>
|
||||
Timeout
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-danger text-[0.7rem]"
|
||||
onClick={() => handleUnban(user)}
|
||||
disabled={!ban}
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-0.5 text-[0.7rem] text-slate-400">
|
||||
{user.lastSocketId && <span>socket {user.lastSocketId.slice(0, 6)}</span>}
|
||||
{user.lastSeen && <span>last seen {new Date(user.lastSeen).toLocaleString()}</span>}
|
||||
{ban?.expiresAt && (
|
||||
<span>expires {new Date(ban.expiresAt).toLocaleString()}</span>
|
||||
)}
|
||||
{user.ips?.length ? (
|
||||
<span>ips {user.ips.slice(-3).join(', ')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<ModerationBanList bans={moderation?.bans || []} onUnban={onUnban} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationBanList({ bans, onUnban }) {
|
||||
if (!bans.length) {
|
||||
return (
|
||||
<div className="surface text-xs text-slate-500">
|
||||
No active bans/timeouts.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||
<div className="panel-muted text-xs uppercase text-slate-400">Active bans</div>
|
||||
{bans.map((ban) => (
|
||||
<div key={ban.id} className="flex flex-wrap items-center justify-between gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
|
||||
{ban.id.slice(0, 6)}
|
||||
</span>
|
||||
{ban.userId && (
|
||||
<span className="text-[0.7rem] text-slate-400">user {ban.userId.slice(0, 6)}</span>
|
||||
)}
|
||||
<span className="text-[0.7rem] text-slate-300">
|
||||
{ban.expiresAt ? 'timeout' : 'ban'}
|
||||
</span>
|
||||
{ban.expiresAt && (
|
||||
<span className="text-[0.7rem] text-slate-400">
|
||||
until {new Date(ban.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{ban.reason ? (
|
||||
<span className="text-[0.7rem] text-slate-400">reason {ban.reason}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button-danger text-[0.7rem]"
|
||||
onClick={() => onUnban({ banId: ban.id })}
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminIpLogPanel({ entries }) {
|
||||
const logs = entries || [];
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import AuthPanel from './AuthPanel.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
|
||||
function formatExpiry(expiresAt) {
|
||||
if (!expiresAt) return 'permanent';
|
||||
const ms = expiresAt - Date.now();
|
||||
if (ms <= 0) return 'expiring soon';
|
||||
const minutes = Math.ceil(ms / 60000);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
const days = Math.ceil(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
export default function BannedOverlay() {
|
||||
const { banStatus } = useSession();
|
||||
if (!banStatus?.banned) return null;
|
||||
const expiresAt = banStatus.expiresAt || null;
|
||||
const reason = banStatus.reason || null;
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/90 px-0.5 py-0.5">
|
||||
<div className="surface w-full max-w-xl space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold text-red-300">Access blocked</p>
|
||||
<p className="text-sm text-slate-300">
|
||||
Your access has been {expiresAt ? 'temporarily restricted' : 'banned'}.
|
||||
</p>
|
||||
<div className="text-xs text-slate-400">
|
||||
{expiresAt ? `Timeout ends in ${formatExpiry(expiresAt)}.` : 'This ban has no expiration.'}
|
||||
</div>
|
||||
{reason && <div className="text-xs text-slate-400">Reason: {reason}</div>}
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Admins can log in above to regain access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const SessionContext = createContext({
|
||||
session: null,
|
||||
logs: [],
|
||||
adminLogs: [],
|
||||
banStatus: null,
|
||||
moderation: null,
|
||||
login: async () => {},
|
||||
setRole: async () => {},
|
||||
requestControl: async () => {},
|
||||
@@ -18,6 +20,9 @@ const SessionContext = createContext({
|
||||
setNickname: async () => {},
|
||||
triggerReplay: async () => {},
|
||||
setCommunityGoal: async () => {},
|
||||
banUser: async () => {},
|
||||
timeoutUser: async () => {},
|
||||
unbanUser: async () => {},
|
||||
});
|
||||
|
||||
function useAckEmitter(socket) {
|
||||
@@ -42,6 +47,8 @@ export function SessionProvider({ children }) {
|
||||
const [session, setSession] = useState(null);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [adminLogs, setAdminLogs] = useState([]);
|
||||
const [banStatus, setBanStatus] = useState(null);
|
||||
const [moderation, setModeration] = useState(null);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [connected, setConnected] = useState(socket.connected);
|
||||
|
||||
@@ -72,11 +79,29 @@ export function SessionProvider({ children }) {
|
||||
function handleAdminLogEntry(entry) {
|
||||
setAdminLogs((prev) => [...prev.slice(-199), entry]);
|
||||
}
|
||||
function handleModerationStatus(payload = {}) {
|
||||
const banned = Boolean(payload.banned);
|
||||
setBanStatus(banned ? payload : null);
|
||||
if (banned) {
|
||||
setSession(null);
|
||||
setLogs([]);
|
||||
setAlerts([]);
|
||||
}
|
||||
}
|
||||
function handleModerationInit(payload = {}) {
|
||||
setModeration(payload);
|
||||
}
|
||||
function handleModerationUpdate(payload = {}) {
|
||||
setModeration(payload);
|
||||
}
|
||||
socket.on('session:sync', handleSession);
|
||||
socket.on('log:init', handleLogInit);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
socket.on('adminlog:init', handleAdminLogInit);
|
||||
socket.on('adminlog:entry', handleAdminLogEntry);
|
||||
socket.on('moderation:status', handleModerationStatus);
|
||||
socket.on('moderation:init', handleModerationInit);
|
||||
socket.on('moderation:update', handleModerationUpdate);
|
||||
socket.on('alert:new', (payload = {}) => {
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
@@ -92,6 +117,9 @@ export function SessionProvider({ children }) {
|
||||
socket.off('log:entry', handleLogEntry);
|
||||
socket.off('adminlog:init', handleAdminLogInit);
|
||||
socket.off('adminlog:entry', handleAdminLogEntry);
|
||||
socket.off('moderation:status', handleModerationStatus);
|
||||
socket.off('moderation:init', handleModerationInit);
|
||||
socket.off('moderation:update', handleModerationUpdate);
|
||||
socket.off('alert:new');
|
||||
};
|
||||
}, [socket]);
|
||||
@@ -112,6 +140,10 @@ export function SessionProvider({ children }) {
|
||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||
banUser: (target, reason) => emitWithAck('moderation:ban', { target, reason }),
|
||||
timeoutUser: (target, durationMs, reason) =>
|
||||
emitWithAck('moderation:ban', { target, durationMs, reason }),
|
||||
unbanUser: (target) => emitWithAck('moderation:unban', { target }),
|
||||
pushAlert: (alert) =>
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
@@ -127,10 +159,12 @@ export function SessionProvider({ children }) {
|
||||
session,
|
||||
logs,
|
||||
adminLogs,
|
||||
banStatus,
|
||||
moderation,
|
||||
alerts,
|
||||
...actions,
|
||||
}),
|
||||
[actions, adminLogs, alerts, connected, logs, session],
|
||||
[actions, adminLogs, alerts, banStatus, connected, logs, moderation, session],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
|
||||
@@ -7,8 +7,27 @@ console.info('[socket] connecting to', resolvedUrl);
|
||||
const settings = loadSettings();
|
||||
const transportPref = settings?.page?.connectionTransport || 'websocket';
|
||||
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
||||
const CLIENT_ID_KEY = 'roverd_client_id';
|
||||
|
||||
function getClientId() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const existing = window.localStorage.getItem(CLIENT_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const created = (crypto?.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
window.localStorage.setItem(CLIENT_ID_KEY, created);
|
||||
return created;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = getClientId();
|
||||
export const socket = io(resolvedUrl, {
|
||||
transports,
|
||||
timeout: 15000,
|
||||
auth: {
|
||||
clientId,
|
||||
},
|
||||
});
|
||||
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
|
||||
|
||||
@@ -9,6 +9,7 @@ import VideoTile from '../components/VideoTile.jsx';
|
||||
import ChatPanel from '../components/ChatPanel.jsx';
|
||||
import AlertFeed from '../components/AlertFeed.jsx';
|
||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||
import BannedOverlay from '../components/BannedOverlay.jsx';
|
||||
|
||||
const ROTATE_MS = 20000;
|
||||
const HARD_REFRESH_MS = 3 * 60 * 60 * 1000;
|
||||
@@ -153,6 +154,7 @@ export default function MiniSummaryApp() {
|
||||
<>
|
||||
<MiniSummaryContent />
|
||||
<AlertFeed scale={3} />
|
||||
<BannedOverlay />
|
||||
</>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
||||
import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
|
||||
import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
|
||||
import BannedOverlay from '../components/BannedOverlay.jsx';
|
||||
|
||||
function formatDriverLabel({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
@@ -159,7 +160,10 @@ function SpectatorContent() {
|
||||
export default function SpectatorApp() {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<SpectatorContent />
|
||||
<>
|
||||
<SpectatorContent />
|
||||
<BannedOverlay />
|
||||
</>
|
||||
</SettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user