ban system

This commit is contained in:
legop3
2026-01-23 04:12:38 -05:00
parent ba4cf80f41
commit 987ef29f30
23 changed files with 1268 additions and 133 deletions
+2
View File
@@ -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}
+212 -1
View File
@@ -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 (
+43
View File
@@ -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>
);
}
+35 -1
View File
@@ -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>;
+19
View File
@@ -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));
+2
View File
@@ -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>
);
+5 -1
View File
@@ -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>
);
}