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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Command |
+ Allowed roles |
+ Allowed users |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No warnings found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-