diff --git a/commands.json b/commands.json
new file mode 100644
index 0000000..379faaa
--- /dev/null
+++ b/commands.json
@@ -0,0 +1,8 @@
+[
+ "warn",
+ "warnings",
+ "ban",
+ "kick",
+ "mute",
+ "unmute"
+]
diff --git a/dashboardServer.js b/dashboardServer.js
index c094d01..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 = {
@@ -38,6 +38,10 @@ const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN ||
config.discordBotToken ||
config.token;
+const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
+ process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
+ 10,
+);
const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl ||
@@ -56,8 +60,17 @@ console.log('[oauth config]', {
});
const DISCORD_API_BASE = 'https://discord.com/api';
+const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
+const COMMANDS_PATH = path.join(__dirname, 'commands.json');
+const GUILD_CACHE_TTL_MS = 5 * 60 * 1000;
+const BOT_GUILD_CACHE_TTL_MS = 60 * 1000;
+const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.';
const sessionStore = new Map();
+const botGuildCache = {
+ guildIds: null,
+ fetchedAt: 0,
+};
const parseCookies = (headerValue) => {
const cookies = {};
@@ -116,12 +129,10 @@ const getSession = (sessionId) => {
if (!session) {
return null;
}
-
if (session.expiresAt <= Date.now()) {
sessionStore.delete(sessionId);
return null;
}
-
return session;
};
@@ -133,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,
@@ -141,6 +151,22 @@ const ensureOAuthConfig = () => {
};
};
+const getDiscordStatusCode = (error) => {
+ const status = error?.response?.status;
+ return typeof status === 'number' ? status : null;
+};
+
+const sendDiscordApiError = (res, error, fallbackMessage) => {
+ const status = getDiscordStatusCode(error);
+ if (status === 401 || status === 403) {
+ return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'});
+ }
+ if (status === 429) {
+ return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'});
+ }
+ return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage});
+};
+
const buildAuthorizationUrl = (state) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
@@ -150,8 +176,7 @@ const buildAuthorizationUrl = (state) => {
state,
prompt: 'consent',
});
-
- return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
+ return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const fetchDiscordAccessToken = async (code) => {
@@ -163,7 +188,7 @@ const fetchDiscordAccessToken = async (code) => {
redirect_uri: DISCORD_REDIRECT_URI,
});
- const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params, {
+ const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
@@ -194,7 +219,7 @@ const fetchDiscordUserGuilds = async (accessToken) => {
const fetchDiscordGuildRoles = async (guildId) => {
if (!DISCORD_BOT_TOKEN) {
- throw new Error('Missing Discord bot token.');
+ throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
@@ -206,6 +231,94 @@ 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);
+ }
+
+ const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
+ headers: {
+ Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
+ },
+ });
+
+ return response.data;
+};
+
+const getCachedBotGuildIds = () => {
+ if (!Array.isArray(botGuildCache.guildIds)) {
+ return null;
+ }
+ if (!botGuildCache.fetchedAt) {
+ return null;
+ }
+ if (Date.now() - botGuildCache.fetchedAt > BOT_GUILD_CACHE_TTL_MS) {
+ return null;
+ }
+ return botGuildCache.guildIds;
+};
+
+const setCachedBotGuildIds = (guildIds) => {
+ botGuildCache.guildIds = guildIds;
+ botGuildCache.fetchedAt = Date.now();
+};
+
+const buildInviteUrl = (guildId) => {
+ if (!DISCORD_CLIENT_ID) {
+ return null;
+ }
+ const permissions = Number.isNaN(DASHBOARD_INVITE_PERMISSIONS) ? 0 : DASHBOARD_INVITE_PERMISSIONS;
+ const params = new URLSearchParams({
+ client_id: DISCORD_CLIENT_ID,
+ scope: 'bot applications.commands',
+ permissions: String(permissions),
+ disable_guild_select: 'true',
+ });
+ if (guildId) {
+ params.set('guild_id', guildId);
+ }
+ return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
+};
+
+const cacheSessionGuilds = (session, guilds) => {
+ session.guilds = guilds;
+ session.guildsFetchedAt = Date.now();
+};
+
+const getCachedGuilds = (session) => {
+ if (!Array.isArray(session.guilds)) {
+ return null;
+ }
+
+ if (!session.guildsFetchedAt) {
+ return null;
+ }
+
+ if (Date.now() - session.guildsFetchedAt > GUILD_CACHE_TTL_MS) {
+ return null;
+ }
+
+ return session.guilds;
+};
+
const parsePermissionBits = (permissionValue) => {
if (!permissionValue) {
return 0n;
@@ -218,6 +331,34 @@ const parsePermissionBits = (permissionValue) => {
}
};
+const isHttpsRequest = (req) => {
+ if (req.secure) {
+ return true;
+ }
+
+ const forwardedProto = req.headers['x-forwarded-proto'];
+ if (typeof forwardedProto === 'string' && forwardedProto.split(',')[0].trim() === 'https') {
+ return true;
+ }
+
+ return Boolean(config.cookieSecure);
+};
+
+const loadCommands = () => {
+ if (!fs.existsSync(COMMANDS_PATH)) {
+ return [];
+ }
+
+ try {
+ const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8');
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
+ } catch (error) {
+ console.error('Failed to load commands.json', {message: error.message});
+ return [];
+ }
+};
+
const canManageGuild = (guild) => {
const permissions = parsePermissionBits(guild.permissions);
return (
@@ -245,7 +386,11 @@ const ensureSession = (req, res, next) => {
const ensureManageableGuild = async (req, res, next) => {
try {
- const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
+ let guilds = getCachedGuilds(req.session);
+ if (!guilds) {
+ guilds = await fetchDiscordUserGuilds(req.session.accessToken);
+ cacheSessionGuilds(req.session, guilds);
+ }
const guild = guilds.find((entry) => entry.id === req.params.guildId);
if (!guild) {
return res.status(404).json({error: 'Guild not found'});
@@ -257,7 +402,7 @@ const ensureManageableGuild = async (req, res, next) => {
req.guild = guild;
return next();
} catch (error) {
- return res.status(502).json({error: 'Failed to fetch guilds'});
+ return sendDiscordApiError(res, error, 'Failed to fetch guilds');
}
};
@@ -276,7 +421,7 @@ const createDashboardApp = () => {
setCookie(res, 'oauth_state', state, {
httpOnly: true,
sameSite: 'Lax',
- secure: req.secure,
+ secure: isHttpsRequest(req),
maxAge: 300,
});
@@ -298,7 +443,7 @@ const createDashboardApp = () => {
const tokenData = await fetchDiscordAccessToken(code);
const user = await fetchDiscordUser(tokenData.access_token);
- const {sessionId, expiresAt} = createSession({
+ const session = createSession({
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type,
@@ -306,11 +451,11 @@ const createDashboardApp = () => {
user,
});
- setCookie(res, 'dashboard_session', sessionId, {
+ setCookie(res, 'dashboard_session', session.sessionId, {
httpOnly: true,
sameSite: 'Lax',
- secure: req.secure,
- maxAge: Math.floor((expiresAt - Date.now()) / 1000),
+ secure: isHttpsRequest(req),
+ maxAge: Math.floor((session.expiresAt - Date.now()) / 1000),
});
clearCookie(res, 'oauth_state');
@@ -319,7 +464,7 @@ const createDashboardApp = () => {
}
if (req.accepts('html')) {
- return res.redirect('/');
+ return res.redirect('/dashboard.html');
}
return res.status(200).json({user});
@@ -327,12 +472,12 @@ const createDashboardApp = () => {
console.error('Discord OAuth failed', {
message: error.message,
status: error.response?.status,
- data: error.response?.data,
+ data: error.response?.data?.error || error.response?.data?.error_description,
});
return res.status(502).json({
error: 'Failed to authenticate with Discord',
- details: error.response?.data || error.message,
+ details: error.response?.data?.error_description || error.message,
});
}
});
@@ -343,16 +488,50 @@ const createDashboardApp = () => {
return res.status(204).send();
});
+ app.get('/api/me', ensureSession, (req, res) => {
+ return res.json({
+ user: req.session.user,
+ expiresAt: req.session.expiresAt,
+ });
+ });
+
app.get('/api/guilds', ensureSession, async (req, res) => {
try {
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
+ cacheSessionGuilds(req.session, guilds);
const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds});
} catch (error) {
- return res.status(502).json({error: 'Failed to fetch guilds'});
+ return sendDiscordApiError(res, error, 'Failed to fetch guilds');
}
});
+ app.get('/api/bot/guilds', ensureSession, async (req, res) => {
+ try {
+ const cached = getCachedBotGuildIds();
+ if (cached) {
+ return res.json({guildIds: cached});
+ }
+ const guilds = await fetchDiscordBotGuilds();
+ const guildIds = Array.isArray(guilds) ? guilds.map((guild) => guild.id) : [];
+ setCachedBotGuildIds(guildIds);
+ return res.json({guildIds});
+ } catch (error) {
+ 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 fetch bot guilds');
+ }
+ });
+
+ app.get('/api/invite-url', ensureSession, (req, res) => {
+ if (!DISCORD_CLIENT_ID) {
+ return res.status(500).json({error: 'MISSING_CLIENT_ID', message: 'Discord client ID not configured.'});
+ }
+ const inviteUrl = buildInviteUrl(req.query.guildId);
+ return res.json({inviteUrl});
+ });
+
app.get(
'/api/guilds/:guildId/permissions',
ensureSession,
@@ -362,7 +541,11 @@ const createDashboardApp = () => {
const permissions = await getGuildPermissions(req.params.guildId);
return res.json({permissions});
} catch (error) {
- return res.status(500).json({error: 'Failed to fetch permissions'});
+ console.error('Failed to fetch guild permissions', {
+ guildId: req.params.guildId,
+ message: error.message,
+ });
+ return res.status(503).json({error: 'Permissions store unavailable'});
}
},
);
@@ -379,7 +562,11 @@ const createDashboardApp = () => {
});
return res.json({permissions});
} catch (error) {
- return res.status(500).json({error: 'Failed to update permissions'});
+ console.error('Failed to update guild permissions', {
+ guildId: req.params.guildId,
+ message: error.message,
+ });
+ return res.status(503).json({error: 'Permissions store unavailable'});
}
},
);
@@ -393,11 +580,80 @@ const createDashboardApp = () => {
const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles});
} catch (error) {
- return res.status(500).json({error: 'Failed to fetch roles'});
+ 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 fetch roles');
}
},
);
+ 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,
+ ensureManageableGuild,
+ (req, res) => {
+ const commands = loadCommands();
+ return res.json({commands});
+ },
+ );
+
app.get(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
@@ -408,7 +664,11 @@ const createDashboardApp = () => {
guildId: req.params.guildId,
userId: req.params.userId,
});
- return res.json({warnings});
+ const normalizedWarnings = warnings.map((warning) => ({
+ ...warning,
+ createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
+ }));
+ return res.json({warnings: normalizedWarnings});
} catch (error) {
return res.status(500).json({error: 'Failed to fetch warnings'});
}
@@ -431,12 +691,45 @@ const createDashboardApp = () => {
moderatorId: req.session.user?.id,
reason,
});
- return res.status(201).json(result);
+ const warnings = (result.warnings || []).map((warning) => ({
+ ...warning,
+ createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
+ }));
+ const warningEntry = result.warningEntry
+ ? {
+ ...result.warningEntry,
+ createdAt: result.warningEntry.createdAt
+ ? new Date(result.warningEntry.createdAt).toISOString()
+ : null,
+ }
+ : null;
+ return res.status(201).json({
+ ...result,
+ warnings,
+ warningEntry,
+ });
} catch (error) {
return res.status(500).json({error: 'Failed to add warning'});
}
},
);
+
+ 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/deploy-commands.js b/deploy-commands.js
index de5dd15..d7edebb 100644
--- a/deploy-commands.js
+++ b/deploy-commands.js
@@ -2,7 +2,6 @@ const path = require('node:path');
const { REST, Routes } = require('discord.js');
const { clientId, token } = require('./config.json');
const { loadCommandModules } = require('./loaders/commands');
-
const commandsPath = path.join(__dirname, 'commands');
const { commands, invalidCommands } = loadCommandModules(commandsPath);
const commandsData = commands.map((command) => command.data.toJSON());
@@ -11,23 +10,17 @@ for (const filePath of invalidCommands) {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
-// Construct and prepare an instance of the REST module
const rest = new REST().setToken(token);
-// and deploy your commands!
(async () => {
try {
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
-
- // The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put(
Routes.applicationCommands(clientId),
{ body: commandsData },
);
-
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) {
- // And of course, make sure you catch and log any errors!
console.error(error);
}
})();
diff --git a/package-lock.json b/package-lock.json
index 76fde88..baba6d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,8 +11,13 @@
"dependencies": {
"axios": "^1.7.7",
"better-sqlite3": "^11.3.0",
+ "connect-mongo": "^5.1.0",
+ "cookie-parser": "^1.4.6",
+ "cors": "^2.8.5",
"discord.js": "^14.17.3",
"express": "^4.21.0",
+ "express-rate-limit": "^7.4.0",
+ "express-session": "^1.18.1",
"moment": "^2.30.1",
"mongodb": "^6.12.0",
"openai": "^4.67.3",
@@ -393,6 +398,18 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -468,6 +485,12 @@
"readable-stream": "^3.4.0"
}
},
+ "node_modules/bn.js": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
+ "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
+ "license": "MIT"
+ },
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -669,6 +692,23 @@
"license": "MIT",
"optional": true
},
+ "node_modules/connect-mongo": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz",
+ "integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.1",
+ "kruptein": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=12.9.0"
+ },
+ "peerDependencies": {
+ "express-session": "^1.17.1",
+ "mongodb": ">= 5.1.0 < 7"
+ }
+ },
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -706,18 +746,56 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
- "optional": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1001,6 +1079,74 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-session": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
+ "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "~0.7.2",
+ "cookie-signature": "~1.0.7",
+ "debug": "~2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "~5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/express-session/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express-session/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1507,6 +1653,18 @@
"license": "MIT",
"optional": true
},
+ "node_modules/kruptein": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.1.7.tgz",
+ "integrity": "sha512-Wk3WFZdGtjnTBGlC12SiBsEh51+6lsW7sSRm+MzRVRc3WRPrUQGu4+/n3YQjSPp8x1AJJxPxgLsekFXLiBN2PQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asn1.js": "^5.4.1"
+ },
+ "engines": {
+ "node": ">8"
+ }
+ },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1644,6 +1802,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2014,6 +2178,15 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
@@ -2038,6 +2211,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -2229,6 +2411,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -2783,6 +2974,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/undici": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
diff --git a/public/app.js b/public/app.js
index 3f50efa..1c34702 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,223 +1,159 @@
-const state = {
- guilds: [],
- selectedGuildId: null,
+const createFetchError = async (response) => {
+ let payload = {};
+ try {
+ payload = await response.json();
+ } catch (error) {
+ payload = {};
+ }
+ const message = payload.message || payload.error || `Request failed with status ${response.status}`;
+ const error = new Error(message);
+ error.status = response.status;
+ error.code = payload.error;
+ error.details = payload.details;
+ error.payload = payload;
+ return error;
};
-const elements = {
- loginButton: document.getElementById('loginButton'),
- logoutButton: document.getElementById('logoutButton'),
- sessionStatus: document.getElementById('sessionStatus'),
- guildCard: document.getElementById('guildCard'),
- warningsCard: document.getElementById('warningsCard'),
- guildSelect: document.getElementById('guildSelect'),
- permissionsEditor: document.getElementById('permissionsEditor'),
- updatePermissionsButton: document.getElementById('updatePermissionsButton'),
- rolesList: document.getElementById('rolesList'),
- warningUserId: document.getElementById('warningUserId'),
- loadWarningsButton: document.getElementById('loadWarningsButton'),
- warningsList: document.getElementById('warningsList'),
- newWarningUserId: document.getElementById('newWarningUserId'),
- warningReason: document.getElementById('warningReason'),
- addWarningButton: document.getElementById('addWarningButton'),
- warningResult: document.getElementById('warningResult'),
-};
-
-const setStatus = (message, isAuthed) => {
- elements.sessionStatus.textContent = message;
- elements.logoutButton.hidden = !isAuthed;
- elements.guildCard.hidden = !isAuthed;
- elements.warningsCard.hidden = !isAuthed;
-};
-
-const fetchJson = async (url, options) => {
+const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
+ ...(options.headers || {}),
},
...options,
});
if (!response.ok) {
- const body = await response.json().catch(() => ({}));
- const message = body.error || `Request failed with status ${response.status}`;
- throw new Error(message);
+ throw await createFetchError(response);
}
return response.json().catch(() => ({}));
};
-const loadGuilds = async () => {
- const data = await fetchJson('/api/guilds');
- state.guilds = data.guilds || [];
- elements.guildSelect.innerHTML = '';
-
- state.guilds.forEach((guild) => {
- const option = document.createElement('option');
- option.value = guild.id;
- option.textContent = guild.name;
- elements.guildSelect.appendChild(option);
- });
-
- if (state.guilds.length > 0) {
- state.selectedGuildId = state.guilds[0].id;
- elements.guildSelect.value = state.selectedGuildId;
- await loadGuildDetails();
- }
-};
-
-const renderRoles = (roles) => {
- elements.rolesList.innerHTML = '';
- roles.forEach((role) => {
- const item = document.createElement('li');
- item.textContent = `${role.name} (${role.id})`;
- elements.rolesList.appendChild(item);
- });
-
- if (roles.length === 0) {
- const item = document.createElement('li');
- item.textContent = 'No roles returned.';
- elements.rolesList.appendChild(item);
- }
-};
-
-const loadGuildDetails = async () => {
- if (!state.selectedGuildId) {
+const showToast = (message, type = 'success') => {
+ const container = document.getElementById('toastContainer');
+ if (!container) {
return;
}
-
- const [permissionsData, rolesData] = await Promise.all([
- fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`),
- fetchJson(`/api/guilds/${state.selectedGuildId}/roles`),
- ]);
-
- elements.permissionsEditor.value = JSON.stringify(permissionsData.permissions || {}, null, 2);
- renderRoles(rolesData.roles || []);
+ const toast = document.createElement('div');
+ toast.className = `toast toast--${type}`;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(() => {
+ toast.classList.add('toast--visible');
+ }, 10);
+ setTimeout(() => {
+ toast.classList.remove('toast--visible');
+ setTimeout(() => toast.remove(), 200);
+ }, 3200);
};
-const updatePermissions = async () => {
- if (!state.selectedGuildId) {
+const setErrorBanner = (bannerEl, message, details) => {
+ if (!bannerEl) {
return;
}
-
- const rawValue = elements.permissionsEditor.value.trim();
- if (!rawValue) {
- return;
+ const messageEl = bannerEl.querySelector('[data-role="error-message"]');
+ const copyButton = bannerEl.querySelector('[data-role="error-copy"]');
+ if (messageEl) {
+ messageEl.textContent = message;
}
-
- let payload;
- try {
- payload = JSON.parse(rawValue);
- } catch (error) {
- elements.permissionsEditor.focus();
- throw new Error('Permissions JSON is invalid.');
+ if (copyButton) {
+ copyButton.onclick = async () => {
+ const payload = details || {message};
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
+ showToast('Error details copied.', 'success');
+ } catch (error) {
+ showToast('Unable to copy details.', 'error');
+ }
+ };
}
-
- const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
- method: 'PATCH',
- body: JSON.stringify(payload),
- });
-
- elements.permissionsEditor.value = JSON.stringify(data.permissions || {}, null, 2);
+ bannerEl.hidden = false;
};
-const loadWarnings = async (userId) => {
- if (!state.selectedGuildId || !userId) {
+const clearErrorBanner = (bannerEl) => {
+ if (!bannerEl) {
return;
}
-
- const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
- elements.warningsList.innerHTML = '';
-
- const warnings = data.warnings || [];
- if (warnings.length === 0) {
- const item = document.createElement('li');
- item.textContent = 'No warnings found.';
- elements.warningsList.appendChild(item);
- return;
- }
-
- warnings.forEach((warning) => {
- const item = document.createElement('li');
- const issuedAt = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
- item.textContent = `${warning.reason} — ${issuedAt}`;
- elements.warningsList.appendChild(item);
- });
+ bannerEl.hidden = true;
};
-const addWarning = async () => {
- if (!state.selectedGuildId) {
- return;
+const formatUserDisplay = (user) => {
+ if (!user) {
+ return 'Not signed in';
}
-
- const userId = elements.newWarningUserId.value.trim();
- const reason = elements.warningReason.value.trim();
-
- if (!userId || !reason) {
- elements.warningResult.textContent = 'User ID and reason are required.';
- return;
- }
-
- const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
- method: 'POST',
- body: JSON.stringify({ reason }),
- });
-
- elements.warningResult.textContent = `Warning added: ${data.warning?.reason || reason}`;
- elements.warningReason.value = '';
+ const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : '';
+ return `${user.username}${tag}`;
};
-const showError = (message) => {
- elements.warningResult.textContent = message;
-};
-
-const init = async () => {
- elements.loginButton.addEventListener('click', () => {
- window.location.href = '/auth/discord';
- });
-
- elements.logoutButton.addEventListener('click', async () => {
- await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
- setStatus('Logged out. Sign in to manage guilds.', false);
- });
-
- elements.guildSelect.addEventListener('change', async (event) => {
- state.selectedGuildId = event.target.value;
- await loadGuildDetails();
- });
-
- elements.updatePermissionsButton.addEventListener('click', async () => {
- elements.warningResult.textContent = '';
- try {
- await updatePermissions();
- elements.warningResult.textContent = 'Permissions updated successfully.';
- } catch (error) {
- showError(error.message);
+const debounce = (callback, wait = 300) => {
+ let timeoutId;
+ return (...args) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
}
- });
+ timeoutId = setTimeout(() => callback(...args), wait);
+ };
+};
- elements.loadWarningsButton.addEventListener('click', async () => {
- try {
- await loadWarnings(elements.warningUserId.value.trim());
- } catch (error) {
- showError(error.message);
- }
- });
+const initIndexPage = async () => {
+ const sessionStatus = document.getElementById('sessionStatus');
+ const loginButton = document.getElementById('loginButton');
+ const dashboardButton = document.getElementById('dashboardButton');
+ const logoutButton = document.getElementById('logoutButton');
+ const banner = document.getElementById('banner');
- elements.addWarningButton.addEventListener('click', async () => {
- try {
- await addWarning();
- } catch (error) {
- showError(error.message);
- }
- });
+ if (!sessionStatus || !loginButton) {
+ return;
+ }
try {
- await loadGuilds();
- setStatus('Authenticated. Choose a guild to get started.', true);
+ const data = await fetchJson('/api/me');
+ sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
+ loginButton.hidden = true;
+ dashboardButton.hidden = false;
+ logoutButton.hidden = false;
+ if (banner) {
+ banner.textContent = '';
+ banner.style.display = 'none';
+ }
} catch (error) {
- setStatus('Sign in with Discord to manage your guilds.', false);
+ sessionStatus.textContent = 'You are not signed in yet.';
+ loginButton.hidden = false;
+ dashboardButton.hidden = true;
+ logoutButton.hidden = true;
+ if (banner) {
+ banner.textContent = 'Log in with Discord to access the dashboard.';
+ banner.style.display = 'block';
+ }
}
+
+ logoutButton?.addEventListener('click', async () => {
+ try {
+ await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
+ sessionStatus.textContent = 'Logged out successfully.';
+ loginButton.hidden = false;
+ dashboardButton.hidden = true;
+ logoutButton.hidden = true;
+ } catch (error) {
+ if (banner) {
+ banner.textContent = error.message || 'Failed to log out.';
+ banner.style.display = 'block';
+ }
+ }
+ });
};
-init();
+document.addEventListener('DOMContentLoaded', () => {
+ initIndexPage();
+});
+
+window.dashboardApi = {
+ fetchJson,
+ showToast,
+ setErrorBanner,
+ clearErrorBanner,
+ formatUserDisplay,
+ debounce,
+};
diff --git a/public/dashboard.html b/public/dashboard.html
new file mode 100644
index 0000000..c6257ea
--- /dev/null
+++ b/public/dashboard.html
@@ -0,0 +1,217 @@
+
+
+
+
+
+ Discord Bot Dashboard
+
+
+
+
+
+
+
DB
+
+
Discord Bot Dashboard
+
Loading session...
+
+
+
+
+
+
+
+
+
Something went wrong.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Bot not added yet.
+
The bot isn’t in this server yet. Invite it to configure settings.
+
You can review saved settings, but changes will stay inactive until the bot joins.
+
+
+
+ Admin roles
+ ?
+
+
+
+
+
+ Admin user IDs
+ ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add command
+ ?
+
+
+
+
+
+
+
+
+ No commands configured yet. Add one or invite the bot to sync commands.
+
+
+
+
+
+
+
User lookup
+
Search members or paste an ID.
+
+
+
+
+
+
No user selected.
+
+
+
+
+
+
+
+
+
+
+
+
Warnings
+
No warnings found.
+
+
+
Add warning
+
+
+
+
+
+
+
+
+ 0/200
+
+
+
+
+
+
+
+
+
+
Unsaved changes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Copy permissions
+
Select another guild to copy permissions from.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/dashboard.js b/public/dashboard.js
new file mode 100644
index 0000000..c300854
--- /dev/null
+++ b/public/dashboard.js
@@ -0,0 +1,1069 @@
+const state = {
+ guilds: [],
+ filteredGuilds: [],
+ selectedGuildId: null,
+ roles: [],
+ permissions: null,
+ commands: [],
+ botGuildIds: new Set(),
+ botInstalled: false,
+ dirty: false,
+ originalPermissions: null,
+ selectedWarningUser: null,
+};
+
+const elements = {
+ errorBanner: document.getElementById('errorBanner'),
+ userDisplay: document.getElementById('userDisplay'),
+ sessionExpiry: document.getElementById('sessionExpiry'),
+ logoutButton: document.getElementById('logoutButton'),
+ guildList: document.getElementById('guildList'),
+ guildListSkeleton: document.getElementById('guildListSkeleton'),
+ guildEmpty: document.getElementById('guildEmpty'),
+ guildSearch: document.getElementById('guildSearch'),
+ guildHeaderIcon: document.getElementById('guildHeaderIcon'),
+ guildHeaderName: document.getElementById('guildHeaderName'),
+ guildStatusBadge: document.getElementById('guildStatusBadge'),
+ guildStatusNote: document.getElementById('guildStatusNote'),
+ inviteButton: document.getElementById('inviteButton'),
+ refreshBotButton: document.getElementById('refreshBotButton'),
+ tabs: document.querySelectorAll('.tab'),
+ settingsPanel: document.getElementById('settingsPanel'),
+ permissionsPanel: document.getElementById('permissionsPanel'),
+ warningsPanel: document.getElementById('warningsPanel'),
+ adminRolesPicker: document.getElementById('adminRolesPicker'),
+ adminUserInput: document.getElementById('adminUserInput'),
+ addAdminUserButton: document.getElementById('addAdminUserButton'),
+ adminUserChips: document.getElementById('adminUserChips'),
+ newCommandInput: document.getElementById('newCommandInput'),
+ addCommandButton: document.getElementById('addCommandButton'),
+ commandsList: document.getElementById('commandsList'),
+ commandsEmpty: document.getElementById('commandsEmpty'),
+ clearAllCommands: document.getElementById('clearAllCommands'),
+ copyFromGuild: document.getElementById('copyFromGuild'),
+ savePermissionsButton: document.getElementById('savePermissionsButton'),
+ discardChangesButton: document.getElementById('discardChangesButton'),
+ 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'),
+ newWarningUserInput: document.getElementById('newWarningUserInput'),
+ warningReasonInput: document.getElementById('warningReasonInput'),
+ warningCharCount: document.getElementById('warningCharCount'),
+ addWarningButton: document.getElementById('addWarningButton'),
+ warningStatus: document.getElementById('warningStatus'),
+ contentSkeleton: document.getElementById('contentSkeleton'),
+ copyModal: document.getElementById('copyModal'),
+ copyGuildSelect: document.getElementById('copyGuildSelect'),
+ cancelCopyButton: document.getElementById('cancelCopyButton'),
+ confirmCopyButton: document.getElementById('confirmCopyButton'),
+};
+
+const dashboardApi = window.dashboardApi;
+const fetchDashboardJson = dashboardApi.fetchJson;
+
+const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
+
+const clonePermissions = (permissions) => JSON.parse(JSON.stringify(permissions || {}));
+
+const normalizePermissions = (permissions = {}) => ({
+ adminRoleIds: Array.isArray(permissions.adminRoleIds) ? permissions.adminRoleIds : [],
+ adminUserIds: Array.isArray(permissions.adminUserIds) ? permissions.adminUserIds : [],
+ commandPermissions: permissions.commandPermissions || {},
+});
+
+const setLoadingGuilds = (isLoading) => {
+ elements.guildList.hidden = isLoading;
+ elements.guildListSkeleton.hidden = !isLoading;
+};
+
+const setLoadingDetails = (isLoading) => {
+ elements.contentSkeleton.hidden = !isLoading;
+ if (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) => {
+ state.dirty = isDirty;
+ elements.saveBar.hidden = !isDirty;
+};
+
+const parseIdList = (value) => {
+ if (!value) {
+ return [];
+ }
+ return Array.from(
+ new Set(
+ value
+ .split(/[\s,\n]+/)
+ .map((entry) => entry.trim())
+ .filter(Boolean),
+ ),
+ );
+};
+
+const setError = (error) => {
+ if (!error) {
+ dashboardApi.clearErrorBanner(elements.errorBanner);
+ return;
+ }
+ dashboardApi.setErrorBanner(elements.errorBanner, error.message || 'Something went wrong.', error.payload || error);
+};
+
+const handleFetchError = (error, fallback) => {
+ if (error?.code === 'BOT_NOT_IN_GUILD') {
+ return;
+ }
+ setError(error || {message: fallback});
+};
+
+const updateGuildHeader = (guild) => {
+ if (!guild) {
+ elements.guildHeaderName.textContent = 'Select a guild';
+ elements.guildHeaderIcon.src = '';
+ elements.guildStatusBadge.textContent = '';
+ elements.guildStatusNote.textContent = '';
+ return;
+ }
+ elements.guildHeaderName.textContent = guild.name;
+ if (guild.icon) {
+ elements.guildHeaderIcon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
+ } else {
+ elements.guildHeaderIcon.removeAttribute('src');
+ }
+ if (state.botInstalled) {
+ elements.guildStatusBadge.textContent = 'Bot added';
+ elements.guildStatusBadge.className = 'status-badge status-badge--ok';
+ elements.guildStatusNote.textContent = 'Settings are active.';
+ } else {
+ elements.guildStatusBadge.textContent = 'Not added';
+ elements.guildStatusBadge.className = 'status-badge status-badge--warn';
+ elements.guildStatusNote.textContent = 'The bot isn’t in this server yet. Invite it to configure settings.';
+ }
+};
+
+const renderGuilds = () => {
+ elements.guildList.innerHTML = '';
+ if (state.filteredGuilds.length === 0) {
+ elements.guildEmpty.hidden = false;
+ return;
+ }
+ elements.guildEmpty.hidden = true;
+ state.filteredGuilds.forEach((guild) => {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = `guild-item${state.selectedGuildId === guild.id ? ' active' : ''}`;
+ item.dataset.guildId = guild.id;
+ const info = document.createElement('div');
+ info.className = 'guild-item__info';
+ const icon = document.createElement('img');
+ if (guild.icon) {
+ icon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
+ icon.alt = `${guild.name} icon`;
+ } else {
+ icon.alt = '';
+ }
+ const name = document.createElement('span');
+ name.textContent = guild.name;
+ info.appendChild(icon);
+ info.appendChild(name);
+
+ const badge = document.createElement('span');
+ const isBotInGuild = state.botGuildIds.size === 0 ? null : state.botGuildIds.has(guild.id);
+ badge.className = `status-pill ${isBotInGuild ? 'status-pill--ok' : 'status-pill--warn'}`;
+ const dot = document.createElement('span');
+ dot.className = 'status-pill__dot';
+ const text = document.createElement('span');
+ text.textContent = isBotInGuild ? 'Bot added' : 'Not added';
+ badge.appendChild(dot);
+ badge.appendChild(text);
+
+ item.appendChild(info);
+ item.appendChild(badge);
+ item.addEventListener('click', () => selectGuild(guild.id));
+ elements.guildList.appendChild(item);
+ });
+};
+
+const renderRolePicker = (container, roles, selectedIds, onChange, options = {}) => {
+ container.innerHTML = '';
+ const wrapper = document.createElement('div');
+ wrapper.className = `multi-picker${options.disabled ? ' multi-picker--disabled' : ''}`;
+
+ const search = document.createElement('input');
+ search.type = 'text';
+ search.className = 'multi-picker__search';
+ search.placeholder = 'Search roles';
+ const list = document.createElement('div');
+ list.className = 'multi-picker__list';
+ const chips = document.createElement('div');
+ chips.className = 'chip-list';
+
+ const renderChips = () => {
+ chips.innerHTML = '';
+ if (selectedIds.length === 0) {
+ const empty = document.createElement('span');
+ empty.className = 'muted';
+ empty.textContent = options.emptyMessage || 'No roles selected.';
+ chips.appendChild(empty);
+ return;
+ }
+ selectedIds.forEach((roleId) => {
+ const role = roles.find((entry) => entry.id === roleId);
+ if (!role) {
+ return;
+ }
+ const chip = document.createElement('span');
+ chip.className = 'chip';
+ chip.textContent = role.name;
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.textContent = '✕';
+ removeButton.addEventListener('click', () => {
+ const next = selectedIds.filter((id) => id !== roleId);
+ selectedIds.splice(0, selectedIds.length, ...next);
+ onChange([...selectedIds]);
+ renderList(search.value);
+ renderChips();
+ });
+ chip.appendChild(removeButton);
+ chips.appendChild(chip);
+ });
+ };
+
+ const renderList = (query = '') => {
+ list.innerHTML = '';
+ const filteredRoles = roles
+ .filter((role) => role.name.toLowerCase().includes(query.toLowerCase()))
+ .sort((a, b) => b.position - a.position);
+ if (filteredRoles.length === 0) {
+ const empty = document.createElement('span');
+ empty.className = 'muted';
+ empty.textContent = 'No matching roles.';
+ list.appendChild(empty);
+ return;
+ }
+ filteredRoles.forEach((role) => {
+ const label = document.createElement('label');
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.checked = selectedIds.includes(role.id);
+ checkbox.disabled = options.disabled;
+ checkbox.addEventListener('change', () => {
+ if (checkbox.checked) {
+ if (!selectedIds.includes(role.id)) {
+ selectedIds.push(role.id);
+ }
+ } else {
+ const next = selectedIds.filter((id) => id !== role.id);
+ selectedIds.splice(0, selectedIds.length, ...next);
+ }
+ onChange([...selectedIds]);
+ renderChips();
+ });
+ label.appendChild(checkbox);
+ label.appendChild(document.createTextNode(role.name));
+ list.appendChild(label);
+ });
+ };
+
+ search.addEventListener('input', () => renderList(search.value));
+
+ renderList();
+ renderChips();
+
+ wrapper.appendChild(search);
+ wrapper.appendChild(list);
+ wrapper.appendChild(chips);
+ container.appendChild(wrapper);
+};
+
+const renderAdminUsers = () => {
+ elements.adminUserChips.innerHTML = '';
+ const adminUserIds = state.permissions?.adminUserIds || [];
+ if (adminUserIds.length === 0) {
+ const empty = document.createElement('span');
+ empty.className = 'muted';
+ empty.textContent = 'No admin users added.';
+ elements.adminUserChips.appendChild(empty);
+ return;
+ }
+
+ adminUserIds.forEach((userId) => {
+ const chip = document.createElement('span');
+ chip.className = 'chip';
+ chip.textContent = userId;
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.textContent = '✕';
+ removeButton.addEventListener('click', () => {
+ state.permissions.adminUserIds = adminUserIds.filter((id) => id !== userId);
+ setDirty(true);
+ renderAdminUsers();
+ });
+ chip.appendChild(removeButton);
+ elements.adminUserChips.appendChild(chip);
+ });
+};
+
+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 = () => {
+ elements.commandsList.innerHTML = '';
+ const commands = [...new Set(state.commands)].sort();
+ if (commands.length === 0) {
+ elements.commandsEmpty.hidden = false;
+ return;
+ }
+ elements.commandsEmpty.hidden = true;
+
+ 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'}`;
+ const header = document.createElement('div');
+ header.className = 'command-card__header';
+
+ const title = document.createElement('strong');
+ title.textContent = `/${commandName}`;
+
+ const toggleLabel = document.createElement('label');
+ toggleLabel.className = 'toggle';
+ const toggle = document.createElement('input');
+ toggle.type = 'checkbox';
+ toggle.checked = isRestricted;
+ toggleLabel.appendChild(toggle);
+ toggleLabel.appendChild(document.createTextNode('Restricted'));
+
+ header.appendChild(title);
+ header.appendChild(toggleLabel);
+
+ const body = document.createElement('div');
+ body.className = 'command-card__body';
+
+ const roleSection = document.createElement('div');
+ const roleLabel = document.createElement('p');
+ roleLabel.className = 'muted';
+ roleLabel.textContent = 'Allowed roles';
+ const rolePicker = document.createElement('div');
+ roleSection.appendChild(roleLabel);
+ roleSection.appendChild(rolePicker);
+
+ const userSection = document.createElement('div');
+ const userLabel = document.createElement('p');
+ userLabel.className = 'muted';
+ userLabel.textContent = 'Allowed users';
+ const userInput = document.createElement('input');
+ userInput.type = 'text';
+ userInput.placeholder = 'Paste user IDs';
+ const addUserButton = document.createElement('button');
+ addUserButton.type = 'button';
+ addUserButton.className = 'button';
+ addUserButton.textContent = 'Add';
+ const userControls = document.createElement('div');
+ userControls.className = 'input-row';
+ userControls.appendChild(userInput);
+ userControls.appendChild(addUserButton);
+ const chipList = document.createElement('div');
+ chipList.className = 'chip-list';
+
+ const renderUserChips = () => {
+ chipList.innerHTML = '';
+ if (commandConfig.allowedUserIds.length === 0) {
+ const empty = document.createElement('span');
+ empty.className = 'muted';
+ empty.textContent = 'No users added.';
+ chipList.appendChild(empty);
+ return;
+ }
+ commandConfig.allowedUserIds.forEach((userId) => {
+ const chip = document.createElement('span');
+ chip.className = 'chip';
+ chip.textContent = userId;
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.textContent = '✕';
+ remove.addEventListener('click', () => {
+ commandConfig.allowedUserIds = commandConfig.allowedUserIds.filter((id) => id !== userId);
+ setDirty(true);
+ renderUserChips();
+ });
+ chip.appendChild(remove);
+ chipList.appendChild(chip);
+ });
+ };
+
+ const addUsers = () => {
+ const values = parseIdList(userInput.value);
+ if (values.length === 0) {
+ return;
+ }
+ const merged = Array.from(new Set([...commandConfig.allowedUserIds, ...values]));
+ commandConfig.allowedUserIds = merged;
+ setDirty(true);
+ userInput.value = '';
+ renderUserChips();
+ };
+
+ addUserButton.addEventListener('click', addUsers);
+ userInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ addUsers();
+ }
+ });
+
+ userSection.appendChild(userLabel);
+ userSection.appendChild(userControls);
+ userSection.appendChild(chipList);
+ renderUserChips();
+
+ const actions = document.createElement('div');
+ const clearButton = document.createElement('button');
+ clearButton.type = 'button';
+ clearButton.className = 'button button--ghost';
+ clearButton.textContent = 'Clear';
+ clearButton.addEventListener('click', () => {
+ commandConfig.allowedRoleIds = [];
+ commandConfig.allowedUserIds = [];
+ commandConfig.restricted = false;
+ setDirty(true);
+ renderCommands();
+ });
+ actions.appendChild(clearButton);
+
+ body.appendChild(roleSection);
+ body.appendChild(userSection);
+ body.appendChild(actions);
+
+ toggle.addEventListener('change', () => {
+ setDirty(true);
+ commandConfig.restricted = toggle.checked;
+ if (!toggle.checked) {
+ commandConfig.allowedRoleIds = [];
+ commandConfig.allowedUserIds = [];
+ }
+ renderCommands();
+ });
+
+ const controlsDisabled = !state.botInstalled || !isRestricted;
+ userInput.disabled = controlsDisabled;
+ addUserButton.disabled = controlsDisabled;
+
+ renderRolePicker(
+ rolePicker,
+ state.roles,
+ commandConfig.allowedRoleIds,
+ (selected) => {
+ commandConfig.allowedRoleIds = selected;
+ setDirty(true);
+ },
+ {
+ disabled: !state.botInstalled || !isRestricted,
+ emptyMessage: isRestricted ? 'Select roles to allow.' : 'Allow all roles.',
+ },
+ );
+
+ card.appendChild(header);
+ card.appendChild(body);
+ elements.commandsList.appendChild(card);
+ });
+};
+
+const renderPanels = () => {
+ renderRolePicker(
+ elements.adminRolesPicker,
+ state.roles,
+ state.permissions.adminRoleIds,
+ (selected) => {
+ state.permissions.adminRoleIds = selected;
+ setDirty(true);
+ },
+ {
+ disabled: !state.botInstalled,
+ emptyMessage: state.botInstalled ? 'Select roles to grant admin access.' : 'Invite bot to load roles.',
+ },
+ );
+ renderAdminUsers();
+ renderCommands();
+
+ if (!state.botInstalled) {
+ elements.settingsPanel.classList.add('panel--disabled');
+ elements.permissionsPanel.classList.add('panel--disabled');
+ elements.botInactiveCallout.hidden = false;
+ } else {
+ elements.settingsPanel.classList.remove('panel--disabled');
+ elements.permissionsPanel.classList.remove('panel--disabled');
+ elements.botInactiveCallout.hidden = true;
+ }
+};
+
+const updateBotStatusControls = () => {
+ if (state.botInstalled) {
+ elements.inviteButton.hidden = true;
+ elements.refreshBotButton.hidden = true;
+ } else {
+ elements.inviteButton.hidden = false;
+ elements.refreshBotButton.hidden = false;
+ }
+};
+
+const loadSession = async () => {
+ const data = await fetchDashboardJson('/api/me');
+ elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
+ if (data.expiresAt) {
+ const expires = new Date(data.expiresAt);
+ elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
+ }
+};
+
+const loadGuilds = async () => {
+ setLoadingGuilds(true);
+ try {
+ const data = await fetchDashboardJson('/api/guilds');
+ state.guilds = data.guilds || [];
+ state.filteredGuilds = [...state.guilds];
+ if (state.guilds.length > 0 && !state.selectedGuildId) {
+ state.selectedGuildId = state.guilds[0].id;
+ }
+ renderGuilds();
+ } finally {
+ setLoadingGuilds(false);
+ }
+};
+
+const loadBotGuilds = async () => {
+ try {
+ const data = await fetchDashboardJson('/api/bot/guilds');
+ state.botGuildIds = new Set(data.guildIds || []);
+ } catch (error) {
+ state.botGuildIds = new Set();
+ handleFetchError(error, 'Failed to fetch bot status.');
+ }
+};
+
+const updateBotInstalled = () => {
+ state.botInstalled = state.selectedGuildId ? state.botGuildIds.has(state.selectedGuildId) : false;
+};
+
+const loadGuildDetails = async () => {
+ if (!state.selectedGuildId) {
+ return;
+ }
+ setLoadingDetails(true);
+ setError(null);
+ setDirty(false);
+ resetWarningSelection();
+
+ const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId);
+ updateBotInstalled();
+ updateGuildHeader(guild);
+ updateBotStatusControls();
+
+ try {
+ const [permissionsData, commandsData] = await Promise.all([
+ fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`),
+ fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/commands`),
+ ]);
+
+ state.permissions = normalizePermissions(permissionsData.permissions || {});
+ state.permissions.commandPermissions = state.permissions.commandPermissions || {};
+ state.commands = commandsData.commands || [];
+
+ Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
+ if (!state.commands.includes(commandName)) {
+ state.commands.push(commandName);
+ }
+ });
+
+ if (state.botInstalled) {
+ try {
+ const rolesData = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/roles`);
+ state.roles = rolesData.roles || [];
+ } catch (error) {
+ if (error?.code === 'BOT_NOT_IN_GUILD') {
+ state.botInstalled = false;
+ state.roles = [];
+ } else {
+ handleFetchError(error, 'Failed to fetch roles.');
+ }
+ }
+ } else {
+ state.roles = [];
+ }
+
+ updateGuildHeader(guild);
+ updateBotStatusControls();
+ state.originalPermissions = clonePermissions(state.permissions);
+ renderPanels();
+ const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'settings';
+ switchTab(activeTab);
+ } catch (error) {
+ handleFetchError(error, 'Failed to load guild data.');
+ } finally {
+ setLoadingDetails(false);
+ }
+};
+
+const selectGuild = async (guildId) => {
+ if (state.dirty) {
+ const proceed = window.confirm('You have unsaved changes. Discard them and switch guilds?');
+ if (!proceed) {
+ return;
+ }
+ }
+ state.selectedGuildId = guildId;
+ renderGuilds();
+ await loadGuildDetails();
+};
+
+const addAdminUser = () => {
+ const values = parseIdList(elements.adminUserInput.value);
+ if (values.length === 0) {
+ return;
+ }
+ state.permissions.adminUserIds = Array.from(new Set([...state.permissions.adminUserIds, ...values]));
+ elements.adminUserInput.value = '';
+ setDirty(true);
+ renderAdminUsers();
+};
+
+const addCommand = () => {
+ const value = elements.newCommandInput.value.trim();
+ if (!value) {
+ return;
+ }
+ if (!COMMAND_NAME_REGEX.test(value)) {
+ setError({message: 'Command name must be 1-32 characters and use letters, numbers, - or _.'});
+ return;
+ }
+ if (!state.commands.includes(value)) {
+ state.commands.push(value);
+ setDirty(true);
+ }
+ elements.newCommandInput.value = '';
+ renderCommands();
+};
+
+const savePermissions = async () => {
+ if (!state.selectedGuildId) {
+ return;
+ }
+
+ setLoadingDetails(true);
+ try {
+ const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
+ method: 'PATCH',
+ body: JSON.stringify(state.permissions),
+ });
+ state.permissions = normalizePermissions(data.permissions || {});
+ state.originalPermissions = clonePermissions(state.permissions);
+ setDirty(false);
+ dashboardApi.showToast('Permissions saved.', 'success');
+ renderPanels();
+ } catch (error) {
+ handleFetchError(error, 'Failed to save permissions.');
+ } finally {
+ setLoadingDetails(false);
+ }
+};
+
+const discardChanges = () => {
+ if (!state.originalPermissions) {
+ return;
+ }
+ state.permissions = clonePermissions(state.originalPermissions);
+ setDirty(false);
+ renderPanels();
+};
+
+const switchTab = (tabName) => {
+ elements.tabs.forEach((tab) => {
+ tab.classList.toggle('active', tab.dataset.tab === tabName);
+ });
+ elements.settingsPanel.hidden = tabName !== 'settings';
+ elements.permissionsPanel.hidden = tabName !== 'permissions';
+ 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 = resolveWarningUserId(elements.warningsUserInput.value);
+ if (!userId) {
+ setError({message: 'Enter a user ID to fetch warnings.'});
+ return;
+ }
+ elements.warningsSpinner.hidden = false;
+ try {
+ const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
+ const warnings = data.warnings || [];
+ elements.warningsList.innerHTML = '';
+ if (warnings.length === 0) {
+ elements.warningsEmpty.hidden = false;
+ } else {
+ elements.warningsEmpty.hidden = true;
+ warnings.forEach((warning) => {
+ const item = document.createElement('li');
+ item.className = 'warning-item';
+ const text = document.createElement('p');
+ text.textContent = warning.reason;
+ const meta = document.createElement('div');
+ meta.className = 'warning-item__meta';
+ const dateText = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
+ const moderator = document.createElement('span');
+ moderator.textContent = `Moderator: ${warning.moderatorId || 'Unknown'}`;
+ const copyButton = document.createElement('button');
+ copyButton.type = 'button';
+ copyButton.className = 'button button--ghost';
+ copyButton.textContent = 'Copy moderator ID';
+ copyButton.addEventListener('click', async () => {
+ if (!warning.moderatorId) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(warning.moderatorId);
+ dashboardApi.showToast('Moderator ID copied.', 'success');
+ } catch (error) {
+ dashboardApi.showToast('Unable to copy moderator ID.', 'error');
+ }
+ });
+ meta.appendChild(moderator);
+ meta.appendChild(document.createElement('span')).textContent = dateText;
+ item.appendChild(text);
+ item.appendChild(meta);
+ item.appendChild(copyButton);
+ elements.warningsList.appendChild(item);
+ });
+ }
+ setError(null);
+ } catch (error) {
+ handleFetchError(error, 'Failed to fetch warnings.');
+ } finally {
+ elements.warningsSpinner.hidden = true;
+ }
+};
+
+const updateWarningCount = () => {
+ const length = elements.warningReasonInput.value.length;
+ elements.warningCharCount.textContent = `${length}/200`;
+};
+
+const addWarning = async () => {
+ const userId = resolveWarningUserId(elements.newWarningUserInput.value);
+ const reason = elements.warningReasonInput.value.trim();
+ if (!userId || !reason) {
+ elements.warningStatus.textContent = 'User ID and reason are required.';
+ return;
+ }
+ if (reason.length > 200) {
+ elements.warningStatus.textContent = 'Reason must be under 200 characters.';
+ return;
+ }
+
+ elements.warningStatus.textContent = '';
+ try {
+ const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
+ method: 'POST',
+ body: JSON.stringify({reason}),
+ });
+ elements.warningStatus.textContent = `Warning added. Total warnings: ${data.warnings?.length || 0}`;
+ elements.warningReasonInput.value = '';
+ updateWarningCount();
+ dashboardApi.showToast('Warning saved.', 'success');
+ } catch (error) {
+ handleFetchError(error, 'Failed to add warning.');
+ }
+};
+
+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();
+ const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId);
+ updateGuildHeader(guild);
+ updateBotStatusControls();
+ renderPanels();
+};
+
+const openInvite = async () => {
+ try {
+ const data = await fetchDashboardJson(`/api/invite-url?guildId=${state.selectedGuildId}`);
+ if (data.inviteUrl) {
+ window.open(data.inviteUrl, '_blank', 'noopener,noreferrer');
+ }
+ } catch (error) {
+ handleFetchError(error, 'Failed to generate invite URL.');
+ }
+};
+
+const openCopyModal = () => {
+ elements.copyGuildSelect.innerHTML = '';
+ state.guilds
+ .filter((guild) => guild.id !== state.selectedGuildId)
+ .forEach((guild) => {
+ const option = document.createElement('option');
+ option.value = guild.id;
+ option.textContent = guild.name;
+ elements.copyGuildSelect.appendChild(option);
+ });
+ elements.copyModal.hidden = false;
+};
+
+const closeCopyModal = () => {
+ elements.copyModal.hidden = true;
+};
+
+const confirmCopy = async () => {
+ const guildId = elements.copyGuildSelect.value;
+ if (!guildId) {
+ closeCopyModal();
+ return;
+ }
+ try {
+ const data = await fetchDashboardJson(`/api/guilds/${guildId}/permissions`);
+ state.permissions = normalizePermissions(data.permissions || {});
+ setDirty(true);
+ renderPanels();
+ dashboardApi.showToast('Permissions copied. Review before saving.', 'success');
+ } catch (error) {
+ handleFetchError(error, 'Failed to copy permissions.');
+ } finally {
+ closeCopyModal();
+ }
+};
+
+const initDashboard = async () => {
+ try {
+ await loadSession();
+ await Promise.all([loadGuilds(), loadBotGuilds()]);
+ renderGuilds();
+ if (state.selectedGuildId) {
+ await loadGuildDetails();
+ }
+ } catch (error) {
+ setError({message: 'Please log in to access the dashboard.'});
+ window.location.href = '/';
+ return;
+ }
+
+ elements.logoutButton.addEventListener('click', async () => {
+ await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
+ window.location.href = '/';
+ });
+
+ elements.tabs.forEach((tab) => {
+ tab.addEventListener('click', () => switchTab(tab.dataset.tab));
+ });
+
+ elements.addAdminUserButton.addEventListener('click', addAdminUser);
+ elements.adminUserInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ addAdminUser();
+ }
+ });
+ elements.addCommandButton.addEventListener('click', addCommand);
+ 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', () => {
+ Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
+ state.permissions.commandPermissions[commandName] = {
+ allowedRoleIds: [],
+ allowedUserIds: [],
+ restricted: false,
+ };
+ });
+ setDirty(true);
+ renderCommands();
+ });
+ elements.copyFromGuild.addEventListener('click', openCopyModal);
+ elements.cancelCopyButton.addEventListener('click', closeCopyModal);
+ elements.confirmCopyButton.addEventListener('click', confirmCopy);
+ updateWarningCount();
+
+ elements.guildSearch.addEventListener(
+ 'input',
+ dashboardApi.debounce((event) => {
+ const query = event.target.value.toLowerCase();
+ state.filteredGuilds = state.guilds.filter((guild) => guild.name.toLowerCase().includes(query));
+ renderGuilds();
+ }, 200),
+ );
+
+ window.addEventListener('beforeunload', (event) => {
+ if (state.dirty) {
+ event.preventDefault();
+ event.returnValue = '';
+ }
+ });
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ initDashboard();
+});
diff --git a/public/index.html b/public/index.html
index 614f3d2..c372e8e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,90 +8,26 @@
-