From f610d7a9224297114389cadea01ca76ede0ede75 Mon Sep 17 00:00:00 2001 From: Fretux Date: Tue, 27 Jan 2026 14:33:32 +0100 Subject: [PATCH] Add dashboard frontend --- dashboardServer.js | 5 + public/app.js | 223 +++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 100 ++++++++++++++++++++ public/styles.css | 176 +++++++++++++++++++++++++++++++++++ 4 files changed, 504 insertions(+) create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css diff --git a/dashboardServer.js b/dashboardServer.js index 401b816..67b3e54 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -260,6 +260,7 @@ const createDashboardApp = () => { const app = express(); app.use(express.json()); + app.use(express.static(path.join(__dirname, 'public'))); app.get('/auth/discord', (req, res) => { if (!ensureOAuthConfig()) { @@ -313,6 +314,10 @@ const createDashboardApp = () => { return res.redirect(DASHBOARD_CLIENT_URL); } + if (req.accepts('html')) { + return res.redirect('/'); + } + return res.status(200).json({ user }); } catch (error) { return res.status(502).json({ error: 'Failed to authenticate with Discord' }); diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..3f50efa --- /dev/null +++ b/public/app.js @@ -0,0 +1,223 @@ +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 response = await fetch(url, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + ...options, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + const message = body.error || `Request failed with status ${response.status}`; + throw new Error(message); + } + + 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) { + 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 updatePermissions = async () => { + if (!state.selectedGuildId) { + 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); +}; + +const loadWarnings = async (userId) => { + if (!state.selectedGuildId || !userId) { + 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); + }); +}; + +const addWarning = async () => { + if (!state.selectedGuildId) { + 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); + } catch (error) { + setStatus('Sign in with Discord to manage your guilds.', false); + } +}; + +init(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..614f3d2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,100 @@ + + + + + + Discord Bot Dashboard + + + +
+
+
+

Discord Bot

+

Moderation Dashboard

+

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

+
+ +
+ +
+
+

Session

+

Checking your session...

+
+ +
+ + + + + +
+

Need help?

+

+ The dashboard reads permissions from the MongoDB-backed overrides collection. Use it to grant + command access to trusted members in each server. +

+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e3c3758 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,176 @@ +:root { + color-scheme: dark; + font-family: "Inter", system-ui, -apple-system, sans-serif; + line-height: 1.5; + background: #0e111b; + color: #eef2ff; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top, #1d2542 0%, #0e111b 55%); +} + +.app { + max-width: 1100px; + margin: 0 auto; + padding: 48px 24px 64px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.app__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + padding: 32px; + border-radius: 20px; + background: rgba(17, 23, 40, 0.9); + border: 1px solid rgba(118, 135, 255, 0.2); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); +} + +.eyebrow { + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.2rem; + color: #8b9cff; + margin: 0 0 8px; +} + +h1 { + font-size: 2.4rem; + margin: 0 0 12px; +} + +.subhead { + margin: 0; + max-width: 520px; + color: #b7c0ff; +} + +.card { + background: rgba(17, 23, 40, 0.95); + border-radius: 18px; + padding: 24px; + border: 1px solid rgba(118, 135, 255, 0.2); + display: flex; + flex-direction: column; + gap: 16px; +} + +.card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.card__content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.columns { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.panel { + background: rgba(12, 15, 26, 0.9); + border-radius: 14px; + padding: 16px; + border: 1px solid rgba(118, 135, 255, 0.15); + display: flex; + flex-direction: column; + gap: 12px; +} + +.button { + padding: 10px 18px; + border-radius: 999px; + border: 1px solid rgba(118, 135, 255, 0.3); + background: rgba(16, 20, 35, 0.8); + color: #f5f7ff; + cursor: pointer; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(34, 44, 90, 0.4); +} + +.button--primary { + background: linear-gradient(135deg, #5f6bff, #8a5bff); + border: none; +} + +.button--ghost { + background: transparent; +} + +select, +input, +textarea { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(118, 135, 255, 0.3); + background: rgba(6, 8, 16, 0.6); + color: #eef2ff; +} + +textarea { + min-height: 140px; + resize: vertical; +} + +.list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.list li { + background: rgba(16, 20, 35, 0.6); + padding: 8px 12px; + border-radius: 10px; + border: 1px solid rgba(118, 135, 255, 0.15); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.85rem; +} + +.muted { + color: #9aa5e3; + margin: 0; +} + +@media (max-width: 720px) { + .app__header { + flex-direction: column; + align-items: flex-start; + } + + h1 { + font-size: 2rem; + } +}