appoint spectator access when you login from external, actually...

This commit is contained in:
legop3
2026-07-14 20:42:37 -04:00
parent 51fbee400c
commit ad7de34d6d
6 changed files with 74 additions and 66 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D5SKLi1e.js"></script>
<script type="module" crossorigin src="/assets/index-CmNL6XEv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css">
</head>
<body>
+51
View File
@@ -16,6 +16,7 @@ const {
const {
getFeatureState,
getUserIdForSocket,
updateFeatureState,
} = require('../identityService');
const config = loadConfig();
@@ -76,6 +77,49 @@ function externalSpectatorAccessError() {
return 'External spectator access is disabled.';
}
function grantExternalSpectatorAccessAfterAdminLogin(socket) {
const policy = getBandwidthSavingsPolicy();
if (policy.externalSpectatorAccess !== 'admin') {
return false;
}
const ip = getSocketIp(socket);
if (isLocalNetwork(ip)) {
return false;
}
const userId = getUserIdForSocket(socket);
if (!userId) {
/*
Sockets are normally identified on connection before login, but keeping a
guard here makes the admin grant fail closed instead of writing an orphan
feature-state row if identity setup changes later.
*/
logger.warn('External spectator grant skipped because socket has no identity', { socketId: socket?.id });
return false;
}
updateFeatureState(
userId,
SPECTATOR_ACCESS_NAMESPACE,
(current) => ({
/*
Preserve any future spectatorAccess settings beside `external`. The
login flow is only approving this identity for external spectating, not
resetting the whole namespace back to a one-field object.
*/
...(current || {}),
external: true,
grantedByAdminLoginAt: Date.now(),
grantedByAdminUsername: socket?.data?.user?.username || null,
}),
{},
);
logger.info('External spectator access granted after admin login', {
socketId: socket.id,
userId,
username: socket?.data?.user?.username || null,
});
return true;
}
io.on('connection', (socket) => {
const requestedRole = socket.handshake?.query?.role;
/*
@@ -96,6 +140,13 @@ io.on('connection', (socket) => {
const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role);
/*
In admin-gated external spectator mode, logging in from /spectate is the
approval action for this browser identity. Persist the grant before the
client retries switching back to spectator, otherwise the user would
lose the admin bypass and immediately fall back into the gate.
*/
grantExternalSpectatorAccessAfterAdminLogin(socket);
socket.emit('auth:role', { role });
clearLockdownTimer(socket);
logger.info('Login success', socket.id, role);
+8 -38
View File
@@ -14,64 +14,35 @@ const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
const LOCKDOWN_ROLES = new Set(['lockdown']);
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
function getModeDetails({ mode = 'admin', spectatorAccessBlocked = false, spectatorAccessMode = 'on' } = {}) {
if (spectatorAccessBlocked) {
/*
External spectator access is not a server mode like admin/lockdown; it is
a route-specific bandwidth/access gate. It still needs the same overlay
because the AuthPanel is the only browser-side way for an admin to prove
they should bypass that gate from /spectate.
*/
return {
title: 'Spectate access required',
description: spectatorAccessMode === 'admin'
? 'External spectators need admin approval or an admin login before viewing this page.'
: 'External spectator access is disabled. Admins can log in to continue.',
reasonLabel: 'Access state:',
reason: spectatorAccessMode === 'admin' ? 'Waiting for approved spectator identity or admin login.' : 'External spectating is off.',
chatIntro: 'You can still use the chat while access is blocked:',
};
}
function getModeDetails(mode = 'admin') {
if (mode === 'lockdown') {
return {
title: 'Lockdown mode active',
description:
'Only the server owners can access the interface at this time.',
reasonLabel: 'Reason for locking:',
chatIntro: 'You can still use the chat while the server is locked:',
};
}
return {
title: 'Admin mode active',
// description:
// 'The server is currently in admin mode. Only admins can access the interface.',
reasonLabel: 'Reason for locking:',
chatIntro: 'You can still use the chat while the server is locked:',
};
}
export default function ModeGateOverlay({ includeSpectatorAccessGate = false }) {
export default function ModeGateOverlay() {
const mode = useSessionSelector((state) => state.session?.mode || null);
const role = useSessionSelector((state) => state.session?.role || null);
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const spectatorAccessAllowed = useSessionSelector(
(state) => state.session?.bandwidthSavings?.canUseExternalSpectatorAccess,
);
const spectatorAccessMode = useSessionSelector(
(state) => state.session?.bandwidthSavings?.externalSpectatorAccess || 'on',
);
const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
const spectatorAccessBlocked = Boolean(includeSpectatorAccessGate && spectatorAccessAllowed === false);
const blocked = (restricted && !privileged) || spectatorAccessBlocked;
/*
The overlay is mounted for the whole app, but the server-time display is
only visible while access is actually blocked. Gating the shared clock here
prevents the hidden overlay from registering a permanent interval.
*/
const nowMs = useSharedClock(1000, blocked);
const nowMs = useSharedClock(1000, restricted && !privileged);
const serverTime = useMemo(() => {
const now = new Date(nowMs);
@@ -87,12 +58,11 @@ export default function ModeGateOverlay({ includeSpectatorAccessGate = false })
}
}, [nowMs, timezone]);
if (!blocked) {
if (!restricted || privileged) {
return null;
}
const details = getModeDetails({ mode, spectatorAccessBlocked, spectatorAccessMode });
const displayedReason = spectatorAccessBlocked ? details.reason : reason || 'No reason set.';
const details = getModeDetails(mode);
return (
<div className="pointer-events-auto fixed inset-0 z-50 overflow-y-auto bg-black px-0.5 py-0.5">
@@ -103,9 +73,9 @@ export default function ModeGateOverlay({ includeSpectatorAccessGate = false })
<p className="text-sm text-slate-300">{details.description}</p>
</div>
<div className="surface-muted space-y-0.5">
<p className="text-[0.7rem] tracking-wide text-slate-400">{details.reasonLabel}</p>
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
<p className="text-lg font-semibold text-slate-100">
{displayedReason}
{reason ? reason : 'No reason set.'}
</p>
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
</div>
@@ -113,7 +83,7 @@ export default function ModeGateOverlay({ includeSpectatorAccessGate = false })
<AuthPanel />
</div>
<SocialButton id="discord" label="Join our Discord server for updates!" />
{details.chatIntro}
You can still use the chat while the server is locked:
{/* set max height of this box */}
<div className='max-h-80 overflow-y-auto'>
<ChatPanel nicknameLayout="stacked" />
@@ -1,21 +1,8 @@
// Spectator App Root
// Purpose: Defines the Spectator App Root module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import ModeGateOverlay from '../../components/ModeGateOverlay/index.jsx';
import SpectatorContent from './SpectatorContent.jsx';
export default function SpectatorAppRoot() {
return (
<>
<SpectatorContent />
{/*
The spectator route does not render App.jsx, so it must mount the gate
overlay itself. This keeps admin/lockdown login behavior available on
/spectate and, more importantly, gives external spectators a real
AuthPanel when the bandwidth policy requires admin approval or admin
login before spectating.
*/}
<ModeGateOverlay includeSpectatorAccessGate />
</>
);
return <SpectatorContent />;
}