Add dashboard UI and secure guild permissions

This commit is contained in:
2026-01-27 15:40:06 +01:00
parent 06fdd57aa0
commit 4fe1831386
8 changed files with 1039 additions and 404 deletions
+8
View File
@@ -0,0 +1,8 @@
[
"warn",
"warnings",
"ban",
"kick",
"mute",
"unmute"
]
+79 -12
View File
@@ -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'});
}
+60 -188
View File
@@ -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 setBanner = (bannerEl, message, type = 'error') => {
if (!bannerEl) {
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 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 clearBanner = (bannerEl) => {
if (!bannerEl) {
return;
}
bannerEl.textContent = '';
bannerEl.style.display = 'none';
};
const loadGuildDetails = async () => {
if (!state.selectedGuildId) {
const formatUserDisplay = (user) => {
if (!user) {
return 'Not signed in';
}
return `${user.username}#${user.discriminator ?? '0000'}`;
};
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 [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);
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) {
elements.permissionsEditor.focus();
throw new Error('Permissions JSON is invalid.');
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');
}
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 () => {
logoutButton?.addEventListener('click', async () => {
try {
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.';
sessionStatus.textContent = 'Logged out successfully.';
loginButton.hidden = false;
dashboardButton.hidden = true;
logoutButton.hidden = true;
} catch (error) {
showError(error.message);
setBanner(banner, error.message || 'Failed to log out.', 'error');
}
});
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();
document.addEventListener('DOMContentLoaded', () => {
initIndexPage();
});
window.dashboardApi = {
fetchJson,
setBanner,
clearBanner,
formatUserDisplay,
};
+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Discord Bot Dashboard</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="topbar__user">
<span class="badge">Guild dashboard</span>
<strong id="userDisplay">Loading session...</strong>
<span class="muted" id="sessionExpiry"></span>
</div>
<div class="button-row">
<a class="button" href="/">Home</a>
<button class="button button--ghost" id="logoutButton">Log out</button>
</div>
</header>
<div class="banner" id="banner"></div>
<div class="layout">
<aside class="panel">
<div class="section">
<div class="section__header">
<h2>Manageable guilds</h2>
</div>
<div class="guild-list" id="guildList"></div>
<div class="empty-state" id="guildEmpty" hidden>
No manageable guilds found. Make sure you have Manage Server permissions.
</div>
</div>
</aside>
<main>
<div class="tabs">
<button class="tab active" data-tab="permissions">Permissions</button>
<button class="tab" data-tab="warnings">Warnings</button>
</div>
<section class="panel" id="permissionsPanel">
<div class="section">
<div class="section__header">
<div>
<h2>Admin access</h2>
<p class="muted">Choose who can edit the bot configuration.</p>
</div>
</div>
<label class="field">
<span>Admin roles</span>
<select class="multi-select" id="adminRolesSelect" multiple></select>
</label>
<label class="field">
<span>Add admin user ID</span>
<div class="button-row">
<input type="text" id="adminUserInput" placeholder="123456789" />
<button class="button" id="addAdminUserButton">Add user</button>
</div>
</label>
<div class="chip-list" id="adminUserChips"></div>
</div>
<div class="section">
<div class="section__header">
<div>
<h2>Command permissions</h2>
<p class="muted">Configure who can run each command. Empty restrictions allow everyone.</p>
</div>
<div class="button-row">
<input type="text" id="newCommandInput" placeholder="new-command" />
<button class="button" id="addCommandButton">Add command</button>
</div>
</div>
<table class="table" id="commandsTable">
<thead>
<tr>
<th>Command</th>
<th>Allowed roles</th>
<th>Allowed users</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="button-row">
<button class="button button--primary" id="savePermissionsButton">Save permissions</button>
<span class="muted" id="permissionsStatus"></span>
</div>
</section>
<section class="panel" id="warningsPanel" hidden>
<div class="section">
<div class="section__header">
<div>
<h2>Warnings lookup</h2>
<p class="muted">Fetch recent warnings for a user.</p>
</div>
</div>
<label class="field">
<span>User ID</span>
<div class="button-row">
<input type="text" id="warningsUserInput" placeholder="123456789" />
<button class="button" id="fetchWarningsButton">Fetch warnings</button>
</div>
</label>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div>
<ul id="warningsList"></ul>
</div>
<div class="section">
<div class="section__header">
<div>
<h2>Add warning</h2>
<p class="muted">Record a new warning and sync strikes.</p>
</div>
</div>
<label class="field">
<span>User ID</span>
<input type="text" id="newWarningUserInput" placeholder="123456789" />
</label>
<label class="field">
<span>Reason</span>
<textarea id="warningReasonInput" placeholder="Reason for warning"></textarea>
</label>
<button class="button button--primary" id="addWarningButton">Add warning</button>
<p class="muted" id="warningStatus"></p>
</div>
</section>
</main>
</div>
</div>
<script src="app.js"></script>
<script src="dashboard.js"></script>
</body>
</html>
+465
View File
@@ -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();
});
+14 -78
View File
@@ -8,90 +8,26 @@
</head>
<body>
<div class="app">
<header class="app__header">
<header class="topbar">
<div>
<p class="eyebrow">Discord Bot</p>
<h1>Moderation Dashboard</h1>
<p class="subhead">
Manage permissions, review roles, and track warnings without leaving your browser.
</p>
<div class="badge">Discord Bot Dashboard</div>
<h1>Manage your moderation settings</h1>
<p class="muted">Configure permissions and warnings without touching JSON.</p>
</div>
<div class="button-row">
<a class="button button--primary" href="/auth/discord" id="loginButton">Log in with Discord</a>
<a class="button" href="/dashboard.html" id="dashboardButton" hidden>Open dashboard</a>
</div>
<button class="button button--primary" id="loginButton">Log in with Discord</button>
</header>
<section class="card" id="sessionCard">
<div>
<h2>Session</h2>
<p id="sessionStatus">Checking your session...</p>
</div>
<div class="banner" id="banner"></div>
<section class="panel">
<h2>Session status</h2>
<p class="muted" id="sessionStatus">Checking your session...</p>
<div class="button-row" style="margin-top: 16px;">
<button class="button button--ghost" id="logoutButton" hidden>Log out</button>
</section>
<section class="card" id="guildCard" hidden>
<div class="card__header">
<div>
<h2>Your guilds</h2>
<p>Select a guild to manage permission overrides and warnings.</p>
</div>
<select id="guildSelect"></select>
</div>
<div class="card__content">
<div class="columns">
<div class="panel">
<h3>Permission overrides</h3>
<p class="muted">Update the JSON payload to configure command permissions.</p>
<textarea id="permissionsEditor" spellcheck="false"></textarea>
<button class="button button--primary" id="updatePermissionsButton">Save permissions</button>
</div>
<div class="panel">
<h3>Roles</h3>
<ul id="rolesList" class="list"></ul>
</div>
</div>
</div>
</section>
<section class="card" id="warningsCard" hidden>
<div class="card__header">
<div>
<h2>Warnings</h2>
<p>Review or add warnings for a specific user in the selected guild.</p>
</div>
</div>
<div class="card__content">
<div class="columns">
<div class="panel">
<h3>Lookup warnings</h3>
<label class="field">
<span>User ID</span>
<input id="warningUserId" type="text" placeholder="123456789" />
</label>
<button class="button" id="loadWarningsButton">Load warnings</button>
<ul id="warningsList" class="list"></ul>
</div>
<div class="panel">
<h3>Add warning</h3>
<label class="field">
<span>User ID</span>
<input id="newWarningUserId" type="text" placeholder="123456789" />
</label>
<label class="field">
<span>Reason</span>
<textarea id="warningReason" placeholder="Reason for warning"></textarea>
</label>
<button class="button button--primary" id="addWarningButton">Add warning</button>
<p class="muted" id="warningResult"></p>
</div>
</div>
</div>
</section>
<section class="card">
<h2>Need help?</h2>
<p>
The dashboard reads permissions from the MongoDB-backed overrides collection. Use it to grant
command access to trusted members in each server.
</p>
</section>
</div>
+234 -121
View File
@@ -1,9 +1,16 @@
:root {
color-scheme: dark;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
background: #0e111b;
color: #eef2ff;
color-scheme: light;
--bg: #0f1218;
--panel: #171b26;
--card: #1e2332;
--border: #2d3447;
--text: #f7f7f9;
--muted: #b3b8c7;
--primary: #5865f2;
--primary-dark: #4752c4;
--success: #3ba55d;
--danger: #ed4245;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
@@ -13,164 +20,270 @@
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #1d2542 0%, #0e111b 55%);
background: var(--bg);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
.app {
max-width: 1100px;
max-width: 1200px;
margin: 0 auto;
padding: 48px 24px 64px;
display: flex;
flex-direction: column;
gap: 24px;
padding: 32px 24px 60px;
}
.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 {
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
margin-bottom: 24px;
}
.card__content {
.topbar__user {
display: flex;
flex-direction: column;
gap: 16px;
gap: 4px;
}
.columns {
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(88, 101, 242, 0.2);
color: #dfe1ff;
font-size: 0.85rem;
}
.banner {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: #22283a;
margin-bottom: 16px;
display: none;
}
.banner--error {
border-color: rgba(237, 66, 69, 0.4);
background: rgba(237, 66, 69, 0.15);
}
.banner--success {
border-color: rgba(59, 165, 93, 0.4);
background: rgba(59, 165, 93, 0.15);
}
.layout {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
grid-template-columns: 280px 1fr;
gap: 24px;
}
.panel {
background: rgba(12, 15, 26, 0.9);
border-radius: 14px;
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
padding: 16px;
border: 1px solid rgba(118, 135, 255, 0.15);
}
.guild-list {
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;
.guild-item {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--card);
border-radius: 12px;
border: 1px solid transparent;
cursor: pointer;
}
.list li {
background: rgba(16, 20, 35, 0.6);
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(118, 135, 255, 0.15);
.guild-item img {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--border);
}
.guild-item.active {
border-color: var(--primary);
}
.section {
margin-bottom: 24px;
}
.section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.tabs {
display: inline-flex;
gap: 8px;
padding: 6px;
background: var(--panel);
border-radius: 12px;
border: 1px solid var(--border);
margin-bottom: 16px;
}
.tab {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.tab.active {
background: var(--primary);
color: white;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.field input,
.field select,
.field textarea {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #131826;
color: var(--text);
}
.field textarea {
min-height: 90px;
resize: vertical;
}
.multi-select {
min-height: 120px;
}
.button {
padding: 10px 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
}
.button--primary {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.button--primary:hover {
background: var(--primary-dark);
}
.button--ghost {
background: transparent;
}
.button-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
font-size: 0.85rem;
}
.chip button {
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.table th,
.table td {
text-align: left;
padding: 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table th {
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
}
.muted {
color: #9aa5e3;
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
@media (max-width: 720px) {
.app__header {
flex-direction: column;
align-items: flex-start;
.loading {
opacity: 0.7;
pointer-events: none;
}
h1 {
font-size: 2rem;
.empty-state {
padding: 24px;
border-radius: 12px;
border: 1px dashed var(--border);
background: #151a27;
color: var(--muted);
}
@media (max-width: 960px) {
.layout {
grid-template-columns: 1fr;
}
}
+37 -4
View File
@@ -6,12 +6,45 @@ const DEFAULT_GUILD_PERMISSIONS = {
commandPermissions: {},
};
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
const normalizeIdArray = (value) => {
if (!Array.isArray(value)) {
return [];
}
const normalized = value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
return Array.from(new Set(normalized));
};
const sanitizeCommandPermissions = (commandPermissions = {}) => {
if (!commandPermissions || typeof commandPermissions !== 'object') {
return {};
}
const sanitized = {};
for (const [commandName, commandConfig] of Object.entries(commandPermissions)) {
if (!COMMAND_NAME_REGEX.test(commandName)) {
continue;
}
sanitized[commandName] = {
allowedRoleIds: normalizeIdArray(commandConfig?.allowedRoleIds),
allowedUserIds: normalizeIdArray(commandConfig?.allowedUserIds),
};
}
return sanitized;
};
const normalizePermissionConfig = (config = {}) => ({
...DEFAULT_GUILD_PERMISSIONS,
...config,
adminRoleIds: Array.isArray(config.adminRoleIds) ? config.adminRoleIds : [],
adminUserIds: Array.isArray(config.adminUserIds) ? config.adminUserIds : [],
commandPermissions: config.commandPermissions ?? {},
adminRoleIds: normalizeIdArray(config.adminRoleIds),
adminUserIds: normalizeIdArray(config.adminUserIds),
commandPermissions: sanitizeCommandPermissions(config.commandPermissions),
});
const memberHasRole = (member, roleIds) =>