mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
verifiction
This commit is contained in:
@@ -16,6 +16,7 @@ require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/verificationService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/communityGoalService');
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DETl6izG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DzeZhrlZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-DlEkWSx3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -31,6 +31,14 @@ const {
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
} = require('./discordGuildStore');
|
||||
const {
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
denyRequest,
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
} = require('./verificationService');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -53,13 +61,16 @@ if (!enabled) {
|
||||
const intents = [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.GuildMessageTyping,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.DirectMessageReactions,
|
||||
GatewayIntentBits.MessageContent,
|
||||
];
|
||||
|
||||
const client = new Client({
|
||||
intents,
|
||||
partials: [Partials.Channel],
|
||||
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
||||
});
|
||||
|
||||
const channelCache = new Map();
|
||||
@@ -68,6 +79,8 @@ let skippedFirstModeAnnouncement = false;
|
||||
const PRESENCE_ROTATE_MS = 20000;
|
||||
let presenceInterval = null;
|
||||
let presenceShowGoal = false;
|
||||
const VERIFY_APPROVE_EMOJI = '✅';
|
||||
const VERIFY_DENY_EMOJI = '❌';
|
||||
function sanitizeMentions(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
@@ -271,6 +284,8 @@ function formatHelp() {
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
'`rs reason [text|clear]` — show or set admin mode reason',
|
||||
'`rs goal [text|clear]` — show or set community goal',
|
||||
'`rs verify list` — list verified users (lockdown admins)',
|
||||
'`rs verify remove <cookieUserId|nickname>` — remove verified user (lockdown admins)',
|
||||
'`ts` — show time status',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -580,6 +595,74 @@ async function handleGoalCommand(message, tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatMaskedCookieKey(value) {
|
||||
const key = String(value || '').trim();
|
||||
if (!key) return 'n/a';
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
async function handleVerifyCommand(message, tokens) {
|
||||
if (!isLockdownAdminUser(message.author?.id)) {
|
||||
await message.reply({
|
||||
content: 'Only lockdown admins can manage verified users.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const action = (tokens.shift() || 'list').toLowerCase();
|
||||
if (action === 'list') {
|
||||
const users = listVerifiedUsers();
|
||||
if (!users.length) {
|
||||
await message.reply({
|
||||
content: 'No verified users.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const lines = users.map((entry, idx) => {
|
||||
const updated = entry.updatedAt ? new Date(entry.updatedAt).toLocaleString() : 'unknown';
|
||||
const ipCount = Array.isArray(entry.knownIps) ? entry.knownIps.length : 0;
|
||||
return `${idx + 1}. ${entry.nickname || 'unknown'} | ${formatMaskedCookieKey(entry.cookieUserId)} | ips:${ipCount} | updated:${updated}`;
|
||||
});
|
||||
await message.reply({
|
||||
content: ['Verified users:', ...lines].join('\n').slice(0, 1900),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'remove') {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) {
|
||||
await message.reply({
|
||||
content: 'Usage: `rs verify remove <cookieUserId|nickname>`',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const removed = removeVerifiedUser(selector, message.author?.id || null);
|
||||
await message.reply({
|
||||
content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${formatMaskedCookieKey(removed.cookieUserId)}).`,
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed to remove verified user: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await message.reply({
|
||||
content: 'Unknown verify command. Use `rs verify list` or `rs verify remove <cookieUserId|nickname>`.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
|
||||
function canManageBridge(message) {
|
||||
if (isAdminUser(message.author.id)) return true;
|
||||
if (!message.guild || !message.member) return false;
|
||||
@@ -757,7 +840,7 @@ async function handleCommand(message) {
|
||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
||||
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
|
||||
const mode = getMode();
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason']);
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify']);
|
||||
|
||||
if (
|
||||
!isAdmin &&
|
||||
@@ -768,7 +851,8 @@ async function handleCommand(message) {
|
||||
action !== 'replay' &&
|
||||
action !== 'bridge' &&
|
||||
action !== 'goal' &&
|
||||
action !== 'reason'
|
||||
action !== 'reason' &&
|
||||
action !== 'verify'
|
||||
) {
|
||||
return; // ignore non-admins for privileged commands
|
||||
}
|
||||
@@ -812,6 +896,9 @@ async function handleCommand(message) {
|
||||
case 'reason':
|
||||
await handleReasonCommand(message, tokens);
|
||||
break;
|
||||
case 'verify':
|
||||
await handleVerifyCommand(message, tokens);
|
||||
break;
|
||||
default:
|
||||
await message.reply(formatHelp());
|
||||
break;
|
||||
@@ -1348,6 +1435,96 @@ function handleBusEvent(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendVerificationRequestDms(event) {
|
||||
const payload = event?.payload || {};
|
||||
const requestId = payload.id;
|
||||
if (!requestId) return;
|
||||
const adminIdsToNotify = Array.from(lockdownAdminIds);
|
||||
if (!adminIdsToNotify.length) {
|
||||
logger.warn('No lockdown admins configured for verification request DM', { requestId });
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown';
|
||||
const content = [
|
||||
'**Verification Request**',
|
||||
`Request ID: \`${requestId}\``,
|
||||
`Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`,
|
||||
`Identity key: \`${payload.cookieUserId || 'unknown'}\``,
|
||||
`IP: \`${payload.ip || 'unknown'}\``,
|
||||
`Created: ${createdAt}`,
|
||||
'',
|
||||
`React with ${VERIFY_APPROVE_EMOJI} to approve or ${VERIFY_DENY_EMOJI} to deny.`,
|
||||
].join('\n');
|
||||
|
||||
await Promise.all(
|
||||
adminIdsToNotify.map(async (adminId) => {
|
||||
try {
|
||||
const user = await client.users.fetch(String(adminId));
|
||||
if (!user) return;
|
||||
const dm = await user.createDM();
|
||||
const message = await dm.send({ content, allowedMentions: { parse: [] } });
|
||||
try {
|
||||
await message.react(VERIFY_APPROVE_EMOJI);
|
||||
await message.react(VERIFY_DENY_EMOJI);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to add verification reactions', { requestId, adminId, error: err.message });
|
||||
}
|
||||
attachDmMessage(requestId, message.id, adminId);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to DM lockdown admin for verification request', {
|
||||
requestId,
|
||||
adminId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleVerificationReaction(reaction, user) {
|
||||
if (!reaction || !user || user.bot) return;
|
||||
const emoji = reaction.emoji?.name;
|
||||
if (emoji !== VERIFY_APPROVE_EMOJI && emoji !== VERIFY_DENY_EMOJI) return;
|
||||
if (!isLockdownAdminUser(user.id)) return;
|
||||
|
||||
const maybePartial = reaction.message?.partial || reaction.partial;
|
||||
if (maybePartial) {
|
||||
try {
|
||||
await reaction.fetch();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to fetch partial reaction', err.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = reaction.message?.id;
|
||||
if (!messageId) return;
|
||||
const linked = getRequestByMessageId(messageId);
|
||||
if (!linked?.request || linked.request.status !== 'pending') return;
|
||||
|
||||
try {
|
||||
if (emoji === VERIFY_APPROVE_EMOJI) {
|
||||
approveRequest(linked.request.id, user.id);
|
||||
await reaction.message.reply({
|
||||
content: `Approved request \`${linked.request.id}\`.`,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
} else {
|
||||
denyRequest(linked.request.id, user.id);
|
||||
await reaction.message.reply({
|
||||
content: `Denied request \`${linked.request.id}\`.`,
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve verification request from reaction', {
|
||||
requestId: linked.request.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatBridgeOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload) return;
|
||||
@@ -1445,12 +1622,19 @@ client.on('typingStart', (typing) => {
|
||||
});
|
||||
});
|
||||
|
||||
client.on('messageReactionAdd', (reaction, user) => {
|
||||
handleVerificationReaction(reaction, user).catch((err) => {
|
||||
logger.warn('Error handling verification reaction', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
client.once('ready', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
schedulePresenceRotation();
|
||||
});
|
||||
|
||||
subscribe('*', handleBusEvent);
|
||||
subscribe('verification.requested', sendVerificationRequestDms);
|
||||
subscribe('chat:message', handleChatBridgeOutbound);
|
||||
subscribe('chat:typing', handleChatTypingOutbound);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('nicknameService');
|
||||
const { getRole } = require('./roleService');
|
||||
|
||||
const nicknameEvents = new EventEmitter();
|
||||
|
||||
@@ -19,15 +18,14 @@ function getNickname(socket) {
|
||||
|
||||
function setNickname(socket, nickname) {
|
||||
if (!socket) return null;
|
||||
const role = getRole(socket);
|
||||
// if (role === 'spectator') {
|
||||
// throw new Error('Spectators cannot set nicknames');
|
||||
// }
|
||||
const value = sanitizeNickname(nickname);
|
||||
if (!value) {
|
||||
throw new Error('Nickname required');
|
||||
}
|
||||
socket.data = socket.data || {};
|
||||
if (socket.data.nickname === value) {
|
||||
return value;
|
||||
}
|
||||
socket.data.nickname = value;
|
||||
nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
|
||||
logger.info('Nickname set', { socketId: socket.id, nickname: value });
|
||||
|
||||
@@ -9,6 +9,11 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||
const {
|
||||
getVerificationStateForSocket,
|
||||
getIdentitySummary,
|
||||
verificationEvents,
|
||||
} = require('./verificationService');
|
||||
const { getReplayState, replayEvents } = require('./replayService');
|
||||
const { getReplaySources } = require('./replaySourceService');
|
||||
const { getHealthSnapshot } = require('./healthService');
|
||||
@@ -83,6 +88,9 @@ function buildSession(socket) {
|
||||
kofi: {
|
||||
link: kofiLink,
|
||||
},
|
||||
identity: getIdentitySummary(socket),
|
||||
verification: getVerificationStateForSocket(socket),
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,6 +237,17 @@ nicknameEvents.on('change', ({ socketId }) => {
|
||||
}
|
||||
});
|
||||
|
||||
verificationEvents.on('change', ({ socketId } = {}) => {
|
||||
if (socketId) {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
syncSocket(socket);
|
||||
return;
|
||||
}
|
||||
}
|
||||
syncAll();
|
||||
});
|
||||
|
||||
subscribe('communityGoal.updated', () => {
|
||||
logger.info('Community goal updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('verificationService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { getSocketIp, normalizeIp } = require('../helpers/ipResolver');
|
||||
const { getNickname, setNickname } = require('./nicknameService');
|
||||
const { getRole } = require('./roleService');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'verified-users.json');
|
||||
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
||||
|
||||
const verificationEvents = new EventEmitter();
|
||||
|
||||
let cache = null;
|
||||
|
||||
function sanitizeNickname(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.replace(/\*/g, 'nope').slice(0, 32);
|
||||
}
|
||||
|
||||
function normalizeStoreShape(store) {
|
||||
const next = store && typeof store === 'object' ? store : {};
|
||||
return {
|
||||
verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [],
|
||||
pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [],
|
||||
dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = normalizeStoreShape(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load verification store', err.message);
|
||||
}
|
||||
cache = normalizeStoreShape({});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function writeStore(next) {
|
||||
const normalized = normalizeStoreShape(next);
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, STORE_PATH);
|
||||
cache = normalized;
|
||||
return cache;
|
||||
}
|
||||
|
||||
function withStore(mutator) {
|
||||
const current = loadStore();
|
||||
const draft = {
|
||||
verifiedUsers: current.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
|
||||
pendingRequests: current.pendingRequests.map((entry) => ({ ...entry })),
|
||||
dmMessages: current.dmMessages.map((entry) => ({ ...entry })),
|
||||
};
|
||||
const result = mutator(draft);
|
||||
writeStore(draft);
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateCookieUserId() {
|
||||
return `cu_${crypto.randomBytes(16).toString('hex')}`;
|
||||
}
|
||||
|
||||
function normalizeCookieUserId(value) {
|
||||
const raw = typeof value === 'string' ? value.trim() : '';
|
||||
if (!raw) return '';
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
function isValidCookieUserId(value) {
|
||||
return COOKIE_USER_ID_RE.test(normalizeCookieUserId(value));
|
||||
}
|
||||
|
||||
function getKnownIp(socket) {
|
||||
return normalizeIp(getSocketIp(socket));
|
||||
}
|
||||
|
||||
function ensureSocketData(socket) {
|
||||
socket.data = socket.data || {};
|
||||
return socket.data;
|
||||
}
|
||||
|
||||
function findVerifiedMatch(store, { cookieUserId, ip }) {
|
||||
if (!cookieUserId && !ip) return null;
|
||||
const byCookie = cookieUserId
|
||||
? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null
|
||||
: null;
|
||||
if (byCookie) return byCookie;
|
||||
if (!ip) return null;
|
||||
return (
|
||||
store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function emitChange(reason, payload = {}) {
|
||||
verificationEvents.emit('change', { reason, ...payload });
|
||||
}
|
||||
|
||||
function reevaluateSocketVerification(socket) {
|
||||
if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' };
|
||||
const store = loadStore();
|
||||
const data = ensureSocketData(socket);
|
||||
const role = getRole(socket);
|
||||
const cookieUserId = normalizeCookieUserId(data.cookieUserId);
|
||||
const nickname = sanitizeNickname(getNickname(socket));
|
||||
const ip = getKnownIp(socket);
|
||||
|
||||
if (role === 'lockdown') {
|
||||
data.isVerified = true;
|
||||
data.verifiedRecordId = null;
|
||||
return {
|
||||
isVerified: true,
|
||||
matchedRecordId: null,
|
||||
reason: 'lockdown_admin',
|
||||
cookieUserId,
|
||||
nickname,
|
||||
ip,
|
||||
};
|
||||
}
|
||||
|
||||
const match = findVerifiedMatch(store, { cookieUserId, ip });
|
||||
const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname);
|
||||
|
||||
let isVerified = false;
|
||||
let reason = 'no_match';
|
||||
if (match && nicknameMatches) {
|
||||
isVerified = true;
|
||||
reason = 'matched';
|
||||
} else if (match && !nicknameMatches) {
|
||||
reason = 'nickname_mismatch';
|
||||
}
|
||||
|
||||
data.isVerified = isVerified;
|
||||
data.verifiedRecordId = isVerified ? match.id : null;
|
||||
|
||||
if (isVerified) {
|
||||
withStore((draft) => {
|
||||
const record = draft.verifiedUsers.find((entry) => entry.id === match.id);
|
||||
if (!record) return;
|
||||
record.updatedAt = Date.now();
|
||||
record.nickname = nickname;
|
||||
if (ip && !record.knownIps.includes(ip)) {
|
||||
record.knownIps.push(ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isVerified,
|
||||
matchedRecordId: isVerified ? match.id : null,
|
||||
reason,
|
||||
cookieUserId,
|
||||
nickname,
|
||||
ip,
|
||||
};
|
||||
}
|
||||
|
||||
function identifySocket(socket, payload = {}) {
|
||||
if (!socket) {
|
||||
throw new Error('Socket required');
|
||||
}
|
||||
const data = ensureSocketData(socket);
|
||||
const incomingKey = normalizeCookieUserId(payload.cookieUserId);
|
||||
if (incomingKey && !isValidCookieUserId(incomingKey)) {
|
||||
throw new Error('Invalid identity key format.');
|
||||
}
|
||||
const currentKey = normalizeCookieUserId(data.cookieUserId);
|
||||
const safeCurrentKey = isValidCookieUserId(currentKey) ? currentKey : '';
|
||||
data.cookieUserId = incomingKey || safeCurrentKey || generateCookieUserId();
|
||||
|
||||
const incomingNickname = sanitizeNickname(payload.nickname);
|
||||
if (incomingNickname) {
|
||||
try {
|
||||
if (incomingNickname !== getNickname(socket)) {
|
||||
setNickname(socket, incomingNickname);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to set nickname from identify', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const verification = reevaluateSocketVerification(socket);
|
||||
emitChange('identify', { socketId: socket.id });
|
||||
return {
|
||||
cookieUserId: data.cookieUserId,
|
||||
isVerified: verification.isVerified,
|
||||
reason: verification.reason,
|
||||
identifiedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function getVerificationStatus(socket) {
|
||||
const data = socket?.data || {};
|
||||
return {
|
||||
isVerified: Boolean(data.isVerified),
|
||||
recordId: data.verifiedRecordId || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getIdentitySummary(socket) {
|
||||
const data = socket?.data || {};
|
||||
return {
|
||||
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
||||
nickname: getNickname(socket) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getPendingRequestForIdentity(cookieUserId) {
|
||||
const key = normalizeCookieUserId(cookieUserId);
|
||||
if (!key) return null;
|
||||
const store = loadStore();
|
||||
return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null;
|
||||
}
|
||||
|
||||
function listVerifiedUsers() {
|
||||
const store = loadStore();
|
||||
return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
|
||||
}
|
||||
|
||||
function resolveVerifiedUserSelector(selector) {
|
||||
const value = String(selector || '').trim();
|
||||
if (!value) return { error: 'selector_required' };
|
||||
const store = loadStore();
|
||||
const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null;
|
||||
if (byCookie) return { record: byCookie };
|
||||
const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value));
|
||||
if (byNickname.length === 1) return { record: byNickname[0] };
|
||||
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
|
||||
return { error: 'not_found' };
|
||||
}
|
||||
|
||||
function removeVerifiedUser(selector, removedBy = null) {
|
||||
const resolved = resolveVerifiedUserSelector(selector);
|
||||
if (resolved.error) {
|
||||
throw new Error(
|
||||
resolved.error === 'ambiguous_nickname'
|
||||
? 'Nickname matches multiple users; remove by cookieUserId.'
|
||||
: 'Verified user not found.',
|
||||
);
|
||||
}
|
||||
const target = resolved.record;
|
||||
let removed = null;
|
||||
withStore((draft) => {
|
||||
const before = draft.verifiedUsers.length;
|
||||
draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id);
|
||||
if (draft.verifiedUsers.length !== before) {
|
||||
removed = target;
|
||||
}
|
||||
});
|
||||
if (!removed) {
|
||||
throw new Error('Verified user not found.');
|
||||
}
|
||||
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const data = ensureSocketData(socket);
|
||||
if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) {
|
||||
reevaluateSocketVerification(socket);
|
||||
}
|
||||
});
|
||||
|
||||
emitChange('remove', { cookieUserId: removed.cookieUserId });
|
||||
publishEvent({
|
||||
source: 'verification',
|
||||
type: 'verification.userRemoved',
|
||||
payload: {
|
||||
cookieUserId: removed.cookieUserId,
|
||||
nickname: removed.nickname,
|
||||
removedBy,
|
||||
removedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function createVerificationRequest(socket) {
|
||||
if (!socket) {
|
||||
throw new Error('Socket required');
|
||||
}
|
||||
const data = ensureSocketData(socket);
|
||||
const cookieUserId = normalizeCookieUserId(data.cookieUserId);
|
||||
const nickname = sanitizeNickname(getNickname(socket));
|
||||
const ip = getKnownIp(socket);
|
||||
|
||||
if (data.isVerified) {
|
||||
throw new Error('You are already verified.');
|
||||
}
|
||||
|
||||
if (!cookieUserId) {
|
||||
throw new Error('Identity key missing. Reconnect and try again.');
|
||||
}
|
||||
if (!isValidCookieUserId(cookieUserId)) {
|
||||
throw new Error('Identity key format invalid.');
|
||||
}
|
||||
if (!nickname) {
|
||||
throw new Error('Nickname required before requesting verification.');
|
||||
}
|
||||
|
||||
const existingPending = getPendingRequestForIdentity(cookieUserId);
|
||||
if (existingPending) {
|
||||
return existingPending;
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
|
||||
status: 'pending',
|
||||
cookieUserId,
|
||||
nickname,
|
||||
ip,
|
||||
socketId: socket.id,
|
||||
createdAt: Date.now(),
|
||||
resolvedAt: null,
|
||||
resolvedBy: null,
|
||||
decision: null,
|
||||
};
|
||||
|
||||
withStore((draft) => {
|
||||
draft.pendingRequests.push(request);
|
||||
});
|
||||
|
||||
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
|
||||
emitChange('request', { requestId: request.id, socketId: socket.id });
|
||||
return request;
|
||||
}
|
||||
|
||||
function attachDmMessage(requestId, messageId, adminDiscordId) {
|
||||
if (!requestId || !messageId) return;
|
||||
withStore((draft) => {
|
||||
const exists = draft.dmMessages.find((entry) => entry.messageId === messageId);
|
||||
if (exists) return;
|
||||
draft.dmMessages.push({
|
||||
requestId,
|
||||
messageId,
|
||||
adminDiscordId: adminDiscordId ? String(adminDiscordId) : null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingRequestById(requestId) {
|
||||
if (!requestId) return null;
|
||||
const store = loadStore();
|
||||
return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null;
|
||||
}
|
||||
|
||||
function getRequestByMessageId(messageId) {
|
||||
if (!messageId) return null;
|
||||
const store = loadStore();
|
||||
const map = store.dmMessages.find((entry) => entry.messageId === messageId);
|
||||
if (!map) return null;
|
||||
const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null;
|
||||
return request ? { request, map } : null;
|
||||
}
|
||||
|
||||
function approveRequest(requestId, actorDiscordId) {
|
||||
const request = getPendingRequestById(requestId);
|
||||
if (!request) {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
|
||||
const approvedAt = Date.now();
|
||||
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
||||
|
||||
withStore((draft) => {
|
||||
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
||||
if (!pending || pending.status !== 'pending') {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
pending.status = 'approved';
|
||||
pending.decision = 'approved';
|
||||
pending.resolvedAt = approvedAt;
|
||||
pending.resolvedBy = actor;
|
||||
|
||||
let target =
|
||||
draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) ||
|
||||
draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip));
|
||||
|
||||
if (!target) {
|
||||
target = {
|
||||
id: `vu_${crypto.randomBytes(8).toString('hex')}`,
|
||||
cookieUserId: pending.cookieUserId,
|
||||
nickname: pending.nickname,
|
||||
knownIps: pending.ip ? [pending.ip] : [],
|
||||
createdAt: approvedAt,
|
||||
updatedAt: approvedAt,
|
||||
approvedBy: actor,
|
||||
};
|
||||
draft.verifiedUsers.push(target);
|
||||
} else {
|
||||
target.cookieUserId = pending.cookieUserId;
|
||||
target.nickname = pending.nickname;
|
||||
if (pending.ip && !target.knownIps.includes(pending.ip)) {
|
||||
target.knownIps.push(pending.ip);
|
||||
}
|
||||
target.updatedAt = approvedAt;
|
||||
target.approvedBy = actor;
|
||||
}
|
||||
});
|
||||
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const data = ensureSocketData(socket);
|
||||
if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) {
|
||||
reevaluateSocketVerification(socket);
|
||||
}
|
||||
});
|
||||
|
||||
publishEvent({
|
||||
source: 'verification',
|
||||
type: 'verification.resolved',
|
||||
payload: {
|
||||
requestId,
|
||||
decision: 'approved',
|
||||
cookieUserId: request.cookieUserId,
|
||||
nickname: request.nickname,
|
||||
resolvedBy: actor,
|
||||
resolvedAt: approvedAt,
|
||||
},
|
||||
});
|
||||
emitChange('approve', { requestId });
|
||||
}
|
||||
|
||||
function denyRequest(requestId, actorDiscordId) {
|
||||
const request = getPendingRequestById(requestId);
|
||||
if (!request) {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
|
||||
const deniedAt = Date.now();
|
||||
const actor = actorDiscordId ? String(actorDiscordId) : null;
|
||||
|
||||
withStore((draft) => {
|
||||
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
|
||||
if (!pending || pending.status !== 'pending') {
|
||||
throw new Error('Request not found or already resolved.');
|
||||
}
|
||||
pending.status = 'denied';
|
||||
pending.decision = 'denied';
|
||||
pending.resolvedAt = deniedAt;
|
||||
pending.resolvedBy = actor;
|
||||
});
|
||||
|
||||
publishEvent({
|
||||
source: 'verification',
|
||||
type: 'verification.resolved',
|
||||
payload: {
|
||||
requestId,
|
||||
decision: 'denied',
|
||||
cookieUserId: request.cookieUserId,
|
||||
nickname: request.nickname,
|
||||
resolvedBy: actor,
|
||||
resolvedAt: deniedAt,
|
||||
},
|
||||
});
|
||||
emitChange('deny', { requestId });
|
||||
}
|
||||
|
||||
function getVerificationStateForSocket(socket) {
|
||||
const identity = getIdentitySummary(socket);
|
||||
const pending = getPendingRequestForIdentity(identity.cookieUserId);
|
||||
return {
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
pendingRequestId: pending?.id || null,
|
||||
pendingRequestedAt: pending?.createdAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function isVerified(socket) {
|
||||
return Boolean(socket?.data?.isVerified);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
identifySocket(socket, {});
|
||||
|
||||
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const result = identifySocket(socket, payload || {});
|
||||
cb({ success: true, ...result });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('verification:request', (_, cb = () => {}) => {
|
||||
try {
|
||||
const request = createVerificationRequest(socket);
|
||||
cb({ success: true, requestId: request.id, status: request.status });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
identifySocket,
|
||||
getVerificationStatus,
|
||||
getIdentitySummary,
|
||||
getVerificationStateForSocket,
|
||||
createVerificationRequest,
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
denyRequest,
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
isVerified,
|
||||
reevaluateSocketVerification,
|
||||
verificationEvents,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user