Merge pull request #14 from Fretuks/improve-discord-bot-dashboard-for-usability
Add web dashboard UI; fix OAuth flow and harden permissions API
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
"warn",
|
||||||
|
"warnings",
|
||||||
|
"ban",
|
||||||
|
"kick",
|
||||||
|
"mute",
|
||||||
|
"unmute"
|
||||||
|
]
|
||||||
+79
-12
@@ -56,6 +56,8 @@ console.log('[oauth config]', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DISCORD_API_BASE = 'https://discord.com/api';
|
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();
|
const sessionStore = new Map();
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ const buildAuthorizationUrl = (state) => {
|
|||||||
prompt: 'consent',
|
prompt: 'consent',
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
|
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDiscordAccessToken = async (code) => {
|
const fetchDiscordAccessToken = async (code) => {
|
||||||
@@ -163,7 +165,7 @@ const fetchDiscordAccessToken = async (code) => {
|
|||||||
redirect_uri: DISCORD_REDIRECT_URI,
|
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: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'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 canManageGuild = (guild) => {
|
||||||
const permissions = parsePermissionBits(guild.permissions);
|
const permissions = parsePermissionBits(guild.permissions);
|
||||||
return (
|
return (
|
||||||
@@ -276,7 +306,7 @@ const createDashboardApp = () => {
|
|||||||
setCookie(res, 'oauth_state', state, {
|
setCookie(res, 'oauth_state', state, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
secure: req.secure,
|
secure: isHttpsRequest(req),
|
||||||
maxAge: 300,
|
maxAge: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,7 +328,7 @@ const createDashboardApp = () => {
|
|||||||
const tokenData = await fetchDiscordAccessToken(code);
|
const tokenData = await fetchDiscordAccessToken(code);
|
||||||
const user = await fetchDiscordUser(tokenData.access_token);
|
const user = await fetchDiscordUser(tokenData.access_token);
|
||||||
|
|
||||||
const {sessionId, expiresAt} = createSession({
|
const session = createSession({
|
||||||
accessToken: tokenData.access_token,
|
accessToken: tokenData.access_token,
|
||||||
refreshToken: tokenData.refresh_token,
|
refreshToken: tokenData.refresh_token,
|
||||||
tokenType: tokenData.token_type,
|
tokenType: tokenData.token_type,
|
||||||
@@ -306,11 +336,11 @@ const createDashboardApp = () => {
|
|||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(res, 'dashboard_session', sessionId, {
|
setCookie(res, 'dashboard_session', session.sessionId, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
secure: req.secure,
|
secure: isHttpsRequest(req),
|
||||||
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
|
maxAge: Math.floor((session.expiresAt - Date.now()) / 1000),
|
||||||
});
|
});
|
||||||
clearCookie(res, 'oauth_state');
|
clearCookie(res, 'oauth_state');
|
||||||
|
|
||||||
@@ -319,7 +349,7 @@ const createDashboardApp = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.accepts('html')) {
|
if (req.accepts('html')) {
|
||||||
return res.redirect('/');
|
return res.redirect('/dashboard.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({user});
|
return res.status(200).json({user});
|
||||||
@@ -327,12 +357,12 @@ const createDashboardApp = () => {
|
|||||||
console.error('Discord OAuth failed', {
|
console.error('Discord OAuth failed', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
data: error.response?.data,
|
data: error.response?.data?.error || error.response?.data?.error_description,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(502).json({
|
return res.status(502).json({
|
||||||
error: 'Failed to authenticate with Discord',
|
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();
|
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) => {
|
app.get('/api/guilds', ensureSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
|
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(
|
app.get(
|
||||||
'/api/guilds/:guildId/warnings/:userId',
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
@@ -408,7 +455,11 @@ const createDashboardApp = () => {
|
|||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
userId: req.params.userId,
|
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) {
|
} catch (error) {
|
||||||
return res.status(500).json({error: 'Failed to fetch warnings'});
|
return res.status(500).json({error: 'Failed to fetch warnings'});
|
||||||
}
|
}
|
||||||
@@ -431,7 +482,23 @@ const createDashboardApp = () => {
|
|||||||
moderatorId: req.session.user?.id,
|
moderatorId: req.session.user?.id,
|
||||||
reason,
|
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) {
|
} catch (error) {
|
||||||
return res.status(500).json({error: 'Failed to add warning'});
|
return res.status(500).json({error: 'Failed to add warning'});
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-188
@@ -1,39 +1,9 @@
|
|||||||
const state = {
|
const fetchJson = async (url, options = {}) => {
|
||||||
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 response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
@@ -41,183 +11,85 @@ const fetchJson = async (url, options) => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
const message = body.error || `Request failed with status ${response.status}`;
|
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(() => ({}));
|
return response.json().catch(() => ({}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadGuilds = async () => {
|
const setBanner = (bannerEl, message, type = 'error') => {
|
||||||
const data = await fetchJson('/api/guilds');
|
if (!bannerEl) {
|
||||||
state.guilds = data.guilds || [];
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
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) => {
|
const clearBanner = (bannerEl) => {
|
||||||
elements.rolesList.innerHTML = '';
|
if (!bannerEl) {
|
||||||
roles.forEach((role) => {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
bannerEl.textContent = '';
|
||||||
|
bannerEl.style.display = 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadGuildDetails = async () => {
|
const formatUserDisplay = (user) => {
|
||||||
if (!state.selectedGuildId) {
|
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;
|
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 {
|
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) {
|
} catch (error) {
|
||||||
elements.permissionsEditor.focus();
|
sessionStatus.textContent = 'You are not signed in yet.';
|
||||||
throw new Error('Permissions JSON is invalid.');
|
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`, {
|
logoutButton?.addEventListener('click', async () => {
|
||||||
method: 'PATCH',
|
try {
|
||||||
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 () => {
|
|
||||||
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
||||||
setStatus('Logged out. Sign in to manage guilds.', false);
|
sessionStatus.textContent = 'Logged out successfully.';
|
||||||
});
|
loginButton.hidden = false;
|
||||||
|
dashboardButton.hidden = true;
|
||||||
elements.guildSelect.addEventListener('change', async (event) => {
|
logoutButton.hidden = true;
|
||||||
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) {
|
} 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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -8,90 +8,26 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="app__header">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Discord Bot</p>
|
<div class="badge">Discord Bot Dashboard</div>
|
||||||
<h1>Moderation Dashboard</h1>
|
<h1>Manage your moderation settings</h1>
|
||||||
<p class="subhead">
|
<p class="muted">Configure permissions and warnings without touching JSON.</p>
|
||||||
Manage permissions, review roles, and track warnings without leaving your browser.
|
</div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<button class="button button--primary" id="loginButton">Log in with Discord</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card" id="sessionCard">
|
<div class="banner" id="banner"></div>
|
||||||
<div>
|
|
||||||
<h2>Session</h2>
|
<section class="panel">
|
||||||
<p id="sessionStatus">Checking your session...</p>
|
<h2>Session status</h2>
|
||||||
</div>
|
<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>
|
<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>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+234
-121
@@ -1,9 +1,16 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
--bg: #0f1218;
|
||||||
line-height: 1.5;
|
--panel: #171b26;
|
||||||
background: #0e111b;
|
--card: #1e2332;
|
||||||
color: #eef2ff;
|
--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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
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 {
|
.app {
|
||||||
max-width: 1100px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px 24px 64px;
|
padding: 32px 24px 60px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app__header {
|
.topbar {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
display: grid;
|
||||||
gap: 16px;
|
grid-template-columns: 280px 1fr;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: rgba(12, 15, 26, 0.9);
|
background: var(--panel);
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
}
|
||||||
|
|
||||||
|
.guild-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.guild-item {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li {
|
.guild-item img {
|
||||||
background: rgba(16, 20, 35, 0.6);
|
width: 36px;
|
||||||
padding: 8px 12px;
|
height: 36px;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
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 {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
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;
|
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 {
|
.muted {
|
||||||
color: #9aa5e3;
|
color: var(--muted);
|
||||||
margin: 0;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
.loading {
|
||||||
.app__header {
|
opacity: 0.7;
|
||||||
flex-direction: column;
|
pointer-events: none;
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.empty-state {
|
||||||
font-size: 2rem;
|
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
@@ -6,12 +6,45 @@ const DEFAULT_GUILD_PERMISSIONS = {
|
|||||||
commandPermissions: {},
|
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 = {}) => ({
|
const normalizePermissionConfig = (config = {}) => ({
|
||||||
...DEFAULT_GUILD_PERMISSIONS,
|
...DEFAULT_GUILD_PERMISSIONS,
|
||||||
...config,
|
adminRoleIds: normalizeIdArray(config.adminRoleIds),
|
||||||
adminRoleIds: Array.isArray(config.adminRoleIds) ? config.adminRoleIds : [],
|
adminUserIds: normalizeIdArray(config.adminUserIds),
|
||||||
adminUserIds: Array.isArray(config.adminUserIds) ? config.adminUserIds : [],
|
commandPermissions: sanitizeCommandPermissions(config.commandPermissions),
|
||||||
commandPermissions: config.commandPermissions ?? {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const memberHasRole = (member, roleIds) =>
|
const memberHasRole = (member, roleIds) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user