mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
admin reason!!
This commit is contained in:
@@ -15,4 +15,5 @@ server/package-lock.json
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
server/data/discord-guilds.json
|
server/data/discord-guilds.json
|
||||||
server/data/community-goal.json
|
server/data/community-goal.json
|
||||||
|
server/data/admin-reason.json
|
||||||
server/data
|
server/data
|
||||||
|
|||||||
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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-VteUBvT4.js"></script>
|
<script type="module" crossorigin src="/assets/index-BeAEzK0v.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D0hMW4Jw.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-G3PbGrn6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('adminReasonService');
|
||||||
|
const { isAdmin } = require('./roleService');
|
||||||
|
const { publishEvent } = require('./eventBus');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||||
|
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
|
||||||
|
const MAX_REASON_LENGTH = 240;
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function loadStore() {
|
||||||
|
if (cache) return cache;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||||
|
cache = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
logger.warn('Failed to load admin reason', err.message);
|
||||||
|
}
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStore(next) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||||
|
cache = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(input) {
|
||||||
|
if (typeof input !== 'string') return '';
|
||||||
|
return input.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminReason() {
|
||||||
|
return loadStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminReason(text, meta = {}) {
|
||||||
|
const clean = normalizeText(text);
|
||||||
|
if (!clean) {
|
||||||
|
throw new Error('Reason text required');
|
||||||
|
}
|
||||||
|
if (clean.length > MAX_REASON_LENGTH) {
|
||||||
|
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
text: clean,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
updatedBy: meta.by || null,
|
||||||
|
};
|
||||||
|
saveStore(payload);
|
||||||
|
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdminReason(meta = {}) {
|
||||||
|
const payload = {
|
||||||
|
text: null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
updatedBy: meta.by || null,
|
||||||
|
};
|
||||||
|
saveStore(payload);
|
||||||
|
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Not authorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
text == null || String(text).trim() === ''
|
||||||
|
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
|
||||||
|
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
|
||||||
|
cb({ success: true, reason: result });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAdminReason,
|
||||||
|
setAdminReason,
|
||||||
|
clearAdminReason,
|
||||||
|
MAX_REASON_LENGTH,
|
||||||
|
};
|
||||||
@@ -4,10 +4,12 @@ const io = require('../globals/io');
|
|||||||
const logger = require('../globals/logger').child('chatService');
|
const logger = require('../globals/logger').child('chatService');
|
||||||
const { publishEvent, subscribe } = require('./eventBus');
|
const { publishEvent, subscribe } = require('./eventBus');
|
||||||
const { getRole } = require('./roleService');
|
const { getRole } = require('./roleService');
|
||||||
|
const { getMode, MODES } = require('./modeManager');
|
||||||
const { describeAssignment } = require('./assignmentService');
|
const { describeAssignment } = require('./assignmentService');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { issueCommand } = require('./commandService');
|
const { issueCommand } = require('./commandService');
|
||||||
|
const { getAdminReason } = require('./adminReasonService');
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||||
const RATE_LIMIT_MAX = 5;
|
const RATE_LIMIT_MAX = 5;
|
||||||
@@ -37,6 +39,9 @@ const typingBySocket = new Map(); // socketId -> boolean
|
|||||||
const TYPING_START_NOTE = 72;
|
const TYPING_START_NOTE = 72;
|
||||||
const TYPING_SEND_NOTE = 79;
|
const TYPING_SEND_NOTE = 79;
|
||||||
const TYPING_NOTE_DURATION = 8;
|
const TYPING_NOTE_DURATION = 8;
|
||||||
|
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
|
||||||
|
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
|
||||||
|
let lastAccessNoticeAt = 0;
|
||||||
|
|
||||||
function withinRateLimit(socketId) {
|
function withinRateLimit(socketId) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -102,6 +107,7 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||||
text,
|
text,
|
||||||
tts: meta.tts || null,
|
tts: meta.tts || null,
|
||||||
|
system: Boolean(meta.system),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +201,46 @@ function normalizeTtsOptions(raw = {}) {
|
|||||||
return { speak, engine, voice, pitch };
|
return { speak, engine, voice, pitch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAccessNoticeText(mode, reasonText) {
|
||||||
|
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
|
||||||
|
const reason = reasonText ? ` Reason: ${reasonText}` : '';
|
||||||
|
return `Heads up: the server is in ${label} mode.${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSendAccessNotice(message) {
|
||||||
|
if (!message?.text || message.system) return false;
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||||
|
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
|
||||||
|
lastAccessNoticeAt = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSystemMessage(text) {
|
||||||
|
const normalized = normalizeUserText(text);
|
||||||
|
const clean = normalized.trim();
|
||||||
|
if (!clean) return null;
|
||||||
|
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
||||||
|
const message = buildMessage(null, safe, {
|
||||||
|
nickname: 'Rover Bot',
|
||||||
|
role: 'user',
|
||||||
|
fromDiscord: false,
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
broadcastMessage(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeSendAccessNotice(message) {
|
||||||
|
if (!shouldSendAccessNotice(message)) return;
|
||||||
|
const reason = getAdminReason()?.text || '';
|
||||||
|
const mode = getMode();
|
||||||
|
const notice = buildAccessNoticeText(mode, reason);
|
||||||
|
sendSystemMessage(notice);
|
||||||
|
}
|
||||||
|
|
||||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
// if (role === 'spectator') {
|
// if (role === 'spectator') {
|
||||||
@@ -233,6 +279,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
|||||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
|
maybeSendAccessNotice(message);
|
||||||
maybeSpeak(socket, message, ttsOptions);
|
maybeSpeak(socket, message, ttsOptions);
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
}
|
}
|
||||||
@@ -306,6 +353,7 @@ function sendExternalMessage({
|
|||||||
});
|
});
|
||||||
logger.info('External chat message', { roverId, nickname });
|
logger.info('External chat message', { roverId, nickname });
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
|
maybeSendAccessNotice(message);
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,4 +431,5 @@ module.exports = {
|
|||||||
sendExternalMessage,
|
sendExternalMessage,
|
||||||
sendExternalTyping,
|
sendExternalTyping,
|
||||||
buildTypingPayload,
|
buildTypingPayload,
|
||||||
|
sendSystemMessage,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { getActiveDrivers } = require('./turnService');
|
|||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { tryTriggerReplay } = require('./replayService');
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
|
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
|
||||||
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('./adminReasonService');
|
||||||
const {
|
const {
|
||||||
getGuildConfig,
|
getGuildConfig,
|
||||||
listGuildConfigs,
|
listGuildConfigs,
|
||||||
@@ -267,6 +268,7 @@ function formatHelp() {
|
|||||||
'`rs lock <id>` — lock a rover',
|
'`rs lock <id>` — lock a rover',
|
||||||
'`rs unlock <id>` — unlock a rover',
|
'`rs unlock <id>` — unlock a rover',
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
'`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 goal [text|clear]` — show or set community goal',
|
||||||
'`ts` — show time status',
|
'`ts` — show time status',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -447,8 +449,9 @@ async function handleLockCommand(message, roverId, locked) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModeCommand(message, mode) {
|
async function handleModeCommand(message, tokens = []) {
|
||||||
const next = String(mode || '').toLowerCase();
|
const next = String(tokens.shift() || '').toLowerCase();
|
||||||
|
const reasonText = tokens.join(' ').trim();
|
||||||
if (!Object.values(MODES).includes(next)) {
|
if (!Object.values(MODES).includes(next)) {
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
|
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
|
||||||
@@ -459,6 +462,9 @@ async function handleModeCommand(message, mode) {
|
|||||||
try {
|
try {
|
||||||
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
|
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
|
||||||
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
|
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
|
||||||
|
if (reasonText) {
|
||||||
|
setAdminReason(reasonText, { by: message.author?.id || null });
|
||||||
|
}
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: sanitizeMentions(`Mode set to ${next}.`),
|
content: sanitizeMentions(`Mode set to ${next}.`),
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
@@ -471,6 +477,57 @@ async function handleModeCommand(message, mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReasonCommand(message, tokens) {
|
||||||
|
const query = tokens.join(' ').trim();
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
const reason = getAdminReason();
|
||||||
|
const text = reason?.text ? reason.text : null;
|
||||||
|
await message.reply({
|
||||||
|
content: text ? `Admin mode reason: ${sanitizeMentions(text)}` : 'No admin mode reason set.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdminUser(message.author.id)) {
|
||||||
|
await message.reply({
|
||||||
|
content: 'Only admins can update the admin mode reason.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === 'clear') {
|
||||||
|
try {
|
||||||
|
clearAdminReason({ by: message.author?.id || null });
|
||||||
|
await message.reply({
|
||||||
|
content: 'Admin mode reason cleared.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Failed to clear reason: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAdminReason(query, { by: message.author?.id || null });
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Admin mode reason set: ${query}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Failed to set reason: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGoalCommand(message, tokens) {
|
async function handleGoalCommand(message, tokens) {
|
||||||
const query = tokens.join(' ').trim();
|
const query = tokens.join(' ').trim();
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
@@ -699,7 +756,7 @@ async function handleCommand(message) {
|
|||||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
||||||
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
|
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal']);
|
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason']);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isAdmin &&
|
!isAdmin &&
|
||||||
@@ -709,7 +766,8 @@ async function handleCommand(message) {
|
|||||||
action !== 'help' &&
|
action !== 'help' &&
|
||||||
action !== 'replay' &&
|
action !== 'replay' &&
|
||||||
action !== 'bridge' &&
|
action !== 'bridge' &&
|
||||||
action !== 'goal'
|
action !== 'goal' &&
|
||||||
|
action !== 'reason'
|
||||||
) {
|
) {
|
||||||
return; // ignore non-admins for privileged commands
|
return; // ignore non-admins for privileged commands
|
||||||
}
|
}
|
||||||
@@ -745,11 +803,14 @@ async function handleCommand(message) {
|
|||||||
await handleLockCommand(message, tokens[0], false);
|
await handleLockCommand(message, tokens[0], false);
|
||||||
break;
|
break;
|
||||||
case 'mode':
|
case 'mode':
|
||||||
await handleModeCommand(message, tokens[0]);
|
await handleModeCommand(message, tokens);
|
||||||
break;
|
break;
|
||||||
case 'goal':
|
case 'goal':
|
||||||
await handleGoalCommand(message, tokens);
|
await handleGoalCommand(message, tokens);
|
||||||
break;
|
break;
|
||||||
|
case 'reason':
|
||||||
|
await handleReasonCommand(message, tokens);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
await message.reply(formatHelp());
|
await message.reply(formatHelp());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ const { getReplaySources } = require('./replaySourceService');
|
|||||||
const { getHealthSnapshot } = require('./healthService');
|
const { getHealthSnapshot } = require('./healthService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
const { getCommunityGoal } = require('./communityGoalService');
|
const { getCommunityGoal } = require('./communityGoalService');
|
||||||
|
const { getAdminReason } = require('./adminReasonService');
|
||||||
const { subscribe } = require('./eventBus');
|
const { subscribe } = require('./eventBus');
|
||||||
|
|
||||||
const discordInvite = loadConfig().discord?.invite || null;
|
const config = loadConfig();
|
||||||
const kofiLink = loadConfig().kofi?.link || null;
|
const discordInvite = config.discord?.invite || null;
|
||||||
|
const kofiLink = config.kofi?.link || null;
|
||||||
|
const serverTimezone = config.timezone || null;
|
||||||
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
||||||
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||||
|
|
||||||
@@ -59,10 +62,12 @@ function buildSession(socket) {
|
|||||||
replaySources: getReplaySources(),
|
replaySources: getReplaySources(),
|
||||||
health: getHealthSnapshot(),
|
health: getHealthSnapshot(),
|
||||||
communityGoal: getCommunityGoal(),
|
communityGoal: getCommunityGoal(),
|
||||||
|
adminReason: getAdminReason(),
|
||||||
users,
|
users,
|
||||||
discord: {
|
discord: {
|
||||||
invite: discordInvite,
|
invite: discordInvite,
|
||||||
},
|
},
|
||||||
|
timezone: serverTimezone,
|
||||||
kofi: {
|
kofi: {
|
||||||
link: kofiLink,
|
link: kofiLink,
|
||||||
},
|
},
|
||||||
@@ -217,6 +222,11 @@ subscribe('communityGoal.updated', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
subscribe('adminReason.updated', () => {
|
||||||
|
logger.info('Admin reason updated; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
// sync all sockets 20 seconds
|
// sync all sockets 20 seconds
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
logger.info('Periodic session sync for all clients');
|
logger.info('Periodic session sync for all clients');
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ const MODES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const { session, lockRover, setMode, requestControl, setCommunityGoal, adminLogs } = useSession();
|
const { session, lockRover, setMode, requestControl, setCommunityGoal, setAdminReason, adminLogs } = useSession();
|
||||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
const health = session?.health || null;
|
const health = session?.health || null;
|
||||||
const currentGoal = session?.communityGoal?.text || '';
|
const currentGoal = session?.communityGoal?.text || '';
|
||||||
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
||||||
const [goalDraft, setGoalDraft] = useState(currentGoal);
|
const [goalDraft, setGoalDraft] = useState(currentGoal);
|
||||||
|
const currentReason = session?.adminReason?.text || '';
|
||||||
|
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||||
|
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session?.role === 'admin' ||
|
session?.role === 'admin' ||
|
||||||
@@ -67,10 +70,30 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReasonSave = async () => {
|
||||||
|
try {
|
||||||
|
await setAdminReason(reasonDraft);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReasonClear = async () => {
|
||||||
|
try {
|
||||||
|
await setAdminReason(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGoalDraft(currentGoal);
|
setGoalDraft(currentGoal);
|
||||||
}, [currentGoal]);
|
}, [currentGoal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReasonDraft(currentReason);
|
||||||
|
}, [currentReason]);
|
||||||
|
|
||||||
const lockMap = useMemo(() => {
|
const lockMap = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
roster.forEach((rover) => {
|
roster.forEach((rover) => {
|
||||||
@@ -116,6 +139,28 @@ export default function AdminPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Admin mode reason</span>
|
||||||
|
{reasonUpdatedAt ? (
|
||||||
|
<span>Updated {new Date(reasonUpdatedAt).toLocaleString()}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={reasonDraft}
|
||||||
|
onChange={(event) => setReasonDraft(event.target.value)}
|
||||||
|
placeholder="Set an admin mode reason"
|
||||||
|
className="field-input text-sm min-h-[3.5rem]"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-0.5 text-xs">
|
||||||
|
<button type="button" onClick={handleReasonSave} className="button-dark">
|
||||||
|
Set reason
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleReasonClear} className="button-danger">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RoverRoster
|
<RoverRoster
|
||||||
roster={roster}
|
roster={roster}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import AuthPanel from './AuthPanel.jsx';
|
import AuthPanel from './AuthPanel.jsx';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
@@ -18,8 +19,8 @@ function getModeDetails(mode = 'admin') {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: 'Admin mode active',
|
title: 'Admin mode active',
|
||||||
description:
|
// description:
|
||||||
'The server is currently in admin mode. Only admins can access the interface.',
|
// 'The server is currently in admin mode. Only admins can access the interface.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +28,30 @@ export default function ModeGateOverlay() {
|
|||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const mode = session?.mode;
|
const mode = session?.mode;
|
||||||
const role = session?.role;
|
const role = session?.role;
|
||||||
|
const reason = session?.adminReason?.text || '';
|
||||||
|
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||||
|
const timezone = session?.timezone || 'UTC';
|
||||||
const restricted = RESTRICTED_MODES.has(mode);
|
const restricted = RESTRICTED_MODES.has(mode);
|
||||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
const serverTime = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(now);
|
||||||
|
} catch (err) {
|
||||||
|
return now.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
}, [now, timezone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!restricted || privileged) {
|
if (!restricted || privileged) {
|
||||||
return null;
|
return null;
|
||||||
@@ -43,13 +66,25 @@ export default function ModeGateOverlay() {
|
|||||||
<p className="text-lg font-semibold">{details.title}</p>
|
<p className="text-lg font-semibold">{details.title}</p>
|
||||||
<p className="text-sm text-slate-300">{details.description}</p>
|
<p className="text-sm text-slate-300">{details.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="surface-muted space-y-0.5">
|
||||||
|
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
|
||||||
|
<p className="text-lg font-semibold text-slate-100">
|
||||||
|
{reason ? reason : 'No reason set.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||||
|
{reasonUpdatedAt ? (
|
||||||
|
<p className="text-[0.7rem] text-slate-500">
|
||||||
|
Updated {new Date(reasonUpdatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="surface-muted">
|
<div className="surface-muted">
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className='w-full justify-center items-center'>
|
<div className='w-full justify-center items-center'>
|
||||||
<DiscordInviteButton text='Join our Discord server for updates!'/>
|
<DiscordInviteButton text='Join our Discord server for updates!'/>
|
||||||
</div>
|
</div>
|
||||||
You can use the chat from here though :3
|
You can still use the chat while the server is locked:
|
||||||
{/* set max height of this box */}
|
{/* set max height of this box */}
|
||||||
<div className='max-h-80 overflow-y-auto'>
|
<div className='max-h-80 overflow-y-auto'>
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const SessionContext = createContext({
|
|||||||
setNickname: async () => {},
|
setNickname: async () => {},
|
||||||
triggerReplay: async () => {},
|
triggerReplay: async () => {},
|
||||||
setCommunityGoal: async () => {},
|
setCommunityGoal: async () => {},
|
||||||
|
setAdminReason: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
function useAckEmitter(socket) {
|
function useAckEmitter(socket) {
|
||||||
@@ -115,6 +116,7 @@ export function SessionProvider({ children }) {
|
|||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||||
|
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
||||||
pushAlert: (alert) =>
|
pushAlert: (alert) =>
|
||||||
setAlerts((prev) => [
|
setAlerts((prev) => [
|
||||||
...prev.slice(-49),
|
...prev.slice(-49),
|
||||||
|
|||||||
Reference in New Issue
Block a user