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,
};