mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
duplicate tab protection
This commit is contained in:
+3
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user