From c6204e19413022c9de71460186a9654fd61e415f Mon Sep 17 00:00:00 2001 From: fretuks Date: Tue, 3 Feb 2026 11:05:06 +0100 Subject: [PATCH] Fix most current issues (overlay issues, some issues with search functionality, and clear warnings functionality) --- dashboardServer.js | 89 ++++++++++++++++++++-- public/dashboard.html | 13 +++- public/dashboard.js | 167 ++++++++++++++++++++++++++++++++++++++++-- public/styles.css | 54 ++++++++++++++ services/warnings.js | 14 ++++ 5 files changed, 322 insertions(+), 15 deletions(-) diff --git a/dashboardServer.js b/dashboardServer.js index 583319c..4895501 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -3,7 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const axios = require('axios'); const express = require('express'); -const {addWarning, getWarnings} = require('./services/warnings'); +const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const PERMISSION_BITS = { @@ -129,12 +129,10 @@ const getSession = (sessionId) => { if (!session) { return null; } - if (session.expiresAt <= Date.now()) { sessionStore.delete(sessionId); return null; } - return session; }; @@ -146,7 +144,6 @@ const ensureOAuthConfig = () => { if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) { return null; } - return { clientId: DISCORD_CLIENT_ID, clientSecret: DISCORD_CLIENT_SECRET, @@ -179,7 +176,6 @@ const buildAuthorizationUrl = (state) => { state, prompt: 'consent', }); - return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`; }; @@ -235,6 +231,24 @@ const fetchDiscordGuildRoles = async (guildId) => { return response.data; }; +const fetchDiscordGuildMembersSearch = async (guildId, query) => { + if (!DISCORD_BOT_TOKEN) { + throw new Error(BOT_TOKEN_ERROR_MESSAGE); + } + + const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/members/search`, { + headers: { + Authorization: `Bot ${DISCORD_BOT_TOKEN}`, + }, + params: { + query, + limit: 20, + }, + }); + + return response.data; +}; + const fetchDiscordBotGuilds = async () => { if (!DISCORD_BOT_TOKEN) { throw new Error(BOT_TOKEN_ERROR_MESSAGE); @@ -582,6 +596,54 @@ const createDashboardApp = () => { }, ); + app.get( + '/api/guilds/:guildId/members', + ensureSession, + ensureManageableGuild, + async (req, res) => { + const query = typeof req.query.query === 'string' ? req.query.query.trim() : ''; + if (query.length < 2) { + return res.json({members: []}); + } + try { + const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query); + const normalized = (members || []) + .map((member) => { + const user = member.user || {}; + if (!user.id) { + return null; + } + return { + id: user.id, + user: { + id: user.id, + username: user.username || '', + discriminator: user.discriminator || '', + global_name: user.global_name || '', + avatar: user.avatar || '', + }, + nick: member.nick || '', + }; + }) + .filter(Boolean); + return res.json({members: normalized}); + } catch (error) { + const status = getDiscordStatusCode(error); + if (status === 403 || status === 404) { + return res.status(409).json({ + error: 'BOT_NOT_IN_GUILD', + message: 'Bot not added to this guild.', + inviteUrl: buildInviteUrl(req.params.guildId), + }); + } + if (error.message === BOT_TOKEN_ERROR_MESSAGE) { + return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'}); + } + return sendDiscordApiError(res, error, 'Failed to search guild members'); + } + }, + ); + app.get( '/api/guilds/:guildId/commands', ensureSession, @@ -651,6 +713,23 @@ const createDashboardApp = () => { } }, ); + + app.delete( + '/api/guilds/:guildId/warnings/:userId', + ensureSession, + ensureManageableGuild, + async (req, res) => { + try { + const warnings = await clearWarnings({ + guildId: req.params.guildId, + userId: req.params.userId, + }); + return res.json({warnings}); + } catch (error) { + return res.status(500).json({error: 'Failed to clear warnings'}); + } + }, + ); return app; }; diff --git a/public/dashboard.html b/public/dashboard.html index 55da244..c6257ea 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -142,12 +142,21 @@

User lookup

-

Fetch warnings for a user ID.

+

Search members or paste an ID.

+
+ + +
+
+

No user selected.

- +
+ + +
diff --git a/public/dashboard.js b/public/dashboard.js index 4d05199..c300854 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -9,6 +9,7 @@ const state = { botInstalled: false, dirty: false, originalPermissions: null, + selectedWarningUser: null, }; const elements = { @@ -45,7 +46,11 @@ const elements = { saveBar: document.getElementById('saveBar'), botInactiveCallout: document.getElementById('botInactiveCallout'), warningsUserInput: document.getElementById('warningsUserInput'), + warningsUserSearch: document.getElementById('warningsUserSearch'), + warningsUserResults: document.getElementById('warningsUserResults'), + warningsSelectedUser: document.getElementById('warningsSelectedUser'), fetchWarningsButton: document.getElementById('fetchWarningsButton'), + clearWarningsButton: document.getElementById('clearWarningsButton'), warningsList: document.getElementById('warningsList'), warningsEmpty: document.getElementById('warningsEmpty'), warningsSpinner: document.getElementById('warningsSpinner'), @@ -85,7 +90,12 @@ const setLoadingDetails = (isLoading) => { elements.settingsPanel.hidden = true; elements.permissionsPanel.hidden = true; elements.warningsPanel.hidden = true; + return; } + const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'settings'; + elements.settingsPanel.hidden = activeTab !== 'settings'; + elements.permissionsPanel.hidden = activeTab !== 'permissions'; + elements.warningsPanel.hidden = activeTab !== 'warnings'; }; const setDirty = (isDirty) => { @@ -311,13 +321,57 @@ const renderAdminUsers = () => { }); }; +const formatMemberDisplay = (member) => { + const user = member?.user || {}; + const displayName = member?.nick || user.global_name || user.username || 'Unknown user'; + const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : ''; + return `${displayName}${tag}`; +}; + +const setSelectedWarningUser = (member) => { + state.selectedWarningUser = member || null; + if (!member) { + elements.warningsSelectedUser.textContent = 'No user selected.'; + return; + } + elements.warningsSelectedUser.textContent = `${formatMemberDisplay(member)} (${member.id})`; + elements.warningsUserInput.value = member.id; + elements.newWarningUserInput.value = member.id; +}; + +const setManualWarningUser = (userId) => { + state.selectedWarningUser = null; + if (!userId) { + elements.warningsSelectedUser.textContent = 'No user selected.'; + return; + } + elements.warningsSelectedUser.textContent = `Using manual ID: ${userId}`; +}; + +const resetWarningSelection = () => { + setSelectedWarningUser(null); + elements.warningsUserResults.innerHTML = ''; + elements.warningsUserSearch.value = ''; + elements.warningsUserInput.value = ''; + elements.newWarningUserInput.value = ''; + elements.warningsList.innerHTML = ''; + elements.warningsEmpty.hidden = true; +}; + const ensureCommandEntry = (commandName) => { if (!state.permissions.commandPermissions[commandName]) { state.permissions.commandPermissions[commandName] = { allowedRoleIds: [], allowedUserIds: [], + restricted: false, }; } + const entry = state.permissions.commandPermissions[commandName]; + entry.allowedRoleIds = Array.isArray(entry.allowedRoleIds) ? entry.allowedRoleIds : []; + entry.allowedUserIds = Array.isArray(entry.allowedUserIds) ? entry.allowedUserIds : []; + if (typeof entry.restricted !== 'boolean') { + entry.restricted = entry.allowedRoleIds.length > 0 || entry.allowedUserIds.length > 0; + } }; const renderCommands = () => { @@ -332,6 +386,7 @@ const renderCommands = () => { commands.forEach((commandName) => { ensureCommandEntry(commandName); const commandConfig = state.permissions.commandPermissions[commandName]; + const isRestricted = Boolean(commandConfig.restricted); const card = document.createElement('div'); card.className = `command-card${state.botInstalled ? '' : ' command-card--disabled'}`; @@ -345,7 +400,7 @@ const renderCommands = () => { toggleLabel.className = 'toggle'; const toggle = document.createElement('input'); toggle.type = 'checkbox'; - toggle.checked = commandConfig.allowedRoleIds.length > 0 || commandConfig.allowedUserIds.length > 0; + toggle.checked = isRestricted; toggleLabel.appendChild(toggle); toggleLabel.appendChild(document.createTextNode('Restricted')); @@ -440,7 +495,7 @@ const renderCommands = () => { clearButton.addEventListener('click', () => { commandConfig.allowedRoleIds = []; commandConfig.allowedUserIds = []; - toggle.checked = false; + commandConfig.restricted = false; setDirty(true); renderCommands(); }); @@ -452,15 +507,15 @@ const renderCommands = () => { toggle.addEventListener('change', () => { setDirty(true); + commandConfig.restricted = toggle.checked; if (!toggle.checked) { commandConfig.allowedRoleIds = []; commandConfig.allowedUserIds = []; - setDirty(true); } renderCommands(); }); - const controlsDisabled = !state.botInstalled || !toggle.checked; + const controlsDisabled = !state.botInstalled || !isRestricted; userInput.disabled = controlsDisabled; addUserButton.disabled = controlsDisabled; @@ -473,8 +528,8 @@ const renderCommands = () => { setDirty(true); }, { - disabled: !state.botInstalled || !toggle.checked, - emptyMessage: toggle.checked ? 'Select roles to allow.' : 'Allow all roles.', + disabled: !state.botInstalled || !isRestricted, + emptyMessage: isRestricted ? 'Select roles to allow.' : 'Allow all roles.', }, ); @@ -567,6 +622,7 @@ const loadGuildDetails = async () => { setLoadingDetails(true); setError(null); setDirty(false); + resetWarningSelection(); const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId); updateBotInstalled(); @@ -699,8 +755,64 @@ const switchTab = (tabName) => { elements.warningsPanel.hidden = tabName !== 'warnings'; }; +const resolveWarningUserId = (value) => { + if (value && value.trim()) { + return value.trim(); + } + return state.selectedWarningUser?.id || ''; +}; + +const renderWarningMembers = (members = []) => { + elements.warningsUserResults.innerHTML = ''; + if (!members.length) { + const empty = document.createElement('span'); + empty.className = 'muted'; + empty.textContent = 'No members found.'; + elements.warningsUserResults.appendChild(empty); + return; + } + + members.forEach((member) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `member-item${state.selectedWarningUser?.id === member.id ? ' active' : ''}`; + const meta = document.createElement('div'); + meta.className = 'member-item__meta'; + const name = document.createElement('span'); + name.className = 'member-item__name'; + name.textContent = formatMemberDisplay(member); + const id = document.createElement('span'); + id.className = 'member-item__id'; + id.textContent = member.id; + meta.appendChild(name); + meta.appendChild(id); + button.appendChild(meta); + button.addEventListener('click', () => { + setSelectedWarningUser(member); + renderWarningMembers(members); + }); + elements.warningsUserResults.appendChild(button); + }); +}; + +const searchWarningMembers = async (query) => { + if (!state.selectedGuildId || !query || query.trim().length < 2) { + elements.warningsUserResults.innerHTML = ''; + return; + } + try { + const data = await fetchDashboardJson( + `/api/guilds/${state.selectedGuildId}/members?query=${encodeURIComponent(query.trim())}`, + ); + const members = data.members || []; + renderWarningMembers(members); + } catch (error) { + handleFetchError(error, 'Failed to search members.'); + } +}; + const fetchWarnings = async () => { - const userId = elements.warningsUserInput.value.trim(); + const userId = resolveWarningUserId(elements.warningsUserInput.value); if (!userId) { setError({message: 'Enter a user ID to fetch warnings.'}); return; @@ -761,7 +873,7 @@ const updateWarningCount = () => { }; const addWarning = async () => { - const userId = elements.newWarningUserInput.value.trim(); + const userId = resolveWarningUserId(elements.newWarningUserInput.value); const reason = elements.warningReasonInput.value.trim(); if (!userId || !reason) { elements.warningStatus.textContent = 'User ID and reason are required.'; @@ -787,6 +899,29 @@ const addWarning = async () => { } }; +const clearWarnings = async () => { + const userId = resolveWarningUserId(elements.warningsUserInput.value); + if (!userId) { + setError({message: 'Select a user to clear warnings.'}); + return; + } + const proceed = window.confirm('Clear all warnings for this user?'); + if (!proceed) { + return; + } + try { + await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, { + method: 'DELETE', + }); + elements.warningsList.innerHTML = ''; + elements.warningsEmpty.hidden = false; + elements.warningStatus.textContent = 'Warnings cleared.'; + dashboardApi.showToast('Warnings cleared.', 'success'); + } catch (error) { + handleFetchError(error, 'Failed to clear warnings.'); + } +}; + const refreshBotStatus = async () => { await loadBotGuilds(); updateBotInstalled(); @@ -877,8 +1012,23 @@ const initDashboard = async () => { elements.savePermissionsButton.addEventListener('click', savePermissions); elements.discardChangesButton.addEventListener('click', discardChanges); elements.fetchWarningsButton.addEventListener('click', fetchWarnings); + elements.clearWarningsButton.addEventListener('click', clearWarnings); elements.addWarningButton.addEventListener('click', addWarning); elements.warningReasonInput.addEventListener('input', updateWarningCount); + elements.warningsUserSearch.addEventListener( + 'input', + dashboardApi.debounce((event) => { + searchWarningMembers(event.target.value); + }, 250), + ); + elements.warningsUserInput.addEventListener('input', (event) => { + setManualWarningUser(event.target.value.trim()); + }); + elements.newWarningUserInput.addEventListener('input', (event) => { + if (event.target.value.trim()) { + setManualWarningUser(event.target.value.trim()); + } + }); elements.inviteButton.addEventListener('click', openInvite); elements.refreshBotButton.addEventListener('click', refreshBotStatus); elements.clearAllCommands.addEventListener('click', () => { @@ -886,6 +1036,7 @@ const initDashboard = async () => { state.permissions.commandPermissions[commandName] = { allowedRoleIds: [], allowedUserIds: [], + restricted: false, }; }); setDirty(true); diff --git a/public/styles.css b/public/styles.css index 3ce16d5..920bbe2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -18,6 +18,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + body { margin: 0; min-height: 100vh; @@ -532,6 +536,52 @@ textarea { margin-top: 12px; } +.member-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 220px; + overflow-y: auto; + padding: 10px; + border-radius: 12px; + border: 1px solid var(--border); + background: #131826; +} + +.member-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid transparent; + background: var(--card); + cursor: pointer; +} + +.member-item__meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.member-item__name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.member-item__id { + font-size: 0.8rem; + color: var(--muted); +} + +.member-item.active { + border-color: var(--primary); +} + .toggle { display: inline-flex; align-items: center; @@ -701,6 +751,10 @@ textarea { z-index: 40; } +.modal[hidden] { + display: none; +} + .modal__content { background: var(--panel); border-radius: 16px; diff --git a/services/warnings.js b/services/warnings.js index 2c032d6..aa8ee82 100644 --- a/services/warnings.js +++ b/services/warnings.js @@ -40,7 +40,21 @@ const getWarnings = async ({ guildId, userId }) => { return record?.warnings ?? []; }; +const clearWarnings = async ({ guildId, userId }) => { + const collection = await getWarningsCollection(); + const result = await collection.findOneAndUpdate( + { guildId, userId }, + { $set: { warnings: [] } }, + { returnDocument: 'after', upsert: true }, + ); + + await updateStrikes({ guildId, userId, strikes: 0 }); + + return result.value?.warnings ?? []; +}; + module.exports = { addWarning, getWarnings, + clearWarnings, };