verifiction

This commit is contained in:
legop3
2026-03-05 03:10:26 -05:00
parent 3007bab54d
commit 3c6de7c040
19 changed files with 1315 additions and 206 deletions
+19
View File
@@ -24,8 +24,11 @@ import HelpPanel from './components/HelpPanel.jsx';
import SettingsPanel from './components/SettingsPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js';
import useUserIdentitySync from './hooks/useUserIdentitySync.js';
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
import VipPanel from './components/VipPanel.jsx';
import { useSession } from './context/SessionContext.jsx';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -80,11 +83,23 @@ function MobileFeatureTabs({
roomPanelId,
showTelemetry = true,
}) {
const { session } = useSession();
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
return (
<section className="panel text-base">
<Tabs defaultTab="chat">
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="vip">
<span className="inline-flex items-center gap-0.5">
<span>VIP</span>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
<Tab id="roomcontrols">Room Controls</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
@@ -96,6 +111,9 @@ function MobileFeatureTabs({
<RawUserPilePanel />
</div>
</TabPanel>
<TabPanel id="vip">
<VipPanel />
</TabPanel>
<TabPanel id="roomcontrols">
<div className="space-y-0.5">
{/* {showTelemetry ? <TelemetryPanel /> : null} */}
@@ -179,6 +197,7 @@ function App() {
function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname();
useUserIdentitySync();
const {
visible: fullscreenVisible,
mode: fullscreenMode,
+2 -33
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx';
import NicknameForm from './NicknameForm.jsx';
import SocialButtonsGrid from './SocialButtonsGrid.jsx';
@@ -34,40 +32,11 @@ export default function RawUserPilePanel({
fillHeight = false,
compact = false,
}) {
const { session, setNickname } = useSession();
const { value } = useSettingsNamespace('profile', { nickname: '' });
const lastSyncedSocketRef = useRef(null);
const socket = useSocket();
const { session } = useSession();
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
useEffect(() => {
if (!canSetNickname) return;
if (!session?.socketId) return;
const nicknameInput = value.nickname || '';
if (!nicknameInput) return;
if (session.socketId === lastSyncedSocketRef.current) return;
const currentId = session.socketId;
setNickname(nicknameInput).then(() => {
lastSyncedSocketRef.current = currentId;
}).catch(() => {});
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
useEffect(() => {
if (!socket) return undefined;
const handleConnect = () => {
if (!canSetNickname) return;
const nick = (value.nickname || '').trim();
if (!nick) return;
setNickname(nick).then(() => {
lastSyncedSocketRef.current = session?.socketId || null;
}).catch(() => {});
};
socket.on('connect', handleConnect);
return () => socket.off('connect', handleConnect);
}, [canSetNickname, setNickname, socket, value.nickname, session?.socketId]);
const sorted = useMemo(
() =>
[...users].sort((a, b) => {
+17
View File
@@ -16,6 +16,8 @@ import { formatKeyLabel } from '../controls/keymapUtils.js';
import NightVisionControl from './NightVisionControl.jsx';
import HornControl from './HornControl.jsx';
import CameraTiltControl from './CameraTiltControl.jsx';
import VipPanel from './VipPanel.jsx';
import { useSession } from '../context/SessionContext.jsx';
function TopDownMapPanel() {
const {
@@ -108,11 +110,23 @@ function DriveDockPanel() {
}
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const { session } = useSession();
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
return (
<section className="panel text-base">
<Tabs defaultTab="telemetry">
<TabList>
<Tab id="telemetry">Controls</Tab>
<Tab id="vip">
<span className="inline-flex items-center gap-2">
<span>VIP</span>
<span
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
@@ -139,6 +153,9 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
</div>
</TabPanel>
<TabPanel id="vip">
<VipPanel />
</TabPanel>
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
</TabPanel>
+2 -2
View File
@@ -12,8 +12,8 @@ function useTabsContext() {
const TAB_VARIANTS = {
primary: {
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500 rounded-md',
active: 'bg-sky-600 text-white',
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500 rounded-md border border-slate-800',
active: 'bg-sky-600 text-white border-white',
inactive: 'bg-zinc-900 text-slate-300 hover:bg-sky-500 hover:text-white',
},
};
+2 -33
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx';
import NicknameForm from './NicknameForm.jsx';
import SocialButtonsGrid from './SocialButtonsGrid.jsx';
@@ -53,10 +51,7 @@ export default function UserListPanel({
compact = false,
showBothTurnsAndUsers = false,
}) {
const { session, setNickname } = useSession();
const { value } = useSettingsNamespace('profile', { nickname: '' });
const lastSyncedSocketRef = useRef(null);
const socket = useSocket();
const { session } = useSession();
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
@@ -70,32 +65,6 @@ export default function UserListPanel({
setTurnView('queues');
}, [isTurnsMode]);
useEffect(() => {
if (!canSetNickname) return;
if (!session?.socketId) return;
const nicknameInput = value.nickname || '';
if (!nicknameInput) return;
if (session.socketId === lastSyncedSocketRef.current) return;
const currentId = session.socketId;
setNickname(nicknameInput).then(() => {
lastSyncedSocketRef.current = currentId;
}).catch(() => {});
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
useEffect(() => {
if (!socket) return undefined;
const handleConnect = () => {
if (!canSetNickname) return;
const nick = (value.nickname || '').trim();
if (!nick) return;
setNickname(nick).then(() => {
lastSyncedSocketRef.current = session?.socketId || null;
}).catch(() => {});
};
socket.on('connect', handleConnect);
return () => socket.off('connect', handleConnect);
}, [canSetNickname, setNickname, socket, value.nickname, session?.socketId]);
const sorted = useMemo(
() =>
[...users].sort((a, b) => {
+328
View File
@@ -0,0 +1,328 @@
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import NicknameForm from './NicknameForm.jsx';
function maskKey(value) {
const key = String(value || '').trim();
if (!key) return '';
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
return `${key.slice(0, 6)}...${key.slice(-6)}`;
}
const cookieKeyRegex = /^cu_[a-f0-9]{32}$/;
export default function VipPanel() {
const { session, identifySession, requestVerification } = useSession();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
const currentStoredKey = useMemo(() => (identity?.cookieUserId || '').trim(), [identity?.cookieUserId]);
const nickname = (profile?.nickname || '').trim();
const isVerified = Boolean(session?.isVerified);
const pendingRequestId = session?.verification?.pendingRequestId || null;
const [requestFlowStep, setRequestFlowStep] = useState(0);
const [requestKeyInput, setRequestKeyInput] = useState('');
const [confirmNickname, setConfirmNickname] = useState(false);
const [restoreFlowStep, setRestoreFlowStep] = useState(0);
const [restoreKeyInput, setRestoreKeyInput] = useState('');
const [working, setWorking] = useState(false);
const [message, setMessage] = useState('');
const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
const innerFlowClass = 'mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center';
const applyIdentityKey = async (nextRaw) => {
const next = String(nextRaw || '').trim().toLowerCase();
if (!next) {
throw new Error('Identity key required.');
}
if (!cookieKeyRegex.test(next)) {
throw new Error('Identity key must match format: cu_ + 32 lowercase hex chars.');
}
saveIdentity((current) => ({ ...(current || {}), cookieUserId: next }));
await identifySession({ cookieUserId: next, nickname });
return next;
};
const beginRequestFlow = () => {
setRequestFlowStep(1);
setRequestKeyInput(currentStoredKey);
setConfirmNickname(false);
setMessage('');
};
const cancelRequestFlow = () => {
setRequestFlowStep(0);
setConfirmNickname(false);
};
const beginRestoreFlow = () => {
setRestoreFlowStep(1);
setRestoreKeyInput('');
setMessage('');
};
const cancelRestoreFlow = () => {
setRestoreFlowStep(0);
setRestoreKeyInput('');
};
const handleRequestSubmit = async (event) => {
event.preventDefault();
if (!confirmNickname) {
setMessage('Please confirm your nickname agreement before sending.');
return;
}
if (requestFlowStep < 3) {
setMessage('Complete all request steps before sending.');
return;
}
setWorking(true);
setMessage('');
try {
const applied = await applyIdentityKey(requestKeyInput);
await requestVerification();
setRequestKeyInput(applied);
setRequestFlowStep(0);
setConfirmNickname(false);
setMessage('Verification request sent to lockdown admins.');
} catch (err) {
setMessage(err.message || 'Failed to submit request.');
} finally {
setWorking(false);
}
};
const handleRestoreSubmit = async (event) => {
event.preventDefault();
if (restoreFlowStep < 2) return;
setWorking(true);
setMessage('');
try {
await applyIdentityKey(restoreKeyInput);
setRestoreFlowStep(0);
setRestoreKeyInput('');
setMessage('Identity key restored.');
} catch (err) {
setMessage(err.message || 'Failed to restore key.');
} finally {
setWorking(false);
}
};
return (
<section className="panel-section space-y-0.5 text-base">
{isVerified ? (
<section className="surface text-sm text-slate-200">
VIP controls will show here... when there are some...
</section>
) : null}
{!isVerified ? (
pendingRequestId ? (
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}>
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
</section>
) : requestFlowStep === 0 ? (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Verification</p>
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
Request Verification
</button>
</div>
</section>
) : (
<form className={`surface ${flowWrapClass}`} onSubmit={handleRequestSubmit}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Request verification</p>
<p className="text-xs text-slate-500">
Step {requestFlowStep} of 3
</p>
{requestFlowStep === 1 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Confirm your nickname, which is used as part of the verification process. You can edit it here before requesting.
</p>
<div className="mx-auto w-full max-w-sm">
<NicknameForm compact />
</div>
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
Current nickname: <span className="font-semibold">{nickname || '(not set)'}</span>
</div>
<label className="flex items-center justify-center gap-0.5 text-xs text-slate-300 text-center">
<input
type="checkbox"
className="accent-emerald-500"
checked={confirmNickname}
onChange={(event) => setConfirmNickname(event.target.checked)}
/>
<span>I understand this nickname is tied to my verification.</span>
</label>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!nickname || !confirmNickname}
onClick={() => setRequestFlowStep(2)}
>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={cancelRequestFlow}>
Cancel
</button>
</div>
</div>
) : null}
{requestFlowStep === 2 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Save your identity key in a safe place. You can use it to restore your identity in another browser.
</p>
<input
className={fieldClass}
type="password"
name="identity_key_request"
autoComplete="current-password"
maxLength={35}
value={requestKeyInput}
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
placeholder="Identity key for this request"
/>
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!requestKeyInput}
onClick={() => navigator.clipboard?.writeText(requestKeyInput)}
>
Copy Key
</button>
<button
type="button"
className="button-dark text-sm"
disabled={!requestKeyInput}
onClick={() => setRequestFlowStep(3)}
>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(1)}>
Back
</button>
</div>
</div>
) : null}
{requestFlowStep === 3 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Final step: confirm and send your verification request.
</p>
<input
className={fieldClass}
type="password"
name="identity_key_request_final"
autoComplete="current-password"
maxLength={35}
value={requestKeyInput}
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
placeholder="Identity key for this request"
/>
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm" disabled={working || !requestKeyInput}>
Confirm Request
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(2)}>
Back
</button>
</div>
</div>
) : null}
</div>
</form>
)
) : null}
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Identity key</p>
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
<input
className={fieldClass}
type="password"
name="identity_key_current"
autoComplete="current-password"
maxLength={35}
value={currentStoredKey}
readOnly
placeholder="Identity key"
/>
{restoreFlowStep === 0 ? (
<div className="flex justify-center gap-0.5">
<button
type="button"
className="button-dark text-sm"
disabled={!currentStoredKey}
onClick={() => navigator.clipboard?.writeText(currentStoredKey)}
>
Copy Key
</button>
<button type="button" className="button-dark text-sm" onClick={beginRestoreFlow} disabled={working}>
Restore Key
</button>
</div>
) : null}
{restoreFlowStep === 1 ? (
<div className="space-y-0.5">
<p className="text-xs text-slate-400">
Restoring your key should only be done when needed. Use this to move your identity to another browser.
</p>
<div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(2)}>
Continue
</button>
<button type="button" className="button-dark text-sm" onClick={cancelRestoreFlow}>
Cancel
</button>
</div>
</div>
) : null}
{restoreFlowStep === 2 ? (
<form className="space-y-0.5" onSubmit={handleRestoreSubmit}>
<input
className={fieldClass}
type="password"
name="identity_key_restore"
autoComplete="current-password"
maxLength={35}
value={restoreKeyInput}
onChange={(event) => setRestoreKeyInput(event.target.value.toLowerCase())}
placeholder="Paste key to restore"
/>
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm" disabled={working || !restoreKeyInput}>
Confirm Restore
</button>
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(1)}>
Back
</button>
</div>
</form>
) : null}
</div>
</section>
{message ? (
<div className={flowWrapClass}>
<p className="text-xs text-slate-400 text-center">{message}</p>
</div>
) : null}
</section>
);
}
+5
View File
@@ -10,6 +10,7 @@ const SessionContext = createContext({
adminLogs: [],
llmCommentaryState: null,
llmCommentaryStatus: null,
identifySession: async () => {},
login: async () => {},
setRole: async () => {},
requestControl: async () => {},
@@ -19,6 +20,7 @@ const SessionContext = createContext({
homeAssistantSetState: async () => {},
homeAssistantSetLightColor: async () => {},
setNickname: async () => {},
requestVerification: async () => {},
triggerReplay: async () => {},
setCommunityGoal: async () => {},
setAdminReason: async () => {},
@@ -117,6 +119,8 @@ export function SessionProvider({ children }) {
const actions = useMemo(
() => ({
login: (username, password) => emitWithAck('auth:login', { username, password }),
identifySession: ({ cookieUserId, nickname } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname }),
setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }),
@@ -130,6 +134,7 @@ export function SessionProvider({ children }) {
homeAssistantSetLightColor: (entityId, rgbColor) =>
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
requestVerification: () => emitWithAck('verification:request'),
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
export default function useUserIdentitySync() {
const socket = useSocket();
const { connected, identifySession } = useSession();
const { value: identity, status: identityStatus, save: saveIdentity } = useSettingsNamespace('identity', {
cookieUserId: '',
});
const { value: profile, status: profileStatus } = useSettingsNamespace('profile', { nickname: '' });
const inFlightRef = useRef(false);
const lastAckSocketRef = useRef(null);
const retryTimerRef = useRef(null);
const ready = identityStatus === 'ready' && profileStatus === 'ready';
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const clearRetry = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const sendIdentify = useCallback(async () => {
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
inFlightRef.current = true;
try {
const resp = await identifySession({ cookieUserId, nickname });
const nextKey = (resp?.cookieUserId || '').trim();
if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
}
lastAckSocketRef.current = socket.id;
clearRetry();
} catch {
clearRetry();
retryTimerRef.current = setTimeout(() => {
sendIdentify();
}, 2000);
} finally {
inFlightRef.current = false;
}
}, [clearRetry, connected, cookieUserId, identifySession, nickname, ready, saveIdentity, socket?.id]);
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
if (lastAckSocketRef.current === socket.id) return;
sendIdentify();
}, [connected, ready, sendIdentify, socket?.id]);
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, sendIdentify]);
useEffect(() => {
const handleOnline = () => {
if (!socket?.connected) return;
sendIdentify();
};
const handleVisibility = () => {
if (typeof document === 'undefined' || document.visibilityState !== 'visible') return;
if (!socket?.connected) return;
sendIdentify();
};
window.addEventListener('online', handleOnline);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', handleOnline);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [sendIdentify, socket?.connected]);
useEffect(() => () => clearRetry(), [clearRetry]);
}
+2 -2
View File
@@ -64,11 +64,11 @@ body {
}
.field-input {
@apply bg-neutral-800 text-white px-0.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md;
@apply bg-neutral-700 border border-neutral-600 text-white placeholder:text-slate-400 px-0.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md;
}
.button-dark {
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-sky-600 hover:bg-sky-500 rounded-md;
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-sky-600 hover:bg-sky-500 rounded-md border border-sky-300 hover:border-sky-500;
}
.button-danger {