Add dashboard UI and secure guild permissions
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_OAUTH_BASE = 'https://discord.com/oauth2';
|
||||
const COMMANDS_PATH = path.join(__dirname, 'commands.json');
|
||||
|
||||
const sessionStore = new Map();
|
||||
|
||||
@@ -151,7 +153,7 @@ const buildAuthorizationUrl = (state) => {
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
|
||||
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
const fetchDiscordAccessToken = async (code) => {
|
||||
@@ -163,7 +165,7 @@ const fetchDiscordAccessToken = async (code) => {
|
||||
redirect_uri: DISCORD_REDIRECT_URI,
|
||||
});
|
||||
|
||||
const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params, {
|
||||
const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
@@ -218,6 +220,34 @@ const parsePermissionBits = (permissionValue) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isHttpsRequest = (req) => {
|
||||
if (req.secure) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
if (typeof forwardedProto === 'string' && forwardedProto.split(',')[0].trim() === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(config.cookieSecure);
|
||||
};
|
||||
|
||||
const loadCommands = () => {
|
||||
if (!fs.existsSync(COMMANDS_PATH)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load commands.json', {message: error.message});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const canManageGuild = (guild) => {
|
||||
const permissions = parsePermissionBits(guild.permissions);
|
||||
return (
|
||||
@@ -276,7 +306,7 @@ const createDashboardApp = () => {
|
||||
setCookie(res, 'oauth_state', state, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: req.secure,
|
||||
secure: isHttpsRequest(req),
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
@@ -298,7 +328,7 @@ const createDashboardApp = () => {
|
||||
const tokenData = await fetchDiscordAccessToken(code);
|
||||
const user = await fetchDiscordUser(tokenData.access_token);
|
||||
|
||||
const {sessionId, expiresAt} = createSession({
|
||||
const session = createSession({
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
tokenType: tokenData.token_type,
|
||||
@@ -306,11 +336,11 @@ const createDashboardApp = () => {
|
||||
user,
|
||||
});
|
||||
|
||||
setCookie(res, 'dashboard_session', sessionId, {
|
||||
setCookie(res, 'dashboard_session', session.sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: req.secure,
|
||||
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
|
||||
secure: isHttpsRequest(req),
|
||||
maxAge: Math.floor((session.expiresAt - Date.now()) / 1000),
|
||||
});
|
||||
clearCookie(res, 'oauth_state');
|
||||
|
||||
@@ -319,7 +349,7 @@ const createDashboardApp = () => {
|
||||
}
|
||||
|
||||
if (req.accepts('html')) {
|
||||
return res.redirect('/');
|
||||
return res.redirect('/dashboard.html');
|
||||
}
|
||||
|
||||
return res.status(200).json({user});
|
||||
@@ -327,12 +357,12 @@ const createDashboardApp = () => {
|
||||
console.error('Discord OAuth failed', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
data: error.response?.data?.error || error.response?.data?.error_description,
|
||||
});
|
||||
|
||||
return res.status(502).json({
|
||||
error: 'Failed to authenticate with Discord',
|
||||
details: error.response?.data || error.message,
|
||||
details: error.response?.data?.error_description || error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -343,6 +373,13 @@ const createDashboardApp = () => {
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/me', ensureSession, (req, res) => {
|
||||
return res.json({
|
||||
user: req.session.user,
|
||||
expiresAt: req.session.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/guilds', ensureSession, async (req, res) => {
|
||||
try {
|
||||
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
|
||||
@@ -398,6 +435,16 @@ const createDashboardApp = () => {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/api/guilds/:guildId/commands',
|
||||
ensureSession,
|
||||
ensureManageableGuild,
|
||||
(req, res) => {
|
||||
const commands = loadCommands();
|
||||
return res.json({commands});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/api/guilds/:guildId/warnings/:userId',
|
||||
ensureSession,
|
||||
@@ -408,7 +455,11 @@ const createDashboardApp = () => {
|
||||
guildId: req.params.guildId,
|
||||
userId: req.params.userId,
|
||||
});
|
||||
return res.json({warnings});
|
||||
const normalizedWarnings = warnings.map((warning) => ({
|
||||
...warning,
|
||||
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
|
||||
}));
|
||||
return res.json({warnings: normalizedWarnings});
|
||||
} catch (error) {
|
||||
return res.status(500).json({error: 'Failed to fetch warnings'});
|
||||
}
|
||||
@@ -431,7 +482,23 @@ const createDashboardApp = () => {
|
||||
moderatorId: req.session.user?.id,
|
||||
reason,
|
||||
});
|
||||
return res.status(201).json(result);
|
||||
const warnings = (result.warnings || []).map((warning) => ({
|
||||
...warning,
|
||||
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
|
||||
}));
|
||||
const warningEntry = result.warningEntry
|
||||
? {
|
||||
...result.warningEntry,
|
||||
createdAt: result.warningEntry.createdAt
|
||||
? new Date(result.warningEntry.createdAt).toISOString()
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
return res.status(201).json({
|
||||
...result,
|
||||
warnings,
|
||||
warningEntry,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({error: 'Failed to add warning'});
|
||||
}
|
||||
|
||||
+60
-188
@@ -1,39 +1,9 @@
|
||||
const state = {
|
||||
guilds: [],
|
||||
selectedGuildId: null,
|
||||
};
|
||||
|
||||
const elements = {
|
||||
loginButton: document.getElementById('loginButton'),
|
||||
logoutButton: document.getElementById('logoutButton'),
|
||||
sessionStatus: document.getElementById('sessionStatus'),
|
||||
guildCard: document.getElementById('guildCard'),
|
||||
warningsCard: document.getElementById('warningsCard'),
|
||||
guildSelect: document.getElementById('guildSelect'),
|
||||
permissionsEditor: document.getElementById('permissionsEditor'),
|
||||
updatePermissionsButton: document.getElementById('updatePermissionsButton'),
|
||||
rolesList: document.getElementById('rolesList'),
|
||||
warningUserId: document.getElementById('warningUserId'),
|
||||
loadWarningsButton: document.getElementById('loadWarningsButton'),
|
||||
warningsList: document.getElementById('warningsList'),
|
||||
newWarningUserId: document.getElementById('newWarningUserId'),
|
||||
warningReason: document.getElementById('warningReason'),
|
||||
addWarningButton: document.getElementById('addWarningButton'),
|
||||
warningResult: document.getElementById('warningResult'),
|
||||
};
|
||||
|
||||
const setStatus = (message, isAuthed) => {
|
||||
elements.sessionStatus.textContent = message;
|
||||
elements.logoutButton.hidden = !isAuthed;
|
||||
elements.guildCard.hidden = !isAuthed;
|
||||
elements.warningsCard.hidden = !isAuthed;
|
||||
};
|
||||
|
||||
const fetchJson = async (url, options) => {
|
||||
const fetchJson = async (url, options = {}) => {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -41,183 +11,85 @@ const fetchJson = async (url, options) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const message = body.error || `Request failed with status ${response.status}`;
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
error.details = body.details;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(() => ({}));
|
||||
};
|
||||
|
||||
const loadGuilds = async () => {
|
||||
const data = await fetchJson('/api/guilds');
|
||||
state.guilds = data.guilds || [];
|
||||
elements.guildSelect.innerHTML = '';
|
||||
|
||||
state.guilds.forEach((guild) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = guild.id;
|
||||
option.textContent = guild.name;
|
||||
elements.guildSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (state.guilds.length > 0) {
|
||||
state.selectedGuildId = state.guilds[0].id;
|
||||
elements.guildSelect.value = state.selectedGuildId;
|
||||
await loadGuildDetails();
|
||||
const setBanner = (bannerEl, message, type = 'error') => {
|
||||
if (!bannerEl) {
|
||||
return;
|
||||
}
|
||||
bannerEl.textContent = message;
|
||||
bannerEl.classList.remove('banner--error', 'banner--success');
|
||||
bannerEl.classList.add(type === 'success' ? 'banner--success' : 'banner--error');
|
||||
bannerEl.style.display = 'block';
|
||||
};
|
||||
|
||||
const renderRoles = (roles) => {
|
||||
elements.rolesList.innerHTML = '';
|
||||
roles.forEach((role) => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = `${role.name} (${role.id})`;
|
||||
elements.rolesList.appendChild(item);
|
||||
});
|
||||
|
||||
if (roles.length === 0) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = 'No roles returned.';
|
||||
elements.rolesList.appendChild(item);
|
||||
const clearBanner = (bannerEl) => {
|
||||
if (!bannerEl) {
|
||||
return;
|
||||
}
|
||||
bannerEl.textContent = '';
|
||||
bannerEl.style.display = 'none';
|
||||
};
|
||||
|
||||
const loadGuildDetails = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
const formatUserDisplay = (user) => {
|
||||
if (!user) {
|
||||
return 'Not signed in';
|
||||
}
|
||||
return `${user.username}#${user.discriminator ?? '0000'}`;
|
||||
};
|
||||
|
||||
const initIndexPage = async () => {
|
||||
const sessionStatus = document.getElementById('sessionStatus');
|
||||
const loginButton = document.getElementById('loginButton');
|
||||
const dashboardButton = document.getElementById('dashboardButton');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
const banner = document.getElementById('banner');
|
||||
|
||||
if (!sessionStatus || !loginButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [permissionsData, rolesData] = await Promise.all([
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`),
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/roles`),
|
||||
]);
|
||||
|
||||
elements.permissionsEditor.value = JSON.stringify(permissionsData.permissions || {}, null, 2);
|
||||
renderRoles(rolesData.roles || []);
|
||||
};
|
||||
|
||||
const updatePermissions = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawValue = elements.permissionsEditor.value.trim();
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(rawValue);
|
||||
const data = await fetchJson('/api/me');
|
||||
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
||||
loginButton.hidden = true;
|
||||
dashboardButton.hidden = false;
|
||||
logoutButton.hidden = false;
|
||||
clearBanner(banner);
|
||||
} catch (error) {
|
||||
elements.permissionsEditor.focus();
|
||||
throw new Error('Permissions JSON is invalid.');
|
||||
sessionStatus.textContent = 'You are not signed in yet.';
|
||||
loginButton.hidden = false;
|
||||
dashboardButton.hidden = true;
|
||||
logoutButton.hidden = true;
|
||||
setBanner(banner, 'Log in with Discord to access the dashboard.', 'error');
|
||||
}
|
||||
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
elements.permissionsEditor.value = JSON.stringify(data.permissions || {}, null, 2);
|
||||
};
|
||||
|
||||
const loadWarnings = async (userId) => {
|
||||
if (!state.selectedGuildId || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
|
||||
elements.warningsList.innerHTML = '';
|
||||
|
||||
const warnings = data.warnings || [];
|
||||
if (warnings.length === 0) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = 'No warnings found.';
|
||||
elements.warningsList.appendChild(item);
|
||||
return;
|
||||
}
|
||||
|
||||
warnings.forEach((warning) => {
|
||||
const item = document.createElement('li');
|
||||
const issuedAt = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
|
||||
item.textContent = `${warning.reason} — ${issuedAt}`;
|
||||
elements.warningsList.appendChild(item);
|
||||
});
|
||||
};
|
||||
|
||||
const addWarning = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = elements.newWarningUserId.value.trim();
|
||||
const reason = elements.warningReason.value.trim();
|
||||
|
||||
if (!userId || !reason) {
|
||||
elements.warningResult.textContent = 'User ID and reason are required.';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
elements.warningResult.textContent = `Warning added: ${data.warning?.reason || reason}`;
|
||||
elements.warningReason.value = '';
|
||||
};
|
||||
|
||||
const showError = (message) => {
|
||||
elements.warningResult.textContent = message;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
elements.loginButton.addEventListener('click', () => {
|
||||
window.location.href = '/auth/discord';
|
||||
});
|
||||
|
||||
elements.logoutButton.addEventListener('click', async () => {
|
||||
logoutButton?.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
||||
setStatus('Logged out. Sign in to manage guilds.', false);
|
||||
});
|
||||
|
||||
elements.guildSelect.addEventListener('change', async (event) => {
|
||||
state.selectedGuildId = event.target.value;
|
||||
await loadGuildDetails();
|
||||
});
|
||||
|
||||
elements.updatePermissionsButton.addEventListener('click', async () => {
|
||||
elements.warningResult.textContent = '';
|
||||
try {
|
||||
await updatePermissions();
|
||||
elements.warningResult.textContent = 'Permissions updated successfully.';
|
||||
sessionStatus.textContent = 'Logged out successfully.';
|
||||
loginButton.hidden = false;
|
||||
dashboardButton.hidden = true;
|
||||
logoutButton.hidden = true;
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setBanner(banner, error.message || 'Failed to log out.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
elements.loadWarningsButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadWarnings(elements.warningUserId.value.trim());
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.addWarningButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await addWarning();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await loadGuilds();
|
||||
setStatus('Authenticated. Choose a guild to get started.', true);
|
||||
} catch (error) {
|
||||
setStatus('Sign in with Discord to manage your guilds.', false);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initIndexPage();
|
||||
});
|
||||
|
||||
window.dashboardApi = {
|
||||
fetchJson,
|
||||
setBanner,
|
||||
clearBanner,
|
||||
formatUserDisplay,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="app__header">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Discord Bot</p>
|
||||
<h1>Moderation Dashboard</h1>
|
||||
<p class="subhead">
|
||||
Manage permissions, review roles, and track warnings without leaving your browser.
|
||||
</p>
|
||||
<div class="badge">Discord Bot Dashboard</div>
|
||||
<h1>Manage your moderation settings</h1>
|
||||
<p class="muted">Configure permissions and warnings without touching JSON.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="button button--primary" href="/auth/discord" id="loginButton">Log in with Discord</a>
|
||||
<a class="button" href="/dashboard.html" id="dashboardButton" hidden>Open dashboard</a>
|
||||
</div>
|
||||
<button class="button button--primary" id="loginButton">Log in with Discord</button>
|
||||
</header>
|
||||
|
||||
<section class="card" id="sessionCard">
|
||||
<div>
|
||||
<h2>Session</h2>
|
||||
<p id="sessionStatus">Checking your session...</p>
|
||||
</div>
|
||||
<div class="banner" id="banner"></div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Session status</h2>
|
||||
<p class="muted" id="sessionStatus">Checking your session...</p>
|
||||
<div class="button-row" style="margin-top: 16px;">
|
||||
<button class="button button--ghost" id="logoutButton" hidden>Log out</button>
|
||||
</section>
|
||||
|
||||
<section class="card" id="guildCard" hidden>
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<h2>Your guilds</h2>
|
||||
<p>Select a guild to manage permission overrides and warnings.</p>
|
||||
</div>
|
||||
<select id="guildSelect"></select>
|
||||
</div>
|
||||
<div class="card__content">
|
||||
<div class="columns">
|
||||
<div class="panel">
|
||||
<h3>Permission overrides</h3>
|
||||
<p class="muted">Update the JSON payload to configure command permissions.</p>
|
||||
<textarea id="permissionsEditor" spellcheck="false"></textarea>
|
||||
<button class="button button--primary" id="updatePermissionsButton">Save permissions</button>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Roles</h3>
|
||||
<ul id="rolesList" class="list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="warningsCard" hidden>
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<h2>Warnings</h2>
|
||||
<p>Review or add warnings for a specific user in the selected guild.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__content">
|
||||
<div class="columns">
|
||||
<div class="panel">
|
||||
<h3>Lookup warnings</h3>
|
||||
<label class="field">
|
||||
<span>User ID</span>
|
||||
<input id="warningUserId" type="text" placeholder="123456789" />
|
||||
</label>
|
||||
<button class="button" id="loadWarningsButton">Load warnings</button>
|
||||
<ul id="warningsList" class="list"></ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Add warning</h3>
|
||||
<label class="field">
|
||||
<span>User ID</span>
|
||||
<input id="newWarningUserId" type="text" placeholder="123456789" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reason</span>
|
||||
<textarea id="warningReason" placeholder="Reason for warning"></textarea>
|
||||
</label>
|
||||
<button class="button button--primary" id="addWarningButton">Add warning</button>
|
||||
<p class="muted" id="warningResult"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Need help?</h2>
|
||||
<p>
|
||||
The dashboard reads permissions from the MongoDB-backed overrides collection. Use it to grant
|
||||
command access to trusted members in each server.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
+234
-121
@@ -1,9 +1,16 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
background: #0e111b;
|
||||
color: #eef2ff;
|
||||
color-scheme: light;
|
||||
--bg: #0f1218;
|
||||
--panel: #171b26;
|
||||
--card: #1e2332;
|
||||
--border: #2d3447;
|
||||
--text: #f7f7f9;
|
||||
--muted: #b3b8c7;
|
||||
--primary: #5865f2;
|
||||
--primary-dark: #4752c4;
|
||||
--success: #3ba55d;
|
||||
--danger: #ed4245;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -13,164 +20,270 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #1d2542 0%, #0e111b 55%);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 24px 60px;
|
||||
}
|
||||
|
||||
.app__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
background: rgba(17, 23, 40, 0.9);
|
||||
border: 1px solid rgba(118, 135, 255, 0.2);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.2rem;
|
||||
color: #8b9cff;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
color: #b7c0ff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(17, 23, 40, 0.95);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card__content {
|
||||
.topbar__user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(88, 101, 242, 0.2);
|
||||
color: #dfe1ff;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #22283a;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner--error {
|
||||
border-color: rgba(237, 66, 69, 0.4);
|
||||
background: rgba(237, 66, 69, 0.15);
|
||||
}
|
||||
|
||||
.banner--success {
|
||||
border-color: rgba(59, 165, 93, 0.4);
|
||||
background: rgba(59, 165, 93, 0.15);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(12, 15, 26, 0.9);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
||||
}
|
||||
|
||||
.guild-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.3);
|
||||
background: rgba(16, 20, 35, 0.8);
|
||||
color: #f5f7ff;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(34, 44, 90, 0.4);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: linear-gradient(135deg, #5f6bff, #8a5bff);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.3);
|
||||
background: rgba(6, 8, 16, 0.6);
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.guild-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list li {
|
||||
background: rgba(16, 20, 35, 0.6);
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
||||
.guild-item img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.guild-item.active {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #131826;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.multi-select {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chip button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9aa5e3;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
.loading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
background: #151a27;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+37
-4
@@ -6,12 +6,45 @@ const DEFAULT_GUILD_PERMISSIONS = {
|
||||
commandPermissions: {},
|
||||
};
|
||||
|
||||
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
|
||||
|
||||
const normalizeIdArray = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
return Array.from(new Set(normalized));
|
||||
};
|
||||
|
||||
const sanitizeCommandPermissions = (commandPermissions = {}) => {
|
||||
if (!commandPermissions || typeof commandPermissions !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitized = {};
|
||||
for (const [commandName, commandConfig] of Object.entries(commandPermissions)) {
|
||||
if (!COMMAND_NAME_REGEX.test(commandName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized[commandName] = {
|
||||
allowedRoleIds: normalizeIdArray(commandConfig?.allowedRoleIds),
|
||||
allowedUserIds: normalizeIdArray(commandConfig?.allowedUserIds),
|
||||
};
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const normalizePermissionConfig = (config = {}) => ({
|
||||
...DEFAULT_GUILD_PERMISSIONS,
|
||||
...config,
|
||||
adminRoleIds: Array.isArray(config.adminRoleIds) ? config.adminRoleIds : [],
|
||||
adminUserIds: Array.isArray(config.adminUserIds) ? config.adminUserIds : [],
|
||||
commandPermissions: config.commandPermissions ?? {},
|
||||
adminRoleIds: normalizeIdArray(config.adminRoleIds),
|
||||
adminUserIds: normalizeIdArray(config.adminUserIds),
|
||||
commandPermissions: sanitizeCommandPermissions(config.commandPermissions),
|
||||
});
|
||||
|
||||
const memberHasRole = (member, roleIds) =>
|
||||
|
||||
Reference in New Issue
Block a user