Add dashboard UI and secure guild permissions
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user