From 10caf2036977cd5a1cf04e6dc2c5ad20eaf800c0 Mon Sep 17 00:00:00 2001 From: Fretux Date: Wed, 28 Jan 2026 10:41:22 +0100 Subject: [PATCH] Improve dashboard UX and bot invite flow --- dashboardServer.js | 100 +++++- public/app.js | 104 ++++-- public/dashboard.html | 257 +++++++++------ public/dashboard.js | 732 ++++++++++++++++++++++++++++++++++-------- public/styles.css | 561 +++++++++++++++++++++++++++----- 5 files changed, 1418 insertions(+), 336 deletions(-) diff --git a/dashboardServer.js b/dashboardServer.js index 1131f97..583319c 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -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 || @@ -59,9 +63,14 @@ 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 = {}; @@ -153,12 +162,12 @@ const getDiscordStatusCode = (error) => { const sendDiscordApiError = (res, error, fallbackMessage) => { const status = getDiscordStatusCode(error); if (status === 401 || status === 403) { - return res.status(401).json({error: 'Discord authentication expired'}); + return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'}); } if (status === 429) { - return res.status(503).json({error: 'Discord rate limit exceeded'}); + return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'}); } - return res.status(502).json({error: fallbackMessage}); + return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage}); }; const buildAuthorizationUrl = (state) => { @@ -226,6 +235,55 @@ const fetchDiscordGuildRoles = async (guildId) => { 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(); @@ -434,6 +492,32 @@ const createDashboardApp = () => { } }); + 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, @@ -482,8 +566,16 @@ const createDashboardApp = () => { const roles = await fetchDiscordGuildRoles(req.params.guildId); return res.json({roles}); } 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: 'Discord bot token not configured'}); + return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'}); } return sendDiscordApiError(res, error, 'Failed to fetch roles'); } diff --git a/public/app.js b/public/app.js index 1571ffc..1c34702 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,19 @@ +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 fetchJson = async (url, options = {}) => { const response = await fetch(url, { credentials: 'include', @@ -9,39 +25,76 @@ const fetchJson = async (url, options = {}) => { }); if (!response.ok) { - const body = await response.json().catch(() => ({})); - const message = body.error || `Request failed with status ${response.status}`; - const error = new Error(message); - error.details = body.details; - throw error; + throw await createFetchError(response); } return response.json().catch(() => ({})); }; -const setBanner = (bannerEl, message, type = 'error') => { - if (!bannerEl) { +const showToast = (message, type = 'success') => { + const container = document.getElementById('toastContainer'); + if (!container) { return; } - bannerEl.textContent = message; - bannerEl.classList.remove('banner--error', 'banner--success'); - bannerEl.classList.add(type === 'success' ? 'banner--success' : 'banner--error'); - bannerEl.style.display = 'block'; + 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 clearBanner = (bannerEl) => { +const setErrorBanner = (bannerEl, message, details) => { if (!bannerEl) { return; } - bannerEl.textContent = ''; - bannerEl.style.display = 'none'; + const messageEl = bannerEl.querySelector('[data-role="error-message"]'); + const copyButton = bannerEl.querySelector('[data-role="error-copy"]'); + if (messageEl) { + messageEl.textContent = message; + } + 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'); + } + }; + } + bannerEl.hidden = false; +}; + +const clearErrorBanner = (bannerEl) => { + if (!bannerEl) { + return; + } + bannerEl.hidden = true; }; const formatUserDisplay = (user) => { if (!user) { return 'Not signed in'; } - return `${user.username}#${user.discriminator ?? '0000'}`; + const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : ''; + return `${user.username}${tag}`; +}; + +const debounce = (callback, wait = 300) => { + let timeoutId; + return (...args) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => callback(...args), wait); + }; }; const initIndexPage = async () => { @@ -61,13 +114,19 @@ const initIndexPage = async () => { loginButton.hidden = true; dashboardButton.hidden = false; logoutButton.hidden = false; - clearBanner(banner); + if (banner) { + banner.textContent = ''; + banner.style.display = 'none'; + } } catch (error) { 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'); + if (banner) { + banner.textContent = 'Log in with Discord to access the dashboard.'; + banner.style.display = 'block'; + } } logoutButton?.addEventListener('click', async () => { @@ -78,7 +137,10 @@ const initIndexPage = async () => { dashboardButton.hidden = true; logoutButton.hidden = true; } catch (error) { - setBanner(banner, error.message || 'Failed to log out.', 'error'); + if (banner) { + banner.textContent = error.message || 'Failed to log out.'; + banner.style.display = 'block'; + } } }); }; @@ -89,7 +151,9 @@ document.addEventListener('DOMContentLoaded', () => { window.dashboardApi = { fetchJson, - setBanner, - clearBanner, + showToast, + setErrorBanner, + clearErrorBanner, formatUserDisplay, + debounce, }; diff --git a/public/dashboard.html b/public/dashboard.html index f356e6e..55da244 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -7,134 +7,201 @@ -
-
-
- Guild dashboard - Loading session... - +
+ - + + +
-