From ff10bb3183e9bee535c3dfcb95910100fa4d6ffb Mon Sep 17 00:00:00 2001 From: chad Date: Thu, 9 Oct 2025 06:13:48 -0400 Subject: [PATCH] Moderation Update --- README.md | 407 ++++--------- backend/index.js | 383 +++++++++++- backend/pg.js | 58 +- checklist.md | 54 +- discord-bot/commands/ban.js | 172 ++++++ discord-bot/commands/kick.js | 167 ++++++ discord-bot/commands/setup-adminlogs.js | 123 ++++ discord-bot/commands/timeout.js | 187 ++++++ discord-bot/deploy-commands.js | 20 + discord-bot/events/guildCreate.js | 15 + discord-bot/events/guildDelete.js | 15 + frontend/src/App.js | 55 +- frontend/src/components/Dashboard.js | 140 ++++- frontend/src/components/NavBar.js | 8 +- frontend/src/components/common/Footer.js | 32 + frontend/src/components/server/HelpPage.js | 10 +- .../src/components/server/ServerSettings.js | 556 ++++++++++++++++-- frontend/src/contexts/BackendContext.js | 3 + frontend/src/index.css | 25 + frontend/src/themes.js | 7 + 20 files changed, 2056 insertions(+), 381 deletions(-) create mode 100644 discord-bot/commands/ban.js create mode 100644 discord-bot/commands/kick.js create mode 100644 discord-bot/commands/setup-adminlogs.js create mode 100644 discord-bot/commands/timeout.js create mode 100644 discord-bot/events/guildCreate.js create mode 100644 discord-bot/events/guildDelete.js create mode 100644 frontend/src/components/common/Footer.js diff --git a/README.md b/README.md index 2ce1796..75df30b 100644 --- a/README.md +++ b/README.md @@ -1,320 +1,161 @@ # ECS Full Stack -A full-stack example project that integrates a React frontend, an Express backend, and a Discord bot. The app provides a dashboard for server admins to manage bot settings and invites, plus Discord moderation/integration features via a bot running with discord.js. +A full-stack Discord bot management dashboard with React frontend, Express backend, and Discord.js bot integration. Server admins can manage bot settings, invites, moderation, and live notifications through a modern web interface. -This README documents how to get the project running, what environment variables are required, where to get Discord keys, how the invite token flow works, and basic troubleshooting tips. +## Features -Note: The backend has been updated to support Postgres persistence (see `CHANGELOG.md`). The backend now requires `DATABASE_URL` to run in the default configuration; if you prefer the legacy encrypted file store, see the notes under "Developer notes". +- **Dashboard**: View Discord servers and manage per-server settings +- **Invite Management**: Create, list, and revoke server invites with custom options +- **Moderation**: Direct ban/kick/timeout actions from web interface with user autocomplete +- **Live Notifications**: Twitch stream notifications with rich embeds +- **Admin Logs**: Complete moderation action logging with real-time updates +- **Theme Support**: Light, dark, and Discord-themed UI options -## Repository layout +## Quick Start -- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production. -- `backend/` — Express backend and API server that also coordinates with the `discord-bot` library to manage guilds, invites, and settings. Uses environment variables for configuration. -- `discord-bot/` — small wrapper that logs the bot in and exposes the discord.js client used by the backend. -- `checklist.md`, `README.md`, other docs and small scripts at repo root. +### Prerequisites +- Node.js 18+ +- PostgreSQL database +- Discord application with bot user -## What this project does +### Setup -- Provides a React dashboard where a user can view servers the bot is connected to and manage per-server settings (welcome/leave messages, autorole, toggling commands, invite creation/listing/deletion). -- Runs a Discord bot (discord.js) that performs moderation and server features. The backend and bot are closely integrated: the backend hosts the API and the bot client is shared to fetch guild data and manipulate invites/channels/roles. -- Uses a short-lived token flow to authorize invite deletions from the frontend without embedding long-lived secrets in the client. +1. **Clone and install dependencies:** + ```bash + git clone + cd ECS-FullStack + npm install # Run in both frontend/ and backend/ directories + ``` -Expanded: what this app does +2. **Configure Discord App:** + - Go to [Discord Developer Portal](https://discord.com/developers/applications) + - Create new application and bot user + - Copy Client ID, Client Secret, and Bot Token -- Hosts a dashboard (React) that lists Discord guilds where the bot is present and lets server admins: - - create and manage invites (create invites with options, view persisted invites, copy and revoke) - - configure Welcome and Leave messages and channels - - enable/disable bot commands per server - - set autorole behavior for new members -- Provides a backend API (Express) that coordinates with a discord.js bot to perform live guild operations (fetch channels/roles, create invites, leave guilds) -- Stores configuration and invites in Postgres (recommended) or a legacy encrypted `db.json` +3. **Database Setup:** + ```sql + CREATE DATABASE ecs_fullstack; + CREATE USER ecs_user WITH PASSWORD 'your_password'; + GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user; + ``` -## Quickstart — prerequisites +4. **Environment Configuration:** -- Node.js (recommended 18.x or later) and npm -- A Discord application with a Bot user (to get `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`) — see below for setup steps -- Optional: a VPS or Tailscale IP if you want to run the frontend/backend on a non-localhost address + **backend/.env:** + ```env + DATABASE_URL=postgres://ecs_user:password@localhost:5432/ecs_fullstack + DISCORD_CLIENT_ID=your_client_id + DISCORD_CLIENT_SECRET=your_client_secret + DISCORD_BOT_TOKEN=your_bot_token + ENCRYPTION_KEY=your_32_byte_secret + BACKEND_BASE=http://localhost:3002 + FRONTEND_BASE=http://localhost:3001 + ``` -## Environment configuration (.env) + **frontend/.env:** + ```env + REACT_APP_API_BASE=http://localhost:3002 + ``` -There are env files used by the backend and frontend. Create `.env` files in the `backend/` and `frontend/` folders for local development. Examples follow. +5. **Start the application:** + ```bash + # Backend (includes Discord bot) + cd backend && npm start -### backend/.env (example) + # Frontend (separate terminal) + cd frontend && npm start + ``` -PORT=3002 -HOST=0.0.0.0 -BACKEND_BASE=http://your-server-or-ip:3002 -FRONTEND_BASE=http://your-server-or-ip:3001 -CORS_ORIGIN=http://your-server-or-ip:3001 -DISCORD_CLIENT_ID=your_discord_client_id -DISCORD_CLIENT_SECRET=your_discord_client_secret -ENCRYPTION_KEY=a-32-byte-or-longer-secret -INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens +6. **Invite Bot to Server:** + - Use OAuth2 URL Generator in Discord Developer Portal + - Select `bot` and `applications.commands` scopes + - Choose appropriate permissions + - Visit generated URL to invite bot -# Postgres example (optional but recommended) -# DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db +## Project Structure -- `PORT` / `HOST`: where the backend listens. -- `BACKEND_BASE` and `FRONTEND_BASE`: used for constructing OAuth redirect URIs and links. -- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS. -- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET`: from the Discord Developer Portal (see below). -- `ENCRYPTION_KEY` or `INVITE_TOKEN_SECRET`: used to sign short-lived invite tokens. Keep this secret. - -Note: This project previously supported an `INVITE_API_KEY` static secret; that requirement has been removed. Invite deletes are authorized via short-lived invite tokens by default. - -### Twitch Live Notifications (optional) - -This project can detect when watched Twitch users go live and post notifications to a configured Discord channel for each guild. To enable this feature, add the following to `backend/.env`: - -- `TWITCH_CLIENT_ID` — your Twitch app client id -- `TWITCH_CLIENT_SECRET` — your Twitch app client secret -- `TWITCH_POLL_INTERVAL_MS` — optional, poll interval in milliseconds (default 30000) - -When configured, the backend exposes: - -- GET /api/twitch/streams?users=user1,user2 — returns stream info for the listed usernames (used by the frontend and bot watcher) - -The bot includes a watcher that polls watched usernames per-guild and posts a message to the configured channel when a streamer goes live. The message includes the stream title and a link to the Twitch stream. - -If you run the backend and the bot on separate hosts, you can configure the backend to push setting updates to the bot so toggles and watched users propagate immediately: - -- `BOT_PUSH_URL` — the URL the bot will expose for the backend to POST setting updates to (e.g., http://bot-host:4002) -- `BOT_SECRET` — a shared secret used by the backend and bot to secure push requests -- `BOT_PUSH_PORT` — optional, the port the bot listens on for push requests (if set the bot starts a small HTTP receiver) - - -### frontend/.env (example) - -HOST=0.0.0.0 -PORT=3001 -REACT_APP_API_BASE=http://your-server-or-ip:3002 - -Set `REACT_APP_API_BASE` to point at the backend so the frontend can call API endpoints. - -## Create a Discord Application and Bot (short) - -1. Go to the Discord Developer Portal: https://discord.com/developers/applications -2. Create a new application. -3. Under "OAuth2" -> "General", add your redirect URI: - - For dev: `http://your-server-or-ip:3002/auth/discord/callback` - - Make sure `BACKEND_BASE` matches the host/port you set in `backend/.env`. -4. Under "Bot" create a Bot user and copy the Bot token (NOT committed to source). -5. Under "OAuth2" -> "URL Generator" select scopes `bot` and `applications.commands` and select permissions (e.g., Administrator if you want full access for testing). Use the generated URL to invite the bot to your guild during testing. - -Store the Client ID / Client Secret in your `backend/.env` as `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`. - -## Invite token flow (why and how) - -- To avoid embedding long-lived secrets in a web client, invite deletions are authorized with a short-lived HMAC-signed token. -- The frontend requests a token with: - GET /api/servers/:guildId/invite-token -- The backend returns `{ token: '...' }`. The frontend then calls - DELETE /api/servers/:guildId/invites/:code - with header `x-invite-token: ` -- Token TTL is short (default 5 minutes) and is signed using `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` from backend `.env`. - -Security note: Currently the `/invite-token` endpoint issues tokens to any caller. For production you should restrict this endpoint by requiring OAuth authentication and checking that the requesting user is authorized for the target guild. - -## Run the app locally - -1. Backend - -```powershell -cd backend -npm install -# create backend/.env from the example above -npm start +``` +ECS-FullStack/ +├── frontend/ # React dashboard +├── backend/ # Express API + Discord bot +├── discord-bot/ # Bot wrapper +├── checklist.md # Feature tracking +└── README.md ``` -Optional: using Postgres (recommended) +## API Endpoints -1. Create a Postgres database and user (pgAdmin or psql) -2. Set `DATABASE_URL` in `backend/.env`, e.g.: - DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db -3. Start the backend; on startup the backend will create simple tables if missing. +### Server Management +- `GET /api/servers/:guildId` - Server info and settings +- `GET /api/servers/:guildId/members` - Server member list +- `GET /api/servers/:guildId/channels` - Text channels +- `GET /api/servers/:guildId/roles` - Server roles -Migration note: -- If you have existing data in `backend/db.json`, a migration script is planned to import invites and server settings into Postgres. I can add that script on request. +### Invites +- `GET /api/servers/:guildId/invites` - List invites +- `POST /api/servers/:guildId/invites` - Create invite +- `DELETE /api/servers/:guildId/invites/:code` - Delete invite -2. Frontend +### Moderation +- `POST /api/servers/:guildId/moderate` - Ban/kick/timeout users +- `GET /api/servers/:guildId/admin-logs` - View moderation logs -```powershell -cd frontend -npm install -# create frontend/.env with REACT_APP_API_BASE pointing to the backend -npm run start +### Live Notifications +- `GET/POST /api/servers/:guildId/live-notifications` - Settings +- `GET/POST /api/servers/:guildId/twitch-users` - Watched users + +## Environment Variables + +### Required +- `DATABASE_URL` - PostgreSQL connection string +- `DISCORD_CLIENT_ID` - Discord app client ID +- `DISCORD_CLIENT_SECRET` - Discord app client secret +- `DISCORD_BOT_TOKEN` - Bot token + +### Optional +- `TWITCH_CLIENT_ID` - Twitch app client ID +- `TWITCH_CLIENT_SECRET` - Twitch app client secret +- `BOT_PUSH_URL` - For separate bot/backend deployment +- `CORS_ORIGIN` - Restrict API access + +## Development + +### Running Tests +```bash +cd frontend && npm test +cd backend && npm test ``` -3. Discord bot - -- The backend boots the bot client (see `discord-bot/`), so if the backend is started and credentials are correct, the bot will log in and register slash commands. You can also run the `discord-bot` project separately if you prefer. +### Building for Production +```bash +cd frontend && npm run build +cd backend && npm run build # If applicable +``` ## Troubleshooting -- Backend refuses to start or missing package.json: ensure you run `npm install` in the `backend` folder and run `npm start` from that folder. - - If the backend exits with "DATABASE_URL is not set": either set `DATABASE_URL` in `backend/.env` pointing to your Postgres DB, or restore the legacy behavior by editing `backend/index.js` to re-enable the encrypted `db.json` fallback (not recommended for production). -- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin. -- Invite delete unauthorized: ensure backend `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` is present and token TTL has not expired. Check the backend logs for validation details. -- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync. +### Common Issues +- **Database connection failed**: Verify `DATABASE_URL` format and credentials +- **CORS errors**: Check `CORS_ORIGIN` matches your frontend URL +- **Bot not responding**: Ensure bot has proper permissions in server +- **Invite deletion fails**: Check `ENCRYPTION_KEY` is set -## Developer notes +### Logs +- Backend logs Discord bot status and API requests +- Frontend console shows API calls and errors +- Check browser Network tab for failed requests -- The dashboard UI is in `frontend/src/components/` (notable files: `Dashboard.js`, `ServerSettings.js`, `Login.js`). -- The Express API is in `backend/index.js` and uses `discord-bot` (discord.js client) to operate on guilds, invites, channels and roles. -- Invite delete flow: frontend fetches a short-lived token then requests DELETE with header `x-invite-token`. +## Contributing -## Next steps / suggestions +1. Fork the repository +2. Create feature branch +3. Make changes with tests +4. Submit pull request -- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild. -- Add rate-limiting to token issuance and optionally keep the old `INVITE_API_KEY` option for server-to-server automation. -- Updated docs: the README and CHANGELOG were updated to reflect Postgres integration and recent frontend/backend changes. See `CHANGELOG.md` and `checklist.md` for details. +## License -If you want, I can add step-by-step instructions to create the `.env` files from templates, or implement the production safe option of authenticating `/invite-token` requests. Tell me which you'd prefer. +MIT License - see LICENSE file for details. --- -Updated: Oct 4, 2025 -## Full setup guide (detailed) - -This section walks through the exact steps to get the project running locally or on a machine reachable via Tailscale/Nginx Proxy Manager. - -Prerequisites -1. Node.js 18+ and npm -2. Postgres (local or remote) or use an existing Postgres server reachable over your network/Tailscale -3. Discord application + Bot credentials and (optional) Twitch app credentials - -Database (Postgres) setup -1. Create a Postgres database and user. Example psql commands: - -```bash -sudo -u postgres psql -CREATE DATABASE ecs_fullstack; -CREATE USER ecs_user WITH PASSWORD 'supersecret'; -GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user; -\q -``` - -2. Set the `DATABASE_URL` in `backend/.env`: - -``` -DATABASE_URL=postgres://ecs_user:supersecret@127.0.0.1:5432/ecs_fullstack -``` - -3. Start the backend (it will run migrations / ensure tables at startup): - -```powershell -cd backend -npm install -npm start -``` - -Backend configuration (.env) -- `DATABASE_URL` - required for Postgres persistence -- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN` - from Discord Developer Portal -- `FRONTEND_BASE` - public frontend URL (used for OAuth redirect) -- `PORT`, `HOST` - where backend listens -- `CORS_ORIGIN` - optional restrict origin to your frontend URL -- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` - optional for Twitch integration - -Frontend configuration -1. In `frontend/.env` set: - -``` -REACT_APP_API_BASE=https://your-domain-or-ip:3002 -``` - -2. For development behind an HTTPS domain (Nginx Proxy Manager), ensure the CRA dev client uses `wss` by setting the `WDS_SOCKET_*` variables in `frontend/.env` (see docs if using a TLS domain) - -Start the frontend dev server: - -```powershell -cd frontend -npm install -npm start -``` - -Bot behaviour and deployment -- The `backend` process will boot the Discord bot client when valid `DISCORD_BOT_TOKEN` is present. The bot registers slash commands per guild on startup and responds to backend pushes for setting updates. -- If you prefer to run the bot separately, you can run the `discord-bot` module separately; ensure `BOT_PUSH_URL`/`BOT_SECRET` are configured if backend and bot are on different hosts. - -Useful endpoints -- `GET /api/servers/:guildId/commands` — returns the authoritative list of commands and per-guild enabled/locked status. -- `GET/POST /api/servers/:guildId/live-notifications` — get/update live notification settings -- `GET /api/twitch/streams?users=user1,user2` — proxy to twitch helix for streams (backend caches app-token) -- `GET /api/events?guildId=...` — Server-Sent Events for real-time updates (ServerSettings subscribes to this) - -### Twitch Live Notification Settings (Detailed) - -Endpoint: `GET/POST /api/servers/:guildId/live-notifications` - -Shape returned by GET: -```json -{ - "enabled": true, - "channelId": "123456789012345678", - "twitchUser": "deprecated-single-user-field", - "message": "🔴 {user} is now live!", - "customMessage": "Custom promo text with link etc" -} -``` - -Notes: -- `twitchUser` is a legacy single-user field retained for backward compatibility. The active watched users list lives under `/api/servers/:guildId/twitch-users`. -- `message` (default message) and `customMessage` (override) are persisted. If `customMessage` is non-empty it is used when announcing a live stream; otherwise `message` is used. If both are empty the bot falls back to `🔴 {user} is now live!`. -- Update by POSTing the same shape (omit fields you don't change is okay; unspecified become empty unless preserved on server). - -### Discord Bot Twitch Embed Layout - -When a watched streamer goes live the bot posts a standardized embed. The layout is fixed to keep consistency: - -Embed fields: -1. Title: Stream title (hyperlinked to Twitch URL) or fallback "{user} is live". -2. Author: Twitch display name with avatar and link. -3. Thumbnail: Stream thumbnail (or profile image fallback). -4. Fields: - - Category: Game / category name (or "Unknown"). - - Viewers: Current viewer count. -5. Description: Twitch user bio (if available via Helix `users` endpoint) else truncated stream description (200 chars). -6. Footer: `ehchadservices • Started: `. - -Pre-Embed Message (optional): -- If `customMessage` is set it is posted as normal message content above the embed. -- Else if `message` is set it is posted above the embed. -- Else no prefix content is posted (embed alone). - -Variables: -- `{user}` in messages will not be auto-replaced server-side yet; include the username manually if desired. (Can add template replacement in a future iteration.) - -### Watched Users - -- Add/remove watched Twitch usernames via `POST /api/servers/:guildId/twitch-users` and `DELETE /api/servers/:guildId/twitch-users/:username`. -- Frontend polls `/api/twitch/streams` every ~15s to refresh live status and renders a "Watch Live" button per user. -- The watcher announces a stream only once per live session; when a user goes offline the session marker clears so a future live event re-announces. - -### SSE Event Types Relevant to Twitch - -- `twitchUsersUpdate`: `{ users: ["user1", "user2"], guildId: "..." }` -- `liveNotificationsUpdate`: `{ enabled, channelId, twitchUser, message, customMessage, guildId }` - -Consume these to live-update UI without refresh (the `BackendContext` exposes an `eventTarget`). - -### Customizing Messages - -- In the dashboard under Live Notifications you can set both a Default Message and a Custom Message. -- Clear Custom to fall back to Default. -- Save persists to backend and pushes an SSE `liveNotificationsUpdate`. - -### Future Improvements - -- Template variable replacement: support `{user}`, `{title}`, `{category}`, `{viewers}` inside message strings. -- Per-user custom messages (different prefix for each watched streamer). -- Embed image improvements (dynamic preview resolution trimming for Twitch thumbnails). - -Notes about Postgres requirement -- The backend now assumes Postgres persistence (via `DATABASE_URL`). If `DATABASE_URL` is not set the server will exit and complain. This change makes server settings authoritative and persistent across restarts. - -Logs and verbosity -- The bot and watcher log messages have been reduced to avoid per-guild spam. You will see concise messages like "🔁 TwitchWatcher started" and "✅ ECS - Full Stack Bot Online!" rather than one-line-per-guild spam. - -Troubleshooting -- If you see mixed-content errors in the browser when using a TLS domain with the CRA dev server, configure Nginx to proxy websockets and set CRA `WDS_SOCKET_*` env vars (see docs/nginx-proxy-manager.md) +**Updated**: October 9, 2025 diff --git a/backend/index.js b/backend/index.js index eda3237..3d3147e 100644 --- a/backend/index.js +++ b/backend/index.js @@ -636,6 +636,8 @@ app.post('/api/servers/:guildId/leave', async (req, res) => { const guild = await bot.client.guilds.fetch(guildId); if (guild) { await guild.leave(); + // Publish event for bot status change + publishEvent('*', 'botStatusUpdate', { guildId, isBotInServer: false }); res.json({ success: true }); } else { res.status(404).json({ success: false, message: 'Bot is not in the specified server' }); @@ -654,7 +656,7 @@ app.get('/api/servers/:guildId/channels', async (req, res) => { } try { const channels = await guild.channels.fetch(); - const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name })); + const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name, type: channel.type })); res.json(textChannels); } catch (error) { console.error('Error fetching channels:', error); @@ -662,6 +664,40 @@ app.get('/api/servers/:guildId/channels', async (req, res) => { } }); +app.get('/api/servers/:guildId/members', async (req, res) => { + const { guildId } = req.params; + const guild = bot.client.guilds.cache.get(guildId); + if (!guild) { + return res.json([]); + } + + try { + // Get the requesting user from the session/token + // For now, we'll assume the frontend sends the user ID in a header or we get it from OAuth + // This is a simplified version - in production you'd want proper authentication + const members = await guild.members.fetch(); + + // Filter to members the bot can interact with and format for frontend + const bannableMembers = members + .filter(member => !member.user.bot) // Exclude bots + .map(member => ({ + id: member.user.id, + username: member.user.username, + globalName: member.user.globalName, + displayName: member.displayName, + avatar: member.user.avatar, + joinedAt: member.joinedAt, + roles: member.roles.cache.map(role => ({ id: role.id, name: role.name, position: role.position })) + })) + .sort((a, b) => a.username.localeCompare(b.username)); + + res.json(bannableMembers); + } catch (error) { + console.error('Error fetching members:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => { const { guildId } = req.params; try { @@ -1098,6 +1134,351 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => { } }); +// ADMIN LOGS: configuration and retrieval +app.get('/api/servers/:guildId/admin-logs-settings', async (req, res) => { + try { + const { guildId } = req.params; + const settings = (await pgClient.getServerSettings(guildId)) || {}; + const adminLogsSettings = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } }; + res.json(adminLogsSettings); + } catch (error) { + console.error('Error fetching admin logs settings:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => { + try { + const { guildId } = req.params; + const newSettings = req.body || {}; + + const existing = (await pgClient.getServerSettings(guildId)) || {}; + const merged = { ...existing }; + merged.adminLogs = { + enabled: newSettings.enabled || false, + channelId: newSettings.channelId || '', + commands: newSettings.commands || { kick: true, ban: true, timeout: true } + }; + + await pgClient.upsertServerSettings(guildId, merged); + + // Notify bot of settings change + if (bot && bot.setGuildSettings) { + bot.setGuildSettings(guildId, merged); + } + + // If a remote bot push URL is configured, notify it with the new settings + if (process.env.BOT_PUSH_URL) { + try { + const headers = {}; + if (process.env.INTERNAL_API_KEY) { + headers['x-api-key'] = process.env.INTERNAL_API_KEY; + } + await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: merged }, { headers }); + } catch (e) { + console.error('Failed to push admin logs settings to bot:', e.message); + } + } + + res.json({ success: true, settings: merged.adminLogs }); + } catch (error) { + console.error('Error saving admin logs settings:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.get('/api/servers/:guildId/admin-logs', async (req, res) => { + try { + const { guildId } = req.params; + const { action, limit } = req.query; + const limitNum = limit ? parseInt(limit) : 50; + + let logs; + if (action) { + logs = await pgClient.getAdminLogsByAction(guildId, action, limitNum); + } else { + logs = await pgClient.getAdminLogs(guildId, limitNum); + } + + // Transform snake_case to camelCase for frontend compatibility + logs = logs.map(log => ({ + id: log.id, + guildId: log.guild_id, + action: log.action, + targetUserId: log.target_user_id, + targetUsername: log.target_username, + moderatorUserId: log.moderator_user_id, + moderatorUsername: log.moderator_username, + reason: log.reason, + duration: log.duration, + endDate: log.end_date, + timestamp: log.timestamp + })); + + res.json(logs); + } catch (error) { + console.error('Error fetching admin logs:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.delete('/api/servers/:guildId/admin-logs/:logId', async (req, res) => { + try { + const { guildId, logId } = req.params; + await pgClient.deleteAdminLog(guildId, parseInt(logId)); + + // Publish SSE event for live updates + publishEvent(guildId, 'adminLogDeleted', { logId: parseInt(logId) }); + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting admin log:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.delete('/api/servers/:guildId/admin-logs', async (req, res) => { + try { + const { guildId } = req.params; + await pgClient.deleteAllAdminLogs(guildId); + + // Publish SSE event for live updates + publishEvent(guildId, 'adminLogsCleared', {}); + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting all admin logs:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +// Internal endpoint for logging moderation actions +app.post('/internal/log-moderation', express.json(), async (req, res) => { + try { + const { guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate } = req.body; + + if (!guildId || !action || !targetUserId || !moderatorUserId || !reason) { + return res.status(400).json({ success: false, message: 'Missing required fields' }); + } + + // Save to database + await pgClient.addAdminLog({ + guildId, + action, + targetUserId, + targetUsername, + moderatorUserId, + moderatorUsername, + reason, + duration, + endDate + }); + + // Check if logging is enabled for this action and send to Discord channel + const settings = (await pgClient.getServerSettings(guildId)) || {}; + const adminLogs = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } }; + + if (adminLogs.enabled && adminLogs.channelId && adminLogs.commands[action]) { + const guild = bot.client.guilds.cache.get(guildId); + if (guild) { + const channel = guild.channels.cache.get(adminLogs.channelId); + if (channel && channel.type === 0) { // GUILD_TEXT + const embed = { + color: action === 'kick' ? 0xffa500 : action === 'ban' ? 0xff0000 : 0x0000ff, + title: `🚨 ${action.charAt(0).toUpperCase() + action.slice(1)} Action`, + fields: [ + { + name: '👤 Target', + value: `${targetUsername} (${targetUserId})`, + inline: true + }, + { + name: '👮 Moderator', + value: `${moderatorUsername} (${moderatorUserId})`, + inline: true + }, + { + name: '📝 Reason', + value: reason, + inline: false + } + ], + timestamp: new Date().toISOString(), + footer: { + text: 'ECS Admin Logs' + } + }; + + if (duration) { + embed.fields.push({ + name: '⏱️ Duration', + value: duration, + inline: true + }); + } + + if (endDate) { + embed.fields.push({ + name: '📅 End Date', + value: new Date(endDate).toLocaleString(), + inline: true + }); + } + + try { + await channel.send({ embeds: [embed] }); + } catch (error) { + console.error('Failed to send admin log to Discord:', error); + } + } + } + } + + // Publish SSE event for live updates + publishEvent(guildId, 'adminLogAdded', { + log: { + guildId, + action, + targetUserId, + targetUsername, + moderatorUserId, + moderatorUsername, + reason, + duration, + endDate, + timestamp: new Date().toISOString() + } + }); + + res.json({ success: true }); + } catch (error) { + console.error('Error logging moderation action:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +// MODERATION: frontend moderation actions +app.post('/api/servers/:guildId/moderate', express.json(), async (req, res) => { + try { + const { guildId } = req.params; + const { action, target, reason, duration, moderator } = req.body; + + if (!action || !target || !reason) { + return res.status(400).json({ success: false, message: 'Missing required fields: action, target, reason' }); + } + + // Validate reason has at least 3 words + const reasonWords = reason.trim().split(/\s+/); + if (reasonWords.length < 3) { + return res.status(400).json({ success: false, message: 'Reason must be at least 3 words long' }); + } + + const guild = bot.client.guilds.cache.get(guildId); + if (!guild) { + return res.status(404).json({ success: false, message: 'Guild not found' }); + } + + // Find the target user + let targetUser = null; + let targetMember = null; + + // Try to find by ID first + try { + targetUser = await bot.client.users.fetch(target); + targetMember = guild.members.cache.get(target); + } catch (e) { + // Try to find by username/mention + const members = await guild.members.fetch(); + targetMember = members.find(m => + m.user.username.toLowerCase().includes(target.toLowerCase()) || + m.user.tag.toLowerCase().includes(target.toLowerCase()) || + (target.startsWith('<@') && target.includes(m.user.id)) + ); + if (targetMember) { + targetUser = targetMember.user; + } + } + + if (!targetUser) { + return res.status(404).json({ success: false, message: 'User not found in this server' }); + } + + // Perform the moderation action + let result = null; + let durationString = null; + let endDate = null; + + switch (action) { + case 'kick': + if (!targetMember) { + return res.status(400).json({ success: false, message: 'User is not in this server' }); + } + result = await targetMember.kick(reason); + break; + + case 'ban': + result = await guild.members.ban(targetUser, { reason }); + break; + + case 'timeout': + if (!targetMember) { + return res.status(400).json({ success: false, message: 'User is not in this server' }); + } + if (!duration || duration < 1 || duration > 40320) { + return res.status(400).json({ success: false, message: 'Invalid timeout duration (1-40320 minutes)' }); + } + const timeoutMs = duration * 60 * 1000; + endDate = new Date(Date.now() + timeoutMs); + result = await targetMember.timeout(timeoutMs, reason); + + // Format duration string + if (duration >= 1440) { + durationString = `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m`; + } else if (duration >= 60) { + durationString = `${Math.floor(duration / 60)}h ${duration % 60}m`; + } else { + durationString = `${duration}m`; + } + break; + + default: + return res.status(400).json({ success: false, message: 'Invalid action' }); + } + + // Log the moderation action + const moderatorUsername = moderator ? (moderator.global_name || moderator.username || 'Unknown User') : 'Web Interface'; + try { + const logData = { + guildId, + action, + targetUserId: targetUser.id, + targetUsername: targetUser.global_name || targetUser.username || 'Unknown User', + moderatorUserId: moderator?.id || 'web-interface', + moderatorUsername, + reason, + duration: durationString, + endDate + }; + + await fetch(`${BACKEND_BASE}/internal/log-moderation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(logData) + }); + } catch (logError) { + console.error('Failed to log moderation action:', logError); + } + + res.json({ success: true, message: `${action} action completed successfully` }); + + } catch (error) { + console.error('Error performing moderation action:', error); + res.status(500).json({ success: false, message: error.message || 'Internal server error' }); + } +}); + const bot = require('../discord-bot'); bot.login(); diff --git a/backend/pg.js b/backend/pg.js index 1b19d08..5d5f810 100644 --- a/backend/pg.js +++ b/backend/pg.js @@ -41,6 +41,22 @@ async function ensureSchema() { data JSONB DEFAULT '{}' ); `); + + await p.query(` + CREATE TABLE IF NOT EXISTS admin_logs ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'kick', 'ban', 'timeout' + target_user_id TEXT NOT NULL, + target_username TEXT NOT NULL, + moderator_user_id TEXT NOT NULL, + moderator_username TEXT NOT NULL, + reason TEXT NOT NULL, + duration TEXT, -- for timeout/ban (e.g., '1d', '30m', 'permanent') + end_date TIMESTAMP WITH TIME ZONE, -- calculated end date for timeout/ban + timestamp TIMESTAMP WITH TIME ZONE DEFAULT now() + ); + `); } // Servers @@ -76,6 +92,46 @@ async function deleteInvite(guildId, code) { await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]); } +// Admin Logs +async function addAdminLog(logData) { + const p = initPool(); + const q = `INSERT INTO admin_logs(guild_id, action, target_user_id, target_username, moderator_user_id, moderator_username, reason, duration, end_date) + VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`; + await p.query(q, [ + logData.guildId, + logData.action, + logData.targetUserId, + logData.targetUsername, + logData.moderatorUserId, + logData.moderatorUsername, + logData.reason, + logData.duration || null, + logData.endDate || null + ]); +} + +async function getAdminLogs(guildId, limit = 50) { + const p = initPool(); + const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 ORDER BY timestamp DESC LIMIT $2', [guildId, limit]); + return res.rows; +} + +async function getAdminLogsByAction(guildId, action, limit = 50) { + const p = initPool(); + const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 AND action = $2 ORDER BY timestamp DESC LIMIT $3', [guildId, action, limit]); + return res.rows; +} + +async function deleteAdminLog(guildId, logId) { + const p = initPool(); + await p.query('DELETE FROM admin_logs WHERE guild_id = $1 AND id = $2', [guildId, logId]); +} + +async function deleteAllAdminLogs(guildId) { + const p = initPool(); + await p.query('DELETE FROM admin_logs WHERE guild_id = $1', [guildId]); +} + // Users async function getUserData(discordId) { const p = initPool(); @@ -89,4 +145,4 @@ async function upsertUserData(discordId, data) { await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]); } -module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData }; +module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs }; diff --git a/checklist.md b/checklist.md index 05ee1fe..e4a4445 100644 --- a/checklist.md +++ b/checklist.md @@ -1,27 +1,43 @@ # Project Checklist (tidy & current) - Below are implemented features and pending items, grouped by area. + Below are implemented features - [x] Front - [x] Live updates between bot and frontend using SSE events for real-time log synchronization (admin logs update immediately when moderation actions occur)nd UI for admin logs configuration in Server Settings + - [x] Database schema for storing moderation action logs + - [x] Require reason field (minimum 3 words) for all moderation commands + - [x] Admin Logs UI: added logs display section showing recent moderation actions with detailsd pending items, grouped by area. ## Backend - [x] Express API: OAuth, server settings, channel/role endpoints, leave - [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance - [x] Per-command toggles persistence and management - [x] Config endpoints for welcome/leave and autorole + - [x] Admin Logs API endpoints: GET/POST for admin logs configuration, GET for retrieving moderation action logs + - [x] Frontend Moderation API: POST endpoint for direct ban/kick/timeout actions from web interface + - [x] Server Members API: GET endpoint for fetching server members for moderation user selection + - [x] SSE events: added botStatusUpdate events for real-time bot join/leave notifications ## Frontend - [x] Login, Dashboard, Server Settings pages - Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage - Dashboard is protected: user must be logged in to view (redirects to login otherwise) - [x] MUI components, responsive layout, mobile fixes - - [x] Theme switching (persist local) and user settings UI + - [x] Theme switching (persist local) and user settings UI with adjusted light theme background - [x] Invite UI: create form, list, copy, delete with confirmation - [x] Commands UI (per-command toggles) + - [x] Admin commands (kick/ban/timeout) removed from regular commands list, only shown in Admin Commands section - [x] Live Notifications UI (per-server toggle & config) - Channel selection, watched-user list, live status with Watch Live button - Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot - Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success - Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields - Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled) + - [x] Admin Commands UI: dedicated section for moderation commands with toggle controls + - [x] Moderation Commands (`/kick`, `/ban`, `/timeout`) displayed with permission requirements and toggle switches + - [x] Admin Logs Configuration UI: channel selection and per-command enable/disable toggles + - [x] Frontend Moderation Actions: direct ban/kick/timeout functionality from web interface with user autocomplete dropdown + - [x] User permission validation and reason requirements (minimum 3 words) + - [x] Integration with backend moderation API and admin logging system + - [x] Admin Logs channel selection: shows all server text channels (not just channels where bot has permission) and updates immediately when changed + - [x] Admin logs properly save moderator usernames for both bot slash commands and frontend moderation actions, and persist across page refreshes ## Discord Bot - [x] discord.js integration (events and commands) @@ -54,6 +70,23 @@ - [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled) - [x] Bot watcher temporarily disabled in index.js startup - [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration + - [x] Admin Moderation Commands: `/kick`, `/ban`, `/timeout` with proper permission checks and role hierarchy validation + - [x] Commands accept user mentions or user IDs as input to allow targeting any user (not limited by Discord's user selection filtering) + - [x] Frontend integration: web interface moderation actions with permission validation + - [x] Moderation actions are logged to postgres database with reasons and automatically posted to configured admin logs channel + - [x] Admin logs properly capture and display the moderator who performed the action (both from bot slash commands and frontend) + - [x] Admin Logs System: event logging for moderation actions + - [x] New slash command: `/setup-adminlogs` to configure logging channel and per-command enable/disable + - [x] Bot posts detailed moderation logs to configured channel showing: command used, target user, moderator, date/time, reason (required min 3 words), duration, end date + - [x] Backend API endpoints for admin logs configuration and retrieval + - [x] Frontend UI for admin logs configuration in Server Settings + - [x] Database schema for storing moderation action logs + - [x] Require reason field (minimum 3 words) for all moderation commands + - [x] Admin logs are unique to each guild and stored in postgres database + - [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs + - [x] Live updates between bot and frontend using SSE events for real-time log synchronization + - [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions + - [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates ## Database - [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`) @@ -68,12 +101,16 @@ - [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON) - Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty) - Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved) + - [x] Admin Logs Database Schema: new table for storing moderation action logs + - Fields: id, guildId, action (kick/ban/timeout), targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate, timestamp ## Security & Behavior - [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`) - [x] Frontend confirmation dialog for invite deletion - [ ] Harden invite-token issuance (require OAuth + admin check) - [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage` + - [x] Moderation Command Requirements: require reason field (minimum 3 words) for all moderation commands (`/kick`, `/ban`, `/timeout`) + - [x] ServerSettings back button: fixed to navigate to dashboard instead of browser history to prevent accidental accordion opening ## Docs & Deployment - [x] README and CHANGELOG updated with setup steps and Postgres guidance @@ -90,6 +127,16 @@ - Mobile spacing and typography adjustments - Dashboard action buttons repositioned (Invite/Leave under title) - Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled) + - [x] All accordions in ServerSettings: consistently grayed out (opacity 0.5) when bot is not in server + + - [x] Footer component: added global footer showing "© ehchadservices.com 2025" on all pages + - [x] Dashboard live reloading: real-time updates when bot joins/leaves servers via SSE events + - [x] Responsive design: mobile-friendly layout with adaptive padding, typography, and component sizing + - [x] Ultra-wide screen support: max-width constraints and overflow prevention + - [x] Sticky footer: footer positioned at bottom of viewport regardless of content height + + - [x] Navbar branding: title shows "ECS" on mobile, "EhChadServices" on desktop + - [x] Dashboard welcome text: updated to "Welcome back, {username}" with even larger typography (h3/h2 variants) and increased spacing; title also enlarged (h4/h3) for better proportion and explicit margin-bottom for clear line separation - [x] Browser tab now shows `ECS - ` (e.g., 'ECS - Dashboard') - [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar @@ -106,5 +153,6 @@ - [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common` - [x] Moved `ServerSettings` and `HelpPage` to `components/server` - [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management - - [ ] Remove legacy top-level duplicate files (archival recommended) + - [x] Fixed compilation errors: added missing MUI imports and Snackbar component + - [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes \ No newline at end of file diff --git a/discord-bot/commands/ban.js b/discord-bot/commands/ban.js new file mode 100644 index 0000000..7c072b0 --- /dev/null +++ b/discord-bot/commands/ban.js @@ -0,0 +1,172 @@ +const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); + +// Helper function to parse user from mention or ID +function parseUser(input, guild) { + // Check if it's a mention <@123456> or <@!123456> + const mentionMatch = input.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + return guild.members.cache.get(mentionMatch[1])?.user; + } + + // Check if it's a user ID + if (/^\d{15,20}$/.test(input)) { + return guild.members.cache.get(input)?.user; + } + + // Try to find by username or global name + const member = guild.members.cache.find(m => + (m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) || + m.user.username.toLowerCase().includes(input.toLowerCase()) || + (m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) || + m.user.username.toLowerCase() === input.toLowerCase() + ); + return member?.user; +} + +// Helper function to log moderation actions +async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) { + try { + const logData = { + guildId, + action, + targetUserId, + targetUsername, + moderatorUserId, + moderatorUsername, + reason, + duration, + endDate + }; + + const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(logData) + }); + + if (!response.ok) { + console.error('Failed to log moderation action:', response.statusText); + } + } catch (error) { + console.error('Error logging moderation action:', error); + } +} + +module.exports = { + name: 'ban', + description: 'Ban a user from the server', + enabled: true, + builder: new SlashCommandBuilder() + .setName('ban') + .setDescription('Ban a user from the server') + .addStringOption(option => + option.setName('user') + .setDescription('The user to ban (mention or user ID)') + .setRequired(true)) + .addStringOption(option => + option.setName('reason') + .setDescription('Reason for the ban (minimum 3 words)') + .setRequired(true)) + .addIntegerOption(option => + option.setName('days') + .setDescription('Number of days of messages to delete (0-7)') + .setRequired(false) + .setMinValue(0) + .setMaxValue(7)), + async execute(interaction) { + // Check if user has ban permissions + if (!interaction.member.permissions.has(PermissionsBitField.Flags.BanMembers)) { + return await interaction.reply({ + content: 'You do not have permission to ban members.', + flags: 64 + }); + } + + // Check if bot has ban permissions + if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.BanMembers)) { + return await interaction.reply({ + content: 'I do not have permission to ban members.', + flags: 64 + }); + } + + const userInput = interaction.options.getString('user'); + const reason = interaction.options.getString('reason'); + const days = interaction.options.getInteger('days') || 0; + + // Parse the user from the input + const user = parseUser(userInput, interaction.guild); + if (!user) { + return await interaction.reply({ + content: 'Could not find that user. Please provide a valid user mention or user ID.', + flags: 64 + }); + } + + // Validate reason has at least 3 words + const reasonWords = reason.trim().split(/\s+/); + if (reasonWords.length < 3) { + return await interaction.reply({ + content: 'Reason must be at least 3 words long.', + flags: 64 + }); + } + + // Cannot ban yourself + if (user.id === interaction.user.id) { + return await interaction.reply({ + content: 'You cannot ban yourself.', + flags: 64 + }); + } + + // Cannot ban the bot + if (user.id === interaction.guild.members.me.id) { + return await interaction.reply({ + content: 'I cannot ban myself.', + flags: 64 + }); + } + + // Check if user is in the server + const member = interaction.guild.members.cache.get(user.id); + if (member) { + // Check role hierarchy + if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) { + return await interaction.reply({ + content: 'You cannot ban a member with a higher or equal role.', + flags: 64 + }); + } + + if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) { + return await interaction.reply({ + content: 'I cannot ban a member with a higher or equal role.', + flags: 64 + }); + } + } + + try { + await interaction.guild.members.ban(user, { + reason: reason, + deleteMessageDays: days + }); + await interaction.reply({ + content: `Successfully banned ${user.global_name || user.username} for: ${reason}${days > 0 ? ` (deleted ${days} days of messages)` : ''}`, + flags: 64 + }); + + // Log the action + await logModerationAction(interaction.guildId, 'ban', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, 'permanent'); + } catch (error) { + console.error('Error banning user:', error); + await interaction.reply({ + content: 'Failed to ban the user. Please try again.', + flags: 64 + }); + } + } +}; \ No newline at end of file diff --git a/discord-bot/commands/kick.js b/discord-bot/commands/kick.js new file mode 100644 index 0000000..9401c59 --- /dev/null +++ b/discord-bot/commands/kick.js @@ -0,0 +1,167 @@ +const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); + +// Helper function to parse user from mention or ID +function parseUser(input, guild) { + // Check if it's a mention <@123456> or <@!123456> + const mentionMatch = input.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + return guild.members.cache.get(mentionMatch[1])?.user; + } + + // Check if it's a user ID + if (/^\d{15,20}$/.test(input)) { + return guild.members.cache.get(input)?.user; + } + + // Try to find by username or global name + const member = guild.members.cache.find(m => + (m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) || + m.user.username.toLowerCase().includes(input.toLowerCase()) || + (m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) || + m.user.username.toLowerCase() === input.toLowerCase() + ); + return member?.user; +} + +// Helper function to log moderation actions +async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) { + try { + const logData = { + guildId, + action, + targetUserId, + targetUsername, + moderatorUserId, + moderatorUsername, + reason, + duration, + endDate + }; + + const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(logData) + }); + + if (!response.ok) { + console.error('Failed to log moderation action:', response.statusText); + } + } catch (error) { + console.error('Error logging moderation action:', error); + } +} + +module.exports = { + name: 'kick', + description: 'Kick a user from the server', + enabled: true, + builder: new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a user from the server') + .addStringOption(option => + option.setName('user') + .setDescription('The user to kick (mention or user ID)') + .setRequired(true)) + .addStringOption(option => + option.setName('reason') + .setDescription('Reason for the kick (minimum 3 words)') + .setRequired(true)), + async execute(interaction) { + // Check if user has kick permissions + if (!interaction.member.permissions.has(PermissionsBitField.Flags.KickMembers)) { + return await interaction.reply({ + content: 'You do not have permission to kick members.', + flags: 64 + }); + } + + // Check if bot has kick permissions + if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.KickMembers)) { + return await interaction.reply({ + content: 'I do not have permission to kick members.', + flags: 64 + }); + } + + const userInput = interaction.options.getString('user'); + const reason = interaction.options.getString('reason'); + + // Parse the user from the input + const user = parseUser(userInput, interaction.guild); + if (!user) { + return await interaction.reply({ + content: 'Could not find that user. Please provide a valid user mention or user ID.', + flags: 64 + }); + } + + // Validate reason has at least 3 words + const reasonWords = reason.trim().split(/\s+/); + if (reasonWords.length < 3) { + return await interaction.reply({ + content: 'Reason must be at least 3 words long.', + flags: 64 + }); + } + + // Cannot kick yourself + if (user.id === interaction.user.id) { + return await interaction.reply({ + content: 'You cannot kick yourself.', + flags: 64 + }); + } + + // Cannot kick the bot + if (user.id === interaction.guild.members.me.id) { + return await interaction.reply({ + content: 'I cannot kick myself.', + flags: 64 + }); + } + + // Check if user is in the server + const member = interaction.guild.members.cache.get(user.id); + if (!member) { + return await interaction.reply({ + content: 'That user is not in this server.', + flags: 64 + }); + } + + // Check role hierarchy + if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) { + return await interaction.reply({ + content: 'You cannot kick a member with a higher or equal role.', + flags: 64 + }); + } + + if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) { + return await interaction.reply({ + content: 'I cannot kick a member with a higher or equal role.', + flags: 64 + }); + } + + try { + await member.kick(reason); + await interaction.reply({ + content: `Successfully kicked ${user.global_name || user.username} for: ${reason}`, + flags: 64 + }); + + // Log the action + await logModerationAction(interaction.guildId, 'kick', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason); + } catch (error) { + console.error('Error kicking user:', error); + await interaction.reply({ + content: 'Failed to kick the user. Please try again.', + flags: 64 + }); + } + } +}; \ No newline at end of file diff --git a/discord-bot/commands/setup-adminlogs.js b/discord-bot/commands/setup-adminlogs.js new file mode 100644 index 0000000..01f7f87 --- /dev/null +++ b/discord-bot/commands/setup-adminlogs.js @@ -0,0 +1,123 @@ +const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); + +module.exports = { + name: 'setup-adminlogs', + description: 'Configure admin moderation logging settings', + enabled: true, + builder: new SlashCommandBuilder() + .setName('setup-adminlogs') + .setDescription('Configure admin moderation logging settings') + .addChannelOption(option => + option.setName('channel') + .setDescription('Channel to send admin logs to') + .setRequired(false)) + .addBooleanOption(option => + option.setName('enabled') + .setDescription('Enable or disable admin logging') + .setRequired(false)) + .addBooleanOption(option => + option.setName('kick_logs') + .setDescription('Log kick actions') + .setRequired(false)) + .addBooleanOption(option => + option.setName('ban_logs') + .setDescription('Log ban actions') + .setRequired(false)) + .addBooleanOption(option => + option.setName('timeout_logs') + .setDescription('Log timeout actions') + .setRequired(false)), + async execute(interaction) { + // Check if user has administrator permissions + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return interaction.reply({ + content: 'You need Administrator permissions to configure admin logs.', + flags: 64 + }); + } + + const channel = interaction.options.getChannel('channel'); + const enabled = interaction.options.getBoolean('enabled'); + const kickLogs = interaction.options.getBoolean('kick_logs'); + const banLogs = interaction.options.getBoolean('ban_logs'); + const timeoutLogs = interaction.options.getBoolean('timeout_logs'); + + try { + // Get current settings + const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`); + if (!response.ok) { + throw new Error('Failed to fetch current settings'); + } + const currentSettings = await response.json(); + + // Update settings + const updatedSettings = { ...currentSettings }; + + if (enabled !== null) { + updatedSettings.enabled = enabled; + } + + if (channel) { + // Check if it's a text channel + if (channel.type !== 0) { // 0 = GUILD_TEXT + return interaction.reply({ + content: 'Please select a text channel for admin logs.', + flags: 64 + }); + } + updatedSettings.channelId = channel.id; + } + + // Update command-specific settings + updatedSettings.commands = { ...updatedSettings.commands }; + if (kickLogs !== null) { + updatedSettings.commands.kick = kickLogs; + } + if (banLogs !== null) { + updatedSettings.commands.ban = banLogs; + } + if (timeoutLogs !== null) { + updatedSettings.commands.timeout = timeoutLogs; + } + + // Save settings + const saveResponse = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedSettings) + }); + + if (!saveResponse.ok) { + throw new Error('Failed to save settings'); + } + + const result = await saveResponse.json(); + + // Create response message + let responseMessage = 'Admin logs settings updated!\n\n'; + responseMessage += `**Enabled:** ${result.settings.enabled ? 'Yes' : 'No'}\n`; + if (result.settings.channelId) { + responseMessage += `**Channel:** <#${result.settings.channelId}>\n`; + } else { + responseMessage += `**Channel:** Not set\n`; + } + responseMessage += `**Kick Logs:** ${result.settings.commands.kick ? 'Enabled' : 'Disabled'}\n`; + responseMessage += `**Ban Logs:** ${result.settings.commands.ban ? 'Enabled' : 'Disabled'}\n`; + responseMessage += `**Timeout Logs:** ${result.settings.commands.timeout ? 'Enabled' : 'Disabled'}`; + + await interaction.reply({ + content: responseMessage, + flags: 64 + }); + + } catch (error) { + console.error('Error configuring admin logs:', error); + await interaction.reply({ + content: 'An error occurred while configuring admin logs. Please try again later.', + flags: 64 + }); + } + } +}; \ No newline at end of file diff --git a/discord-bot/commands/timeout.js b/discord-bot/commands/timeout.js new file mode 100644 index 0000000..3e53190 --- /dev/null +++ b/discord-bot/commands/timeout.js @@ -0,0 +1,187 @@ +const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); + +// Helper function to parse user from mention or ID +function parseUser(input, guild) { + // Check if it's a mention <@123456> or <@!123456> + const mentionMatch = input.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + return guild.members.cache.get(mentionMatch[1])?.user; + } + + // Check if it's a user ID + if (/^\d{15,20}$/.test(input)) { + return guild.members.cache.get(input)?.user; + } + + // Try to find by username or global name + const member = guild.members.cache.find(m => + (m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) || + m.user.username.toLowerCase().includes(input.toLowerCase()) || + (m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) || + m.user.username.toLowerCase() === input.toLowerCase() + ); + return member?.user; +} + +// Helper function to log moderation actions +async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) { + try { + const logData = { + guildId, + action, + targetUserId, + targetUsername, + moderatorUserId, + moderatorUsername, + reason, + duration, + endDate + }; + + const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(logData) + }); + + if (!response.ok) { + console.error('Failed to log moderation action:', response.statusText); + } + } catch (error) { + console.error('Error logging moderation action:', error); + } +} + +module.exports = { + name: 'timeout', + description: 'Timeout a user in the server', + enabled: true, + builder: new SlashCommandBuilder() + .setName('timeout') + .setDescription('Timeout a user in the server') + .addStringOption(option => + option.setName('user') + .setDescription('The user to timeout (mention or user ID)') + .setRequired(true)) + .addIntegerOption(option => + option.setName('duration') + .setDescription('Duration in minutes (max 40320 minutes = 28 days)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(40320)) + .addStringOption(option => + option.setName('reason') + .setDescription('Reason for the timeout (minimum 3 words)') + .setRequired(true)), + async execute(interaction) { + // Check if user has moderate members permissions (required for timeout) + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) { + return await interaction.reply({ + content: 'You do not have permission to timeout members.', + flags: 64 + }); + } + + // Check if bot has moderate members permissions + if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.ModerateMembers)) { + return await interaction.reply({ + content: 'I do not have permission to timeout members.', + flags: 64 + }); + } + + const userInput = interaction.options.getString('user'); + const duration = interaction.options.getInteger('duration'); + const reason = interaction.options.getString('reason'); + + // Parse the user from the input + const user = parseUser(userInput, interaction.guild); + if (!user) { + return await interaction.reply({ + content: 'Could not find that user. Please provide a valid user mention or user ID.', + flags: 64 + }); + } + + // Validate reason has at least 3 words + const reasonWords = reason.trim().split(/\s+/); + if (reasonWords.length < 3) { + return await interaction.reply({ + content: 'Reason must be at least 3 words long.', + flags: 64 + }); + } + + // Cannot timeout yourself + if (user.id === interaction.user.id) { + return await interaction.reply({ + content: 'You cannot timeout yourself.', + flags: 64 + }); + } + + // Cannot timeout the bot + if (user.id === interaction.guild.members.me.id) { + return await interaction.reply({ + content: 'I cannot timeout myself.', + flags: 64 + }); + } + + // Check if user is in the server + const member = interaction.guild.members.cache.get(user.id); + if (!member) { + return await interaction.reply({ + content: 'That user is not in this server.', + flags: 64 + }); + } + + // Check role hierarchy + if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) { + return await interaction.reply({ + content: 'You cannot timeout a member with a higher or equal role.', + flags: 64 + }); + } + + if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) { + return await interaction.reply({ + content: 'I cannot timeout a member with a higher or equal role.', + flags: 64 + }); + } + + // Check if user is already timed out + if (member.communicationDisabledUntil) { + return await interaction.reply({ + content: 'This user is already timed out.', + flags: 64 + }); + } + + try { + const timeoutDuration = duration * 60 * 1000; // Convert minutes to milliseconds + const timeoutUntil = new Date(Date.now() + timeoutDuration); + + await member.timeout(timeoutDuration, reason); + await interaction.reply({ + content: `Successfully timed out ${user.global_name || user.username} for ${duration} minutes. Reason: ${reason}`, + flags: 64 + }); + + // Log the action + const durationString = duration >= 1440 ? `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m` : + duration >= 60 ? `${Math.floor(duration / 60)}h ${duration % 60}m` : `${duration}m`; + await logModerationAction(interaction.guildId, 'timeout', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, durationString, timeoutUntil); + } catch (error) { + console.error('Error timing out user:', error); + await interaction.reply({ + content: 'Failed to timeout the user. Please try again.', + flags: 64 + }); + } + } +}; \ No newline at end of file diff --git a/discord-bot/deploy-commands.js b/discord-bot/deploy-commands.js index 90525ab..3b047b0 100644 --- a/discord-bot/deploy-commands.js +++ b/discord-bot/deploy-commands.js @@ -37,4 +37,24 @@ const deployCommands = async (guildId) => { } }; +// Standalone execution +if (require.main === module) { + const { Client, GatewayIntentBits } = require('discord.js'); + const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + + client.once('ready', async () => { + console.log(`Logged in as ${client.user.tag}`); + console.log(`Deploying commands to ${client.guilds.cache.size} guilds...`); + + for (const [guildId, guild] of client.guilds.cache) { + await deployCommands(guildId); + } + + console.log('All commands deployed!'); + client.destroy(); + }); + + client.login(process.env.DISCORD_BOT_TOKEN); +} + module.exports = deployCommands; diff --git a/discord-bot/events/guildCreate.js b/discord-bot/events/guildCreate.js new file mode 100644 index 0000000..fd9503c --- /dev/null +++ b/discord-bot/events/guildCreate.js @@ -0,0 +1,15 @@ +const api = require('../api'); + +module.exports = { + name: 'guildCreate', + execute: async (guild, client) => { + console.log(`Bot joined guild: ${guild.name} (${guild.id})`); + + try { + // Publish SSE event for bot status change + await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: true }); + } catch (error) { + console.error('Error publishing bot join event:', error); + } + }, +}; \ No newline at end of file diff --git a/discord-bot/events/guildDelete.js b/discord-bot/events/guildDelete.js new file mode 100644 index 0000000..071a5e3 --- /dev/null +++ b/discord-bot/events/guildDelete.js @@ -0,0 +1,15 @@ +const api = require('../api'); + +module.exports = { + name: 'guildDelete', + execute: async (guild, client) => { + console.log(`Bot left guild: ${guild.name} (${guild.id})`); + + try { + // Publish SSE event for bot status change + await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: false }); + } catch (error) { + console.error('Error publishing bot leave event:', error); + } + }, +}; \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.js index d03dffe..5f3ac48 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -3,7 +3,7 @@ import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-route import { ThemeProvider } from './contexts/ThemeContext'; import { UserProvider } from './contexts/UserContext'; import { BackendProvider, useBackend } from './contexts/BackendContext'; -import { CssBaseline } from '@mui/material'; +import { CssBaseline, Box } from '@mui/material'; import Login from './components/Login'; import Dashboard from './components/Dashboard'; import ServerSettings from './components/server/ServerSettings'; @@ -11,6 +11,7 @@ import NavBar from './components/NavBar'; import HelpPage from './components/server/HelpPage'; import DiscordPage from './components/DiscordPage'; import MaintenancePage from './components/common/MaintenancePage'; +import Footer from './components/common/Footer'; function AppInner() { const { backendOnline, checking, forceCheck } = useBackend(); @@ -23,23 +24,41 @@ function AppInner() { - - - {!backendOnline ? ( - - ) : ( - <> - - - } /> - } /> - } /> - } /> - } /> - - - )} - + + + + {!backendOnline ? ( + + ) : ( + <> + + + + } /> + } /> + } /> + } /> + } /> + + +