Merge pull request #12 from Fretuks/main

Implement alot of changes, adding backend and frontend for the bot dashboard.
This commit is contained in:
2026-02-03 11:14:35 +01:00
committed by GitHub
11 changed files with 2789 additions and 387 deletions
+8
View File
@@ -0,0 +1,8 @@
[
"warn",
"warnings",
"ban",
"kick",
"mute",
"unmute"
]
+317 -24
View File
@@ -3,7 +3,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const axios = require('axios'); const axios = require('axios');
const express = require('express'); const express = require('express');
const {addWarning, getWarnings} = require('./services/warnings'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings');
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const PERMISSION_BITS = { const PERMISSION_BITS = {
@@ -38,6 +38,10 @@ const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN ||
config.discordBotToken || config.discordBotToken ||
config.token; config.token;
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
10,
);
const DASHBOARD_CLIENT_URL = const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL || process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl || config.dashboardClientUrl ||
@@ -56,8 +60,17 @@ 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 GUILD_CACHE_TTL_MS = 5 * 60 * 1000;
const BOT_GUILD_CACHE_TTL_MS = 60 * 1000;
const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.';
const sessionStore = new Map(); const sessionStore = new Map();
const botGuildCache = {
guildIds: null,
fetchedAt: 0,
};
const parseCookies = (headerValue) => { const parseCookies = (headerValue) => {
const cookies = {}; const cookies = {};
@@ -116,12 +129,10 @@ const getSession = (sessionId) => {
if (!session) { if (!session) {
return null; return null;
} }
if (session.expiresAt <= Date.now()) { if (session.expiresAt <= Date.now()) {
sessionStore.delete(sessionId); sessionStore.delete(sessionId);
return null; return null;
} }
return session; return session;
}; };
@@ -133,7 +144,6 @@ const ensureOAuthConfig = () => {
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) { if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) {
return null; return null;
} }
return { return {
clientId: DISCORD_CLIENT_ID, clientId: DISCORD_CLIENT_ID,
clientSecret: DISCORD_CLIENT_SECRET, clientSecret: DISCORD_CLIENT_SECRET,
@@ -141,6 +151,22 @@ const ensureOAuthConfig = () => {
}; };
}; };
const getDiscordStatusCode = (error) => {
const status = error?.response?.status;
return typeof status === 'number' ? status : null;
};
const sendDiscordApiError = (res, error, fallbackMessage) => {
const status = getDiscordStatusCode(error);
if (status === 401 || status === 403) {
return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'});
}
if (status === 429) {
return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'});
}
return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage});
};
const buildAuthorizationUrl = (state) => { const buildAuthorizationUrl = (state) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID, client_id: DISCORD_CLIENT_ID,
@@ -150,8 +176,7 @@ const buildAuthorizationUrl = (state) => {
state, state,
prompt: 'consent', prompt: 'consent',
}); });
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
}; };
const fetchDiscordAccessToken = async (code) => { const fetchDiscordAccessToken = async (code) => {
@@ -163,7 +188,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',
}, },
@@ -194,7 +219,7 @@ const fetchDiscordUserGuilds = async (accessToken) => {
const fetchDiscordGuildRoles = async (guildId) => { const fetchDiscordGuildRoles = async (guildId) => {
if (!DISCORD_BOT_TOKEN) { if (!DISCORD_BOT_TOKEN) {
throw new Error('Missing Discord bot token.'); throw new Error(BOT_TOKEN_ERROR_MESSAGE);
} }
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, { const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
@@ -206,6 +231,94 @@ const fetchDiscordGuildRoles = async (guildId) => {
return response.data; return response.data;
}; };
const fetchDiscordGuildMembersSearch = async (guildId, query) => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/members/search`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
params: {
query,
limit: 20,
},
});
return response.data;
};
const fetchDiscordBotGuilds = async () => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const getCachedBotGuildIds = () => {
if (!Array.isArray(botGuildCache.guildIds)) {
return null;
}
if (!botGuildCache.fetchedAt) {
return null;
}
if (Date.now() - botGuildCache.fetchedAt > BOT_GUILD_CACHE_TTL_MS) {
return null;
}
return botGuildCache.guildIds;
};
const setCachedBotGuildIds = (guildIds) => {
botGuildCache.guildIds = guildIds;
botGuildCache.fetchedAt = Date.now();
};
const buildInviteUrl = (guildId) => {
if (!DISCORD_CLIENT_ID) {
return null;
}
const permissions = Number.isNaN(DASHBOARD_INVITE_PERMISSIONS) ? 0 : DASHBOARD_INVITE_PERMISSIONS;
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
scope: 'bot applications.commands',
permissions: String(permissions),
disable_guild_select: 'true',
});
if (guildId) {
params.set('guild_id', guildId);
}
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const cacheSessionGuilds = (session, guilds) => {
session.guilds = guilds;
session.guildsFetchedAt = Date.now();
};
const getCachedGuilds = (session) => {
if (!Array.isArray(session.guilds)) {
return null;
}
if (!session.guildsFetchedAt) {
return null;
}
if (Date.now() - session.guildsFetchedAt > GUILD_CACHE_TTL_MS) {
return null;
}
return session.guilds;
};
const parsePermissionBits = (permissionValue) => { const parsePermissionBits = (permissionValue) => {
if (!permissionValue) { if (!permissionValue) {
return 0n; return 0n;
@@ -218,6 +331,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 (
@@ -245,7 +386,11 @@ const ensureSession = (req, res, next) => {
const ensureManageableGuild = async (req, res, next) => { const ensureManageableGuild = async (req, res, next) => {
try { try {
const guilds = await fetchDiscordUserGuilds(req.session.accessToken); let guilds = getCachedGuilds(req.session);
if (!guilds) {
guilds = await fetchDiscordUserGuilds(req.session.accessToken);
cacheSessionGuilds(req.session, guilds);
}
const guild = guilds.find((entry) => entry.id === req.params.guildId); const guild = guilds.find((entry) => entry.id === req.params.guildId);
if (!guild) { if (!guild) {
return res.status(404).json({error: 'Guild not found'}); return res.status(404).json({error: 'Guild not found'});
@@ -257,7 +402,7 @@ const ensureManageableGuild = async (req, res, next) => {
req.guild = guild; req.guild = guild;
return next(); return next();
} catch (error) { } catch (error) {
return res.status(502).json({error: 'Failed to fetch guilds'}); return sendDiscordApiError(res, error, 'Failed to fetch guilds');
} }
}; };
@@ -276,7 +421,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 +443,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 +451,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 +464,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 +472,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,16 +488,50 @@ 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);
cacheSessionGuilds(req.session, guilds);
const manageableGuilds = guilds.filter(canManageGuild); const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds}); return res.json({guilds: manageableGuilds});
} catch (error) { } catch (error) {
return res.status(502).json({error: 'Failed to fetch guilds'}); return sendDiscordApiError(res, error, 'Failed to fetch guilds');
} }
}); });
app.get('/api/bot/guilds', ensureSession, async (req, res) => {
try {
const cached = getCachedBotGuildIds();
if (cached) {
return res.json({guildIds: cached});
}
const guilds = await fetchDiscordBotGuilds();
const guildIds = Array.isArray(guilds) ? guilds.map((guild) => guild.id) : [];
setCachedBotGuildIds(guildIds);
return res.json({guildIds});
} catch (error) {
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch bot guilds');
}
});
app.get('/api/invite-url', ensureSession, (req, res) => {
if (!DISCORD_CLIENT_ID) {
return res.status(500).json({error: 'MISSING_CLIENT_ID', message: 'Discord client ID not configured.'});
}
const inviteUrl = buildInviteUrl(req.query.guildId);
return res.json({inviteUrl});
});
app.get( app.get(
'/api/guilds/:guildId/permissions', '/api/guilds/:guildId/permissions',
ensureSession, ensureSession,
@@ -362,7 +541,11 @@ const createDashboardApp = () => {
const permissions = await getGuildPermissions(req.params.guildId); const permissions = await getGuildPermissions(req.params.guildId);
return res.json({permissions}); return res.json({permissions});
} catch (error) { } catch (error) {
return res.status(500).json({error: 'Failed to fetch permissions'}); console.error('Failed to fetch guild permissions', {
guildId: req.params.guildId,
message: error.message,
});
return res.status(503).json({error: 'Permissions store unavailable'});
} }
}, },
); );
@@ -379,7 +562,11 @@ const createDashboardApp = () => {
}); });
return res.json({permissions}); return res.json({permissions});
} catch (error) { } catch (error) {
return res.status(500).json({error: 'Failed to update permissions'}); console.error('Failed to update guild permissions', {
guildId: req.params.guildId,
message: error.message,
});
return res.status(503).json({error: 'Permissions store unavailable'});
} }
}, },
); );
@@ -393,8 +580,77 @@ const createDashboardApp = () => {
const roles = await fetchDiscordGuildRoles(req.params.guildId); const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles}); return res.json({roles});
} catch (error) { } catch (error) {
return res.status(500).json({error: 'Failed to fetch roles'}); const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
} }
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch roles');
}
},
);
app.get(
'/api/guilds/:guildId/members',
ensureSession,
ensureManageableGuild,
async (req, res) => {
const query = typeof req.query.query === 'string' ? req.query.query.trim() : '';
if (query.length < 2) {
return res.json({members: []});
}
try {
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
const normalized = (members || [])
.map((member) => {
const user = member.user || {};
if (!user.id) {
return null;
}
return {
id: user.id,
user: {
id: user.id,
username: user.username || '',
discriminator: user.discriminator || '',
global_name: user.global_name || '',
avatar: user.avatar || '',
},
nick: member.nick || '',
};
})
.filter(Boolean);
return res.json({members: normalized});
} catch (error) {
const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
}
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to search guild members');
}
},
);
app.get(
'/api/guilds/:guildId/commands',
ensureSession,
ensureManageableGuild,
(req, res) => {
const commands = loadCommands();
return res.json({commands});
}, },
); );
@@ -408,7 +664,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,12 +691,45 @@ 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'});
} }
}, },
); );
app.delete(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const warnings = await clearWarnings({
guildId: req.params.guildId,
userId: req.params.userId,
});
return res.json({warnings});
} catch (error) {
return res.status(500).json({error: 'Failed to clear warnings'});
}
},
);
return app; return app;
}; };
-7
View File
@@ -2,7 +2,6 @@ const path = require('node:path');
const { REST, Routes } = require('discord.js'); const { REST, Routes } = require('discord.js');
const { clientId, token } = require('./config.json'); const { clientId, token } = require('./config.json');
const { loadCommandModules } = require('./loaders/commands'); const { loadCommandModules } = require('./loaders/commands');
const commandsPath = path.join(__dirname, 'commands'); const commandsPath = path.join(__dirname, 'commands');
const { commands, invalidCommands } = loadCommandModules(commandsPath); const { commands, invalidCommands } = loadCommandModules(commandsPath);
const commandsData = commands.map((command) => command.data.toJSON()); const commandsData = commands.map((command) => command.data.toJSON());
@@ -11,23 +10,17 @@ for (const filePath of invalidCommands) {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
} }
// Construct and prepare an instance of the REST module
const rest = new REST().setToken(token); const rest = new REST().setToken(token);
// and deploy your commands!
(async () => { (async () => {
try { try {
console.log(`Started refreshing ${commandsData.length} application (/) commands.`); console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put( const data = await rest.put(
Routes.applicationCommands(clientId), Routes.applicationCommands(clientId),
{ body: commandsData }, { body: commandsData },
); );
console.log(`Successfully reloaded ${data.length} application (/) commands.`); console.log(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) { } catch (error) {
// And of course, make sure you catch and log any errors!
console.error(error); console.error(error);
} }
})(); })();
+204 -1
View File
@@ -11,8 +11,13 @@
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"discord.js": "^14.17.3", "discord.js": "^14.17.3",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"openai": "^4.67.3", "openai": "^4.67.3",
@@ -393,6 +398,18 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -468,6 +485,12 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -669,6 +692,23 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/connect-mongo": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz",
"integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"kruptein": "^3.0.0"
},
"engines": {
"node": ">=12.9.0"
},
"peerDependencies": {
"express-session": "^1.17.1",
"mongodb": ">= 5.1.0 < 7"
}
},
"node_modules/console-control-strings": { "node_modules/console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -706,18 +746,56 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -1001,6 +1079,74 @@
"node": ">= 0.10.0" "node": ">= 0.10.0"
} }
}, },
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express/node_modules/debug": { "node_modules/express/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1507,6 +1653,18 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/kruptein": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.1.7.tgz",
"integrity": "sha512-Wk3WFZdGtjnTBGlC12SiBsEh51+6lsW7sSRm+MzRVRc3WRPrUQGu4+/n3YQjSPp8x1AJJxPxgLsekFXLiBN2PQ==",
"license": "MIT",
"dependencies": {
"asn1.js": "^5.4.1"
},
"engines": {
"node": ">8"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1644,6 +1802,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2014,6 +2178,15 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.2", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
@@ -2038,6 +2211,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -2229,6 +2411,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -2783,6 +2974,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undici": { "node_modules/undici": {
"version": "6.19.8", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
+120 -184
View File
@@ -1,223 +1,159 @@
const state = { const createFetchError = async (response) => {
guilds: [], let payload = {};
selectedGuildId: null, try {
payload = await response.json();
} catch (error) {
payload = {};
}
const message = payload.message || payload.error || `Request failed with status ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.code = payload.error;
error.details = payload.details;
error.payload = payload;
return error;
}; };
const elements = { const fetchJson = async (url, options = {}) => {
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,
}); });
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({})); throw await createFetchError(response);
const message = body.error || `Request failed with status ${response.status}`;
throw new Error(message);
} }
return response.json().catch(() => ({})); return response.json().catch(() => ({}));
}; };
const loadGuilds = async () => { const showToast = (message, type = 'success') => {
const data = await fetchJson('/api/guilds'); const container = document.getElementById('toastContainer');
state.guilds = data.guilds || []; if (!container) {
elements.guildSelect.innerHTML = '';
state.guilds.forEach((guild) => {
const option = document.createElement('option');
option.value = guild.id;
option.textContent = guild.name;
elements.guildSelect.appendChild(option);
});
if (state.guilds.length > 0) {
state.selectedGuildId = state.guilds[0].id;
elements.guildSelect.value = state.selectedGuildId;
await loadGuildDetails();
}
};
const renderRoles = (roles) => {
elements.rolesList.innerHTML = '';
roles.forEach((role) => {
const item = document.createElement('li');
item.textContent = `${role.name} (${role.id})`;
elements.rolesList.appendChild(item);
});
if (roles.length === 0) {
const item = document.createElement('li');
item.textContent = 'No roles returned.';
elements.rolesList.appendChild(item);
}
};
const loadGuildDetails = async () => {
if (!state.selectedGuildId) {
return; return;
} }
const toast = document.createElement('div');
const [permissionsData, rolesData] = await Promise.all([ toast.className = `toast toast--${type}`;
fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`), toast.textContent = message;
fetchJson(`/api/guilds/${state.selectedGuildId}/roles`), container.appendChild(toast);
]); setTimeout(() => {
toast.classList.add('toast--visible');
elements.permissionsEditor.value = JSON.stringify(permissionsData.permissions || {}, null, 2); }, 10);
renderRoles(rolesData.roles || []); setTimeout(() => {
toast.classList.remove('toast--visible');
setTimeout(() => toast.remove(), 200);
}, 3200);
}; };
const updatePermissions = async () => { const setErrorBanner = (bannerEl, message, details) => {
if (!state.selectedGuildId) { if (!bannerEl) {
return; return;
} }
const messageEl = bannerEl.querySelector('[data-role="error-message"]');
const rawValue = elements.permissionsEditor.value.trim(); const copyButton = bannerEl.querySelector('[data-role="error-copy"]');
if (!rawValue) { if (messageEl) {
return; messageEl.textContent = message;
} }
if (copyButton) {
let payload; copyButton.onclick = async () => {
const payload = details || {message};
try { try {
payload = JSON.parse(rawValue); await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
showToast('Error details copied.', 'success');
} catch (error) { } catch (error) {
elements.permissionsEditor.focus(); showToast('Unable to copy details.', 'error');
throw new Error('Permissions JSON is invalid.');
} }
};
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, { }
method: 'PATCH', bannerEl.hidden = false;
body: JSON.stringify(payload),
});
elements.permissionsEditor.value = JSON.stringify(data.permissions || {}, null, 2);
}; };
const loadWarnings = async (userId) => { const clearErrorBanner = (bannerEl) => {
if (!state.selectedGuildId || !userId) { if (!bannerEl) {
return;
}
bannerEl.hidden = true;
};
const formatUserDisplay = (user) => {
if (!user) {
return 'Not signed in';
}
const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : '';
return `${user.username}${tag}`;
};
const debounce = (callback, wait = 300) => {
let timeoutId;
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => callback(...args), wait);
};
};
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 data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`); try {
elements.warningsList.innerHTML = ''; const data = await fetchJson('/api/me');
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
const warnings = data.warnings || []; loginButton.hidden = true;
if (warnings.length === 0) { dashboardButton.hidden = false;
const item = document.createElement('li'); logoutButton.hidden = false;
item.textContent = 'No warnings found.'; if (banner) {
elements.warningsList.appendChild(item); banner.textContent = '';
return; banner.style.display = 'none';
}
} catch (error) {
sessionStatus.textContent = 'You are not signed in yet.';
loginButton.hidden = false;
dashboardButton.hidden = true;
logoutButton.hidden = true;
if (banner) {
banner.textContent = 'Log in with Discord to access the dashboard.';
banner.style.display = 'block';
}
} }
warnings.forEach((warning) => { logoutButton?.addEventListener('click', async () => {
const item = document.createElement('li'); try {
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); if (banner) {
banner.textContent = error.message || 'Failed to log out.';
banner.style.display = 'block';
}
} }
}); });
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,
showToast,
setErrorBanner,
clearErrorBanner,
formatUserDisplay,
debounce,
};
+217
View File
@@ -0,0 +1,217 @@
<!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-shell">
<header class="navbar">
<div class="navbar__brand">
<div class="brand-mark">DB</div>
<div>
<p class="muted">Discord Bot Dashboard</p>
<strong id="userDisplay">Loading session...</strong>
<p class="muted" id="sessionExpiry"></p>
</div>
</div>
<div class="navbar__actions">
<a class="button button--ghost" href="/">Home</a>
<button class="button button--primary" id="logoutButton">Log out</button>
</div>
</header>
<div class="error-banner" id="errorBanner" hidden>
<div>
<strong>Something went wrong.</strong>
<p data-role="error-message"></p>
</div>
<button class="button button--ghost" data-role="error-copy">Copy error details</button>
</div>
<div class="toast-container" id="toastContainer"></div>
<div class="layout">
<aside class="sidebar">
<div class="sidebar__header">
<div>
<h2>Guilds</h2>
<p class="muted">Pick a server to manage.</p>
</div>
<input type="search" id="guildSearch" placeholder="Search guilds" />
</div>
<div class="guild-list" id="guildList"></div>
<div class="skeleton-list" id="guildListSkeleton" hidden>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
<div class="empty-state" id="guildEmpty" hidden>
No manageable guilds found. Make sure you have Manage Server permissions.
</div>
</aside>
<main class="content">
<section class="guild-header">
<div class="guild-header__info">
<img id="guildHeaderIcon" alt="" />
<div>
<h1 id="guildHeaderName">Select a guild</h1>
<div class="guild-header__status">
<span class="status-badge" id="guildStatusBadge"></span>
<span class="muted" id="guildStatusNote"></span>
</div>
</div>
</div>
<div class="guild-header__actions">
<button class="button button--primary" id="inviteButton" hidden>Invite bot</button>
<button class="button button--ghost" id="refreshBotButton" hidden>Refresh status</button>
</div>
</section>
<div class="tabs">
<button class="tab active" data-tab="settings">Settings</button>
<button class="tab" data-tab="permissions">Permissions</button>
<button class="tab" data-tab="warnings">Warnings</button>
</div>
<section class="panel" id="settingsPanel">
<div class="panel__header">
<div>
<h2>Admin access</h2>
<p class="muted">Control who can edit bot configuration and permissions.</p>
</div>
</div>
<div class="callout" id="botInactiveCallout" hidden>
<strong>Bot not added yet.</strong>
<p class="muted">The bot isn’t in this server yet. Invite it to configure settings.</p>
<p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p>
</div>
<div class="field">
<span>
Admin roles
<span class="tooltip" title="Members with these roles can edit dashboard settings.">?</span>
</span>
<div id="adminRolesPicker"></div>
</div>
<div class="field">
<span>
Admin user IDs
<span class="tooltip" title="Paste one or more user IDs separated by commas or spaces.">?</span>
</span>
<div class="input-row">
<input type="text" id="adminUserInput" placeholder="Paste user IDs" />
<button class="button" id="addAdminUserButton">Add</button>
</div>
<div class="chip-list" id="adminUserChips"></div>
</div>
</section>
<section class="panel" id="permissionsPanel" hidden>
<div class="panel__header panel__header--split">
<div>
<h2>Command permissions</h2>
<p class="muted">Restrict commands to roles or specific users.</p>
</div>
<div class="button-row">
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
<button class="button" id="copyFromGuild">Copy from another guild</button>
</div>
</div>
<div class="field">
<span>
Add command
<span class="tooltip" title="Create new command overrides for custom commands.">?</span>
</span>
<div class="input-row">
<input type="text" id="newCommandInput" placeholder="command-name" />
<button class="button" id="addCommandButton">Add</button>
</div>
</div>
<div class="command-list" id="commandsList"></div>
<div class="empty-state" id="commandsEmpty" hidden>
No commands configured yet. Add one or invite the bot to sync commands.
</div>
</section>
<section class="panel" id="warningsPanel" hidden>
<div class="warnings-layout">
<div class="warnings-pane">
<h2>User lookup</h2>
<p class="muted">Search members or paste an ID.</p>
<div class="field">
<label for="warningsUserSearch">Find member</label>
<input type="search" id="warningsUserSearch" placeholder="Type a username" />
</div>
<div class="member-list" id="warningsUserResults"></div>
<p class="muted" id="warningsSelectedUser">No user selected.</p>
<div class="field">
<label for="warningsUserInput">User ID</label>
<input type="text" id="warningsUserInput" placeholder="123456789" />
</div>
<div class="button-row">
<button class="button" id="fetchWarningsButton">Fetch warnings</button>
<button class="button button--ghost" id="clearWarningsButton">Clear warnings</button>
</div>
<div class="spinner" id="warningsSpinner" hidden></div>
</div>
<div class="warnings-pane">
<h2>Warnings</h2>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div>
<ul id="warningsList"></ul>
<div class="divider"></div>
<h3>Add warning</h3>
<div class="field">
<label for="newWarningUserInput">User ID</label>
<input type="text" id="newWarningUserInput" placeholder="123456789" />
</div>
<div class="field">
<label for="warningReasonInput">Reason</label>
<textarea id="warningReasonInput" placeholder="Reason for warning" maxlength="200"></textarea>
<div class="field__meta">
<span class="muted" id="warningCharCount">0/200</span>
<span class="muted" id="warningStatus"></span>
</div>
</div>
<button class="button button--primary" id="addWarningButton">Add warning</button>
</div>
</div>
</section>
<div class="save-bar" id="saveBar" hidden>
<span class="save-indicator" id="dirtyIndicator">Unsaved changes</span>
<div class="button-row">
<button class="button button--ghost" id="discardChangesButton">Discard</button>
<button class="button button--primary" id="savePermissionsButton">Save changes</button>
</div>
</div>
<div class="panel skeleton-panel" id="contentSkeleton" hidden>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</main>
</div>
</div>
<div class="modal" id="copyModal" hidden>
<div class="modal__content">
<h3>Copy permissions</h3>
<p class="muted">Select another guild to copy permissions from.</p>
<select id="copyGuildSelect"></select>
<div class="button-row">
<button class="button button--ghost" id="cancelCopyButton">Cancel</button>
<button class="button button--primary" id="confirmCopyButton">Copy</button>
</div>
</div>
</div>
<script src="app.js"></script>
<script src="dashboard.js"></script>
</body>
</html>
+1069
View File
File diff suppressed because it is too large Load Diff
+14 -78
View File
@@ -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>
+785 -89
View File
@@ -1,176 +1,872 @@
:root { :root {
color-scheme: dark; color-scheme: light;
font-family: "Inter", system-ui, -apple-system, sans-serif; --bg: #0b1113;
line-height: 1.5; --bg-accent: #122025;
background: #0e111b; --panel: #101820;
color: #eef2ff; --card: #151f27;
--border: #24313b;
--text: #f4f7fb;
--muted: #9fb0bc;
--primary: #2cb9c6;
--primary-dark: #2197a2;
--success: #33d17a;
--danger: #ff6b6b;
--warning: #f4b860;
--glow: rgba(44, 185, 198, 0.25);
font-family: 'Space Grotesk', 'Satoshi', 'Segoe UI', sans-serif;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
[hidden] {
display: none !important;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: radial-gradient(circle at top, #1d2542 0%, #0e111b 55%); background: radial-gradient(1200px 800px at 15% 10%, rgba(44, 185, 198, 0.12), transparent 60%),
radial-gradient(900px 700px at 85% 0%, rgba(244, 184, 96, 0.12), transparent 55%),
linear-gradient(180deg, #0b1113 0%, #0e1418 55%, #0b1113 100%);
color: var(--text);
} }
.app { .app {
max-width: 1100px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: 48px 24px 64px; padding: 48px 24px 96px;
display: flex; position: relative;
flex-direction: column;
gap: 24px;
} }
.app__header { .topbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
padding: 32px; padding: 28px;
border-radius: 20px; background: linear-gradient(135deg, rgba(44, 185, 198, 0.08), rgba(16, 24, 32, 0.9));
background: rgba(17, 23, 40, 0.9); border-radius: 22px;
border: 1px solid rgba(118, 135, 255, 0.2); border: 1px solid rgba(44, 185, 198, 0.35);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); margin-bottom: 28px;
box-shadow: 0 20px 40px rgba(5, 8, 10, 0.45);
animation: rise 0.45s ease;
} }
.eyebrow { .badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85rem;
background: rgba(44, 185, 198, 0.15);
border: 1px solid rgba(44, 185, 198, 0.4);
color: #d7fbff;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.75rem; letter-spacing: 0.06em;
letter-spacing: 0.2rem;
color: #8b9cff;
margin: 0 0 8px;
} }
h1 { .banner {
font-size: 2.4rem; padding: 14px 18px;
margin: 0 0 12px; border-radius: 14px;
border: 1px solid rgba(244, 184, 96, 0.35);
background: rgba(244, 184, 96, 0.12);
margin-bottom: 20px;
color: #ffe3b0;
display: none;
} }
.subhead { .muted {
margin: 0; color: var(--muted);
max-width: 520px;
color: #b7c0ff;
} }
.card { a {
background: rgba(17, 23, 40, 0.95); color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
input,
select,
textarea {
font: inherit;
}
.app-shell {
max-width: 1280px;
margin: 0 auto;
padding: 24px 24px 60px;
position: relative;
animation: rise 0.45s ease;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 22px;
background: rgba(16, 24, 32, 0.9);
border-radius: 18px; border-radius: 18px;
padding: 24px; border: 1px solid var(--border);
border: 1px solid rgba(118, 135, 255, 0.2); margin-bottom: 22px;
box-shadow: 0 14px 30px rgba(5, 8, 10, 0.4);
}
.navbar__brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 44px;
height: 44px;
border-radius: 14px;
background: linear-gradient(135deg, var(--primary), #58d6c7);
display: grid;
place-items: center;
font-weight: 700;
color: #031417;
text-shadow: none;
}
.navbar__actions {
display: flex;
gap: 12px;
align-items: center;
}
.error-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid rgba(255, 107, 107, 0.45);
background: rgba(255, 107, 107, 0.12);
margin-bottom: 16px;
}
.toast-container {
position: fixed;
top: 20px;
right: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 12px;
z-index: 50;
} }
.card__header { .toast {
opacity: 0;
transform: translateY(-8px);
padding: 12px 16px;
border-radius: 12px;
background: rgba(16, 24, 32, 0.92);
border: 1px solid var(--border);
transition: all 0.2s ease;
}
.toast--visible {
opacity: 1;
transform: translateY(0);
}
.toast--success {
border-color: rgba(59, 165, 93, 0.5);
}
.toast--error {
border-color: rgba(237, 66, 69, 0.5);
}
.layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 24px;
}
.sidebar {
background: rgba(16, 24, 32, 0.9);
border-radius: 18px;
border: 1px solid var(--border);
padding: 16px;
box-shadow: inset 0 0 0 1px rgba(44, 185, 198, 0.08);
animation: rise 0.45s ease;
}
.sidebar__header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.sidebar input[type='search'] {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #111a22;
color: var(--text);
}
.guild-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.guild-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 12px;
padding: 10px 12px;
background: rgba(21, 31, 39, 0.9);
border-radius: 12px;
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
} }
.card__content { .guild-item__info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.guild-item img {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--border);
}
.guild-item span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-item.active {
border-color: var(--primary);
}
.guild-item:hover {
border-color: rgba(44, 185, 198, 0.5);
transform: translateY(-1px);
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid var(--border);
}
.status-pill__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
}
.status-pill--ok {
border-color: rgba(59, 165, 93, 0.5);
color: #d6f5de;
}
.status-pill--ok .status-pill__dot {
background: var(--success);
}
.status-pill--warn {
border-color: rgba(246, 195, 67, 0.5);
color: #ffe8b1;
}
.status-pill--warn .status-pill__dot {
background: var(--warning);
}
.content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.columns { .guild-header {
display: grid; display: flex;
justify-content: space-between;
align-items: center;
gap: 16px; gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); padding: 18px;
background: rgba(16, 24, 32, 0.9);
border-radius: 18px;
border: 1px solid var(--border);
box-shadow: inset 0 0 0 1px rgba(244, 184, 96, 0.06);
animation: rise 0.45s ease;
}
.guild-header__info {
display: flex;
align-items: center;
gap: 16px;
}
.guild-header__info img {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--border);
}
.guild-header__status {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.status-badge {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 0.8rem;
}
.status-badge--ok {
border-color: rgba(59, 165, 93, 0.5);
color: #d6f5de;
}
.status-badge--warn {
border-color: rgba(246, 195, 67, 0.5);
color: #ffe8b1;
}
.tabs {
display: inline-flex;
gap: 8px;
padding: 6px;
background: rgba(16, 24, 32, 0.9);
border-radius: 14px;
border: 1px solid var(--border);
width: fit-content;
}
.tab {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: all 0.2s ease;
}
.tab.active {
background: linear-gradient(135deg, var(--primary), #58d6c7);
color: #041316;
box-shadow: 0 10px 18px rgba(44, 185, 198, 0.25);
} }
.panel { .panel {
background: rgba(12, 15, 26, 0.9); background: rgba(16, 24, 32, 0.9);
border-radius: 14px; border-radius: 18px;
padding: 16px; border: 1px solid var(--border);
border: 1px solid rgba(118, 135, 255, 0.15); padding: 18px;
box-shadow: 0 18px 30px rgba(5, 8, 10, 0.35);
animation: rise 0.45s ease;
}
.panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel__header--split {
align-items: flex-start;
}
.callout {
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(244, 184, 96, 0.5);
background: rgba(244, 184, 96, 0.14);
margin-bottom: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.field label {
font-size: 0.9rem;
color: var(--muted);
}
.field input,
.field select,
.field textarea {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #111a22;
color: var(--text);
}
.field textarea {
min-height: 100px;
resize: vertical;
}
.field__meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.input-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.multi-picker {
background: #111a22;
border-radius: 12px;
border: 1px solid var(--border);
padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.button { .multi-picker__search {
padding: 10px 18px; padding: 8px 10px;
border-radius: 999px; border-radius: 8px;
border: 1px solid rgba(118, 135, 255, 0.3); border: 1px solid var(--border);
background: rgba(16, 20, 35, 0.8); background: #0f1820;
color: #f5f7ff; color: var(--text);
cursor: pointer;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.button:hover { .multi-picker__list {
transform: translateY(-1px); max-height: 180px;
box-shadow: 0 8px 20px rgba(34, 44, 90, 0.4); overflow-y: auto;
display: grid;
gap: 8px;
}
.multi-picker__list label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 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;
}
.button {
padding: 10px 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(21, 31, 39, 0.9);
color: var(--text);
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease;
} }
.button--primary { .button--primary {
background: linear-gradient(135deg, #5f6bff, #8a5bff); background: linear-gradient(135deg, var(--primary), #58d6c7);
border: none; border-color: var(--primary);
color: #041316;
box-shadow: 0 10px 18px rgba(44, 185, 198, 0.3);
}
.button--primary:hover {
background: linear-gradient(135deg, var(--primary-dark), #3cc0b1);
transform: translateY(-1px);
} }
.button--ghost { .button--ghost {
background: transparent; background: transparent;
} }
select, .button:hover {
input, border-color: rgba(44, 185, 198, 0.35);
textarea { transform: translateY(-1px);
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 { .button:focus-visible,
min-height: 140px; input:focus-visible,
resize: vertical; select:focus-visible,
textarea:focus-visible,
.tab:focus-visible {
outline: 2px solid rgba(44, 185, 198, 0.6);
outline-offset: 2px;
} }
.list { .button-row {
list-style: none; display: flex;
padding: 0; gap: 12px;
margin: 0; flex-wrap: wrap;
}
.command-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.command-card {
background: rgba(21, 31, 39, 0.95);
border-radius: 16px;
border: 1px solid var(--border);
padding: 14px;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.command-card:hover {
border-color: rgba(44, 185, 198, 0.4);
transform: translateY(-1px);
}
.command-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.command-card__body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-top: 12px;
}
.member-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
max-height: 220px;
overflow-y: auto;
padding: 10px;
border-radius: 12px;
border: 1px solid var(--border);
background: #111a22;
} }
.list li { .member-item {
background: rgba(16, 20, 35, 0.6); display: flex;
padding: 8px 12px; justify-content: space-between;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(118, 135, 255, 0.15); border: 1px solid transparent;
background: var(--card);
cursor: pointer;
} }
.field { .member-item__meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 2px;
min-width: 0;
}
.member-item__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-item__id {
font-size: 0.8rem;
color: var(--muted);
}
.member-item.active {
border-color: var(--primary);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.toggle input {
width: 40px;
height: 20px;
appearance: none;
background: var(--border);
border-radius: 999px;
position: relative;
cursor: pointer;
}
.toggle input::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 999px;
background: white;
top: 2px;
left: 2px;
transition: transform 0.2s ease;
}
.toggle input:checked {
background: var(--primary);
}
.toggle input:checked::after {
transform: translateX(20px);
}
.warning-item {
background: rgba(21, 31, 39, 0.9);
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px;
margin-bottom: 10px;
}
.warning-item__meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted);
} }
.muted { .warnings-layout {
color: #9aa5e3; display: grid;
margin: 0; grid-template-columns: 1fr 2fr;
gap: 16px;
} }
@media (max-width: 720px) { .warnings-pane {
.app__header { background: rgba(21, 31, 39, 0.9);
border-radius: 12px;
border: 1px solid var(--border);
padding: 14px;
}
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.save-bar {
position: sticky;
bottom: 16px;
background: rgba(16, 24, 32, 0.95);
border-radius: 18px;
border: 1px solid var(--border);
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 16px 30px rgba(5, 8, 10, 0.4);
}
.save-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.save-indicator::before {
content: '';
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--warning);
}
.empty-state {
padding: 20px;
border-radius: 12px;
border: 1px dashed var(--border);
background: #111a22;
color: var(--muted);
}
.spinner {
width: 28px;
height: 28px;
border-radius: 999px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--primary);
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.skeleton-list,
.skeleton-panel {
display: grid;
gap: 12px;
}
.skeleton-line {
height: 14px;
border-radius: 999px;
background: linear-gradient(90deg, #16222a 25%, #1d2b34 37%, #16222a 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.tooltip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--border);
color: var(--muted);
font-size: 0.75rem;
margin-left: 6px;
cursor: help;
}
.modal {
position: fixed;
inset: 0;
background: rgba(10, 12, 18, 0.7);
display: grid;
place-items: center;
z-index: 40;
backdrop-filter: blur(6px);
}
.modal[hidden] {
display: none;
}
.modal__content {
background: rgba(16, 24, 32, 0.98);
border-radius: 18px;
border: 1px solid var(--border);
padding: 20px;
width: min(420px, 90vw);
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 24px 40px rgba(5, 8, 10, 0.6);
}
.modal__content select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #111a22;
color: var(--text);
}
.command-card--disabled,
.panel--disabled,
.multi-picker--disabled {
opacity: 0.6;
pointer-events: none;
}
h1,
h2,
h3,
strong {
font-family: 'Fraunces', 'Space Grotesk', 'Segoe UI', sans-serif;
letter-spacing: 0.01em;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.warnings-layout {
grid-template-columns: 1fr;
}
.save-bar {
position: static;
}
.topbar {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
h1 {
font-size: 2rem;
}
} }
+43 -6
View File
@@ -6,13 +6,50 @@ const DEFAULT_GUILD_PERMISSIONS = {
commandPermissions: {}, commandPermissions: {},
}; };
const normalizePermissionConfig = (config = {}) => ({ 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 safeConfig = config && typeof config === 'object' ? config : {};
return {
...DEFAULT_GUILD_PERMISSIONS, ...DEFAULT_GUILD_PERMISSIONS,
...config, adminRoleIds: normalizeIdArray(safeConfig.adminRoleIds),
adminRoleIds: Array.isArray(config.adminRoleIds) ? config.adminRoleIds : [], adminUserIds: normalizeIdArray(safeConfig.adminUserIds),
adminUserIds: Array.isArray(config.adminUserIds) ? config.adminUserIds : [], commandPermissions: sanitizeCommandPermissions(safeConfig.commandPermissions),
commandPermissions: config.commandPermissions ?? {}, };
}); };
const memberHasRole = (member, roleIds) => const memberHasRole = (member, roleIds) =>
roleIds.some((roleId) => member.roles?.cache?.has(roleId)); roleIds.some((roleId) => member.roles?.cache?.has(roleId));
+14
View File
@@ -40,7 +40,21 @@ const getWarnings = async ({ guildId, userId }) => {
return record?.warnings ?? []; return record?.warnings ?? [];
}; };
const clearWarnings = async ({ guildId, userId }) => {
const collection = await getWarningsCollection();
const result = await collection.findOneAndUpdate(
{ guildId, userId },
{ $set: { warnings: [] } },
{ returnDocument: 'after', upsert: true },
);
await updateStrikes({ guildId, userId, strikes: 0 });
return result.value?.warnings ?? [];
};
module.exports = { module.exports = {
addWarning, addWarning,
getWarnings, getWarnings,
clearWarnings,
}; };