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 + + + +
+ + + + +
+ +
+ + +
+
+
+ +
+

Select a guild

+
+ + +
+
+
+
+ + +
+
+ +
+ + + +
+ +
+
+
+

Admin access

+

Control who can edit bot configuration and permissions.

+
+
+ +
+ + Admin roles + ? + +
+
+
+ + Admin user IDs + ? + +
+ + +
+
+
+
+ + + + + + + + +
+
+
+ + + + + + + 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 @@
-
+
-

Discord Bot

-

Moderation Dashboard

-

- Manage permissions, review roles, and track warnings without leaving your browser. -

+
Discord Bot Dashboard
+

Manage your moderation settings

+

Configure permissions and warnings without touching JSON.

+
+ -
-
-
-

Session

-

Checking your session...

-
- -
+ -