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
+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();
});