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..4419383 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -56,6 +56,8 @@ 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 sessionStore = new Map(); @@ -151,7 +153,7 @@ const buildAuthorizationUrl = (state) => { prompt: 'consent', }); - return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`; + return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`; }; const fetchDiscordAccessToken = async (code) => { @@ -163,7 +165,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', }, @@ -218,6 +220,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 ( @@ -276,7 +306,7 @@ const createDashboardApp = () => { setCookie(res, 'oauth_state', state, { httpOnly: true, sameSite: 'Lax', - secure: req.secure, + secure: isHttpsRequest(req), maxAge: 300, }); @@ -298,7 +328,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 +336,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 +349,7 @@ const createDashboardApp = () => { } if (req.accepts('html')) { - return res.redirect('/'); + return res.redirect('/dashboard.html'); } return res.status(200).json({user}); @@ -327,12 +357,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,6 +373,13 @@ 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); @@ -398,6 +435,16 @@ const createDashboardApp = () => { }, ); + 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 +455,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,7 +482,23 @@ 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'}); } diff --git a/public/app.js b/public/app.js index 3f50efa..1571ffc 100644 --- a/public/app.js +++ b/public/app.js @@ -1,39 +1,9 @@ -const state = { - guilds: [], - selectedGuildId: null, -}; - -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, }); @@ -41,183 +11,85 @@ const fetchJson = async (url, options) => { if (!response.ok) { const body = await response.json().catch(() => ({})); const message = body.error || `Request failed with status ${response.status}`; - throw new Error(message); + const error = new Error(message); + error.details = body.details; + throw error; } 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 setBanner = (bannerEl, message, type = 'error') => { + if (!bannerEl) { 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 || []); + bannerEl.textContent = message; + bannerEl.classList.remove('banner--error', 'banner--success'); + bannerEl.classList.add(type === 'success' ? 'banner--success' : 'banner--error'); + bannerEl.style.display = 'block'; }; -const updatePermissions = async () => { - if (!state.selectedGuildId) { +const clearBanner = (bannerEl) => { + if (!bannerEl) { return; } - - const rawValue = elements.permissionsEditor.value.trim(); - if (!rawValue) { - return; - } - - let payload; - try { - payload = JSON.parse(rawValue); - } catch (error) { - elements.permissionsEditor.focus(); - throw new Error('Permissions JSON is invalid.'); - } - - 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.textContent = ''; + bannerEl.style.display = 'none'; }; -const loadWarnings = async (userId) => { - if (!state.selectedGuildId || !userId) { - return; +const formatUserDisplay = (user) => { + if (!user) { + return 'Not signed in'; } - - 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); - }); + return `${user.username}#${user.discriminator ?? '0000'}`; }; -const addWarning = async () => { - if (!state.selectedGuildId) { +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'); + + if (!sessionStatus || !loginButton) { return; } - 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 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); - } - }); - - elements.loadWarningsButton.addEventListener('click', async () => { - try { - await loadWarnings(elements.warningUserId.value.trim()); - } catch (error) { - showError(error.message); - } - }); - - elements.addWarningButton.addEventListener('click', async () => { - try { - await addWarning(); - } catch (error) { - showError(error.message); - } - }); - 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; + clearBanner(banner); } 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; + setBanner(banner, 'Log in with Discord to access the dashboard.', 'error'); } + + 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) { + setBanner(banner, error.message || 'Failed to log out.', 'error'); + } + }); }; -init(); +document.addEventListener('DOMContentLoaded', () => { + initIndexPage(); +}); + +window.dashboardApi = { + fetchJson, + setBanner, + clearBanner, + formatUserDisplay, +}; diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000..f356e6e --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,141 @@ + + + + + + Discord Bot Dashboard + + + +
+
+
+ Guild dashboard + Loading session... + +
+
+ Home + +
+
+ + + +
+ + +
+
+ + +
+ +
+
+
+
+

Admin access

+

Choose who can edit the bot configuration.

+
+
+ + +
+
+ +
+
+
+

Command permissions

+

Configure who can run each command. Empty restrictions allow everyone.

+
+
+ + +
+
+ + + + + + + + + + +
CommandAllowed rolesAllowed users
+
+ +
+ + +
+
+ + +
+
+
+ + + + + diff --git a/public/dashboard.js b/public/dashboard.js new file mode 100644 index 0000000..854e392 --- /dev/null +++ b/public/dashboard.js @@ -0,0 +1,465 @@ +const state = { + guilds: [], + selectedGuildId: null, + roles: [], + permissions: null, + commands: [], +}; + +const elements = { + banner: document.getElementById('banner'), + userDisplay: document.getElementById('userDisplay'), + sessionExpiry: document.getElementById('sessionExpiry'), + logoutButton: document.getElementById('logoutButton'), + guildList: document.getElementById('guildList'), + guildEmpty: document.getElementById('guildEmpty'), + tabs: document.querySelectorAll('.tab'), + permissionsPanel: document.getElementById('permissionsPanel'), + warningsPanel: document.getElementById('warningsPanel'), + adminRolesSelect: document.getElementById('adminRolesSelect'), + adminUserInput: document.getElementById('adminUserInput'), + addAdminUserButton: document.getElementById('addAdminUserButton'), + adminUserChips: document.getElementById('adminUserChips'), + newCommandInput: document.getElementById('newCommandInput'), + addCommandButton: document.getElementById('addCommandButton'), + commandsTable: document.getElementById('commandsTable'), + savePermissionsButton: document.getElementById('savePermissionsButton'), + permissionsStatus: document.getElementById('permissionsStatus'), + warningsUserInput: document.getElementById('warningsUserInput'), + fetchWarningsButton: document.getElementById('fetchWarningsButton'), + warningsList: document.getElementById('warningsList'), + warningsEmpty: document.getElementById('warningsEmpty'), + newWarningUserInput: document.getElementById('newWarningUserInput'), + warningReasonInput: document.getElementById('warningReasonInput'), + addWarningButton: document.getElementById('addWarningButton'), + warningStatus: document.getElementById('warningStatus'), +}; + +const {fetchJson, setBanner, clearBanner, formatUserDisplay} = window.dashboardApi; + +const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i; + +const setLoading = (isLoading) => { + document.body.classList.toggle('loading', isLoading); +}; + +const renderGuilds = () => { + elements.guildList.innerHTML = ''; + if (state.guilds.length === 0) { + elements.guildEmpty.hidden = false; + return; + } + + elements.guildEmpty.hidden = true; + state.guilds.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 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; + item.appendChild(icon); + item.appendChild(name); + item.addEventListener('click', () => selectGuild(guild.id)); + elements.guildList.appendChild(item); + }); +}; + +const renderRolesSelect = () => { + elements.adminRolesSelect.innerHTML = ''; + state.roles + .sort((a, b) => b.position - a.position) + .forEach((role) => { + const option = document.createElement('option'); + option.value = role.id; + option.textContent = role.name; + elements.adminRolesSelect.appendChild(option); + }); + + if (state.permissions) { + const adminRoleIds = new Set(state.permissions.adminRoleIds || []); + Array.from(elements.adminRolesSelect.options).forEach((option) => { + option.selected = adminRoleIds.has(option.value); + }); + } +}; + +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); + renderAdminUsers(); + }); + chip.appendChild(removeButton); + elements.adminUserChips.appendChild(chip); + }); +}; + +const ensureCommandEntry = (commandName) => { + if (!state.permissions.commandPermissions[commandName]) { + state.permissions.commandPermissions[commandName] = { + allowedRoleIds: [], + allowedUserIds: [], + }; + } +}; + +const renderCommandsTable = () => { + const tbody = elements.commandsTable.querySelector('tbody'); + tbody.innerHTML = ''; + + const commands = [...new Set(state.commands)].sort(); + if (commands.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 4; + cell.className = 'muted'; + cell.textContent = 'No commands configured yet. Add one above.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + commands.forEach((commandName) => { + ensureCommandEntry(commandName); + const commandConfig = state.permissions.commandPermissions[commandName]; + const row = document.createElement('tr'); + + const nameCell = document.createElement('td'); + nameCell.textContent = commandName; + + const roleCell = document.createElement('td'); + const roleSelect = document.createElement('select'); + roleSelect.multiple = true; + roleSelect.className = 'multi-select'; + state.roles + .sort((a, b) => b.position - a.position) + .forEach((role) => { + const option = document.createElement('option'); + option.value = role.id; + option.textContent = role.name; + option.selected = commandConfig.allowedRoleIds.includes(role.id); + roleSelect.appendChild(option); + }); + roleSelect.addEventListener('change', () => { + commandConfig.allowedRoleIds = Array.from(roleSelect.selectedOptions).map((opt) => opt.value); + }); + roleCell.appendChild(roleSelect); + + const userCell = document.createElement('td'); + const userInput = document.createElement('input'); + userInput.type = 'text'; + userInput.placeholder = 'User ID'; + const addUserButton = document.createElement('button'); + addUserButton.type = 'button'; + addUserButton.className = 'button'; + addUserButton.textContent = 'Add'; + 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); + renderUserChips(); + }); + chip.appendChild(remove); + chipList.appendChild(chip); + }); + }; + + addUserButton.addEventListener('click', () => { + const value = userInput.value.trim(); + if (!value) { + return; + } + if (!commandConfig.allowedUserIds.includes(value)) { + commandConfig.allowedUserIds.push(value); + renderUserChips(); + } + userInput.value = ''; + }); + + const userControls = document.createElement('div'); + userControls.className = 'button-row'; + userControls.appendChild(userInput); + userControls.appendChild(addUserButton); + userCell.appendChild(userControls); + userCell.appendChild(chipList); + renderUserChips(); + + const actionCell = document.createElement('td'); + const clearButton = document.createElement('button'); + clearButton.type = 'button'; + clearButton.className = 'button button--ghost'; + clearButton.textContent = 'Clear restrictions'; + clearButton.addEventListener('click', () => { + commandConfig.allowedRoleIds = []; + commandConfig.allowedUserIds = []; + renderCommandsTable(); + }); + actionCell.appendChild(clearButton); + + row.appendChild(nameCell); + row.appendChild(roleCell); + row.appendChild(userCell); + row.appendChild(actionCell); + tbody.appendChild(row); + }); +}; + +const loadSession = async () => { + const data = await fetchJson('/api/me'); + elements.userDisplay.textContent = formatUserDisplay(data.user); + if (data.expiresAt) { + const expires = new Date(data.expiresAt); + elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`; + } +}; + +const loadGuilds = async () => { + const data = await fetchJson('/api/guilds'); + state.guilds = data.guilds || []; + if (state.guilds.length > 0) { + state.selectedGuildId = state.guilds[0].id; + } + renderGuilds(); +}; + +const loadGuildDetails = async () => { + if (!state.selectedGuildId) { + return; + } + setLoading(true); + try { + const [permissionsData, rolesData, commandsData] = await Promise.all([ + fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`), + fetchJson(`/api/guilds/${state.selectedGuildId}/roles`), + fetchJson(`/api/guilds/${state.selectedGuildId}/commands`), + ]); + + state.permissions = permissionsData.permissions || { + adminRoleIds: [], + adminUserIds: [], + commandPermissions: {}, + }; + state.permissions.commandPermissions = state.permissions.commandPermissions || {}; + state.roles = rolesData.roles || []; + state.commands = commandsData.commands || []; + + Object.keys(state.permissions.commandPermissions).forEach((commandName) => { + if (!state.commands.includes(commandName)) { + state.commands.push(commandName); + } + }); + + renderRolesSelect(); + renderAdminUsers(); + renderCommandsTable(); + elements.permissionsStatus.textContent = ''; + clearBanner(elements.banner); + } catch (error) { + setBanner(elements.banner, error.message || 'Failed to load guild data.'); + } finally { + setLoading(false); + } +}; + +const selectGuild = async (guildId) => { + state.selectedGuildId = guildId; + renderGuilds(); + await loadGuildDetails(); +}; + +const addAdminUser = () => { + const value = elements.adminUserInput.value.trim(); + if (!value) { + return; + } + if (!state.permissions.adminUserIds.includes(value)) { + state.permissions.adminUserIds.push(value); + } + elements.adminUserInput.value = ''; + renderAdminUsers(); +}; + +const addCommand = () => { + const value = elements.newCommandInput.value.trim(); + if (!value) { + return; + } + if (!COMMAND_NAME_REGEX.test(value)) { + setBanner(elements.banner, 'Command name must be 1-32 characters and use letters, numbers, - or _.'); + return; + } + if (!state.commands.includes(value)) { + state.commands.push(value); + } + elements.newCommandInput.value = ''; + renderCommandsTable(); +}; + +const savePermissions = async () => { + if (!state.selectedGuildId) { + return; + } + + const adminRoleIds = Array.from(elements.adminRolesSelect.selectedOptions).map((option) => option.value); + state.permissions.adminRoleIds = adminRoleIds; + + const payload = { + adminRoleIds: state.permissions.adminRoleIds, + adminUserIds: state.permissions.adminUserIds, + commandPermissions: state.permissions.commandPermissions, + }; + + setLoading(true); + try { + const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + state.permissions = data.permissions; + elements.permissionsStatus.textContent = 'Permissions saved successfully.'; + setBanner(elements.banner, 'Permissions updated successfully.', 'success'); + renderRolesSelect(); + renderAdminUsers(); + renderCommandsTable(); + } catch (error) { + setBanner(elements.banner, error.message || 'Failed to save permissions.'); + } finally { + setLoading(false); + } +}; + +const switchTab = (tabName) => { + elements.tabs.forEach((tab) => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + elements.permissionsPanel.hidden = tabName !== 'permissions'; + elements.warningsPanel.hidden = tabName !== 'warnings'; +}; + +const fetchWarnings = async () => { + const userId = elements.warningsUserInput.value.trim(); + if (!userId) { + setBanner(elements.banner, 'Enter a user ID to fetch warnings.'); + return; + } + setLoading(true); + try { + const data = await fetchJson(`/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'); + const dateText = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date'; + item.textContent = `${warning.reason} (Moderator: ${warning.moderatorId || 'Unknown'}) — ${dateText}`; + elements.warningsList.appendChild(item); + }); + } + clearBanner(elements.banner); + } catch (error) { + setBanner(elements.banner, error.message || 'Failed to fetch warnings.'); + } finally { + setLoading(false); + } +}; + +const addWarning = async () => { + const userId = elements.newWarningUserInput.value.trim(); + const reason = elements.warningReasonInput.value.trim(); + if (!userId || !reason) { + elements.warningStatus.textContent = 'User ID and reason are required.'; + return; + } + + setLoading(true); + try { + const data = await fetchJson(`/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 = ''; + clearBanner(elements.banner); + } catch (error) { + setBanner(elements.banner, error.message || 'Failed to add warning.'); + } finally { + setLoading(false); + } +}; + +const initDashboard = async () => { + try { + await loadSession(); + await loadGuilds(); + if (state.selectedGuildId) { + await loadGuildDetails(); + } + } catch (error) { + setBanner(elements.banner, '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.addCommandButton.addEventListener('click', addCommand); + elements.savePermissionsButton.addEventListener('click', savePermissions); + elements.fetchWarningsButton.addEventListener('click', fetchWarnings); + elements.addWarningButton.addEventListener('click', addWarning); +}; + +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...

-
- -
+ -