duplicate tab protection

This commit is contained in:
legop3
2026-06-21 16:21:25 -04:00
parent e95eeeb526
commit 9cf71e8df3
10 changed files with 316 additions and 146 deletions
+3 -1
View File
@@ -45,6 +45,7 @@ import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
import OdometerPanel from './components/OdometerPanel/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
import { trackAnalyticsEvent } from './analytics/index.js';
@@ -304,7 +305,7 @@ function App() {
function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname();
useUserIdentitySync();
useUserIdentitySync({ identitySurface: 'driver' });
useTelemetryVisualPolicy({ mobile: !isDesktop });
const {
visible: fullscreenVisible,
@@ -425,6 +426,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
{renderedLayout}
</main>
<AlertFeed />
<DuplicateIdentityOverlay />
<RewardRunOverlay />
<TurnAlertListener />
<ModeGateOverlay />
@@ -0,0 +1,36 @@
// Duplicate Identity Overlay
// Purpose: Blocks an older non-verified driver tab after the server detects the same identity in another driver tab.
// Scope: Presents a driver-page-only message while leaving enforcement owned by the server.
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function DuplicateIdentityOverlay() {
const duplicateIdentityBlock = useSessionSelector((state) => state.duplicateIdentityBlock);
if (!duplicateIdentityBlock) {
return null;
}
const message =
typeof duplicateIdentityBlock?.message === 'string' && duplicateIdentityBlock.message.trim()
? duplicateIdentityBlock.message.trim()
: 'This driver session is already active in another tab.';
return (
<div className="pointer-events-auto fixed inset-0 z-[2000] flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
<section className="surface w-full max-w-md space-y-0.5 text-center shadow-2xl">
<div className="space-y-0.5">
<h1 className="text-lg font-semibold text-white">Another driver tab is already open</h1>
<p className="text-sm text-slate-300">{message}</p>
</div>
{/* <div className="surface-muted space-y-0.5">
<p className="text-sm text-slate-100">
Continue from the newest tab or close this one.
</p>
<p className="text-xs text-slate-400">
Verified users are allowed to keep multiple tabs open.
</p>
</div> */}
</section>
</div>
);
}
+25 -2
View File
@@ -18,6 +18,7 @@ const INITIAL_STATE = {
replayJobs: {},
latestReplay: null,
latestRequestedReplay: null,
duplicateIdentityBlock: null,
};
const SessionContext = createContext(null);
@@ -283,6 +284,26 @@ export function SessionProvider({ children }) {
const memory = payload && typeof payload === 'object' ? payload : null;
setState((prev) => ({ ...prev, overseerMemory: memory }));
}
function handleDuplicateIdentity(payload = {}) {
/*
The server sends this event immediately before closing an older duplicate
tab. Persisting the reason in app state lets the UI replace the whole
page with a clear blocking screen instead of leaving users on a normal
disconnected interface that looks recoverable.
*/
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message.trim()
: 'This driver session is already active in another tab.';
setState((prev) => ({
...prev,
duplicateIdentityBlock: {
...payload,
message,
receivedAt: Date.now(),
},
}));
}
socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry);
@@ -295,6 +316,7 @@ export function SessionProvider({ children }) {
socket.on('replay:status', handleReplayStatus);
socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
return () => {
socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit);
@@ -308,14 +330,15 @@ export function SessionProvider({ children }) {
socket.off('replay:status', handleReplayStatus);
socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
};
}, [setState, socket]);
const actions = useMemo(
() => ({
login: (username, password) => emitWithAck('auth:login', { username, password }),
identifySession: ({ cookieUserId, nickname, overseerEnabled } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled }),
identifySession: ({ cookieUserId, nickname, overseerEnabled, identitySurface } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled, identitySurface }),
setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }),
+16 -3
View File
@@ -5,7 +5,7 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
import { useSocket } from '../context/SocketContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
export default function useUserIdentitySync() {
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
const socket = useSocket();
const connected = useSessionSelector((state) => state.connected);
const { identifySession } = useSessionActions();
@@ -27,6 +27,7 @@ export default function useUserIdentitySync() {
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
const clearRetry = useCallback(() => {
if (retryTimerRef.current) {
@@ -39,7 +40,18 @@ export default function useUserIdentitySync() {
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
inFlightRef.current = true;
try {
const resp = await identifySession({ cookieUserId, nickname, overseerEnabled });
/*
Every route shares the same persisted identity key, but only the main
driver page should trigger duplicate-tab prevention. Sending the surface
with the heartbeat lets the server make that decision before spectator
pages finish their role switch.
*/
const resp = await identifySession({
cookieUserId,
nickname,
overseerEnabled,
identitySurface: normalizedIdentitySurface,
});
const nextKey = (resp?.cookieUserId || '').trim();
if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
@@ -59,6 +71,7 @@ export default function useUserIdentitySync() {
connected,
cookieUserId,
identifySession,
normalizedIdentitySurface,
nickname,
overseerEnabled,
ready,
@@ -75,7 +88,7 @@ export default function useUserIdentitySync() {
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, sendIdentify]);
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
useEffect(() => {
const handleOnline = () => {