Moderation Update
This commit is contained in:
407
README.md
407
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 <repository-url>
|
||||
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>`
|
||||
- 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: <localized start time>`.
|
||||
|
||||
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
|
||||
|
||||
383
backend/index.js
383
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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
54
checklist.md
54
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 - <Page Name>` (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
|
||||
|
||||
172
discord-bot/commands/ban.js
Normal file
172
discord-bot/commands/ban.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
167
discord-bot/commands/kick.js
Normal file
167
discord-bot/commands/kick.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
123
discord-bot/commands/setup-adminlogs.js
Normal file
123
discord-bot/commands/setup-adminlogs.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
187
discord-bot/commands/timeout.js
Normal file
187
discord-bot/commands/timeout.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
15
discord-bot/events/guildCreate.js
Normal file
15
discord-bot/events/guildCreate.js
Normal file
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
15
discord-bot/events/guildDelete.js
Normal file
15
discord-bot/events/guildDelete.js
Normal file
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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() {
|
||||
<UserProvider>
|
||||
<ThemeProvider>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<TitleSetter />
|
||||
{!backendOnline ? (
|
||||
<MaintenancePage onRetry={handleRetry} checking={checking} />
|
||||
) : (
|
||||
<>
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/server/:guildId" element={<ServerSettings />} />
|
||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
||||
<Route path="/discord" element={<DiscordPage />} />
|
||||
</Routes>
|
||||
</>
|
||||
)}
|
||||
</Router>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Router>
|
||||
<TitleSetter />
|
||||
{!backendOnline ? (
|
||||
<MaintenancePage onRetry={handleRetry} checking={checking} />
|
||||
) : (
|
||||
<>
|
||||
<NavBar />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/server/:guildId" element={<ServerSettings />} />
|
||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
||||
<Route path="/discord" element={<DiscordPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</Router>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button } from '@mui/material';
|
||||
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button, Container } from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { UserContext } from '../contexts/UserContext';
|
||||
import { useBackend } from '../contexts/BackendContext';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import WavingHandIcon from '@mui/icons-material/WavingHand';
|
||||
import { get, post } from '../lib/api';
|
||||
|
||||
import ConfirmDialog from './common/ConfirmDialog';
|
||||
@@ -13,6 +16,7 @@ const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
const { eventTarget } = useBackend();
|
||||
|
||||
const [guilds, setGuilds] = useState([]);
|
||||
const [botStatus, setBotStatus] = useState({});
|
||||
@@ -89,6 +93,25 @@ const Dashboard = () => {
|
||||
fetchStatuses();
|
||||
}, [guilds, API_BASE]);
|
||||
|
||||
// Listen for bot status updates
|
||||
useEffect(() => {
|
||||
if (!eventTarget) return;
|
||||
|
||||
const onBotStatusUpdate = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
setBotStatus(prev => ({
|
||||
...prev,
|
||||
[data.guildId]: data.isBotInServer
|
||||
}));
|
||||
};
|
||||
|
||||
eventTarget.addEventListener('botStatusUpdate', onBotStatusUpdate);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener('botStatusUpdate', onBotStatusUpdate);
|
||||
};
|
||||
}, [eventTarget]);
|
||||
|
||||
// Dashboard no longer loads live settings; that's on the server settings page
|
||||
|
||||
// Live notifications handlers were removed from Dashboard
|
||||
@@ -139,18 +162,32 @@ const Dashboard = () => {
|
||||
const handleSnackbarClose = () => setSnackbarOpen(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Container maxWidth="lg" sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
||||
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<DashboardIcon sx={{ mr: 1, fontSize: { xs: '2rem', sm: '2.5rem' } }} />
|
||||
<Typography variant={{ xs: 'h4', sm: 'h3' }}>Dashboard</Typography>
|
||||
</Box>
|
||||
{user && <Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 4, sm: 5 }, mb: { xs: 4, sm: 5 } }}>
|
||||
<WavingHandIcon sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
<Typography
|
||||
variant={{ xs: 'h3', sm: 'h2' }}
|
||||
sx={{
|
||||
fontWeight: 300,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Welcome back, {user.username}
|
||||
</Typography>
|
||||
</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
|
||||
|
||||
<Grid container spacing={3} justifyContent="center">
|
||||
{guilds.map(guild => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Grid item xs={12} sm={6} md={6} lg={4} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Card
|
||||
onClick={() => handleCardClick(guild)}
|
||||
@@ -163,6 +200,11 @@ const Dashboard = () => {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(-2px) scale(0.98)',
|
||||
transition: 'transform 0.1s ease-in-out',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.4)',
|
||||
},
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
@@ -171,30 +213,66 @@ const Dashboard = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 2 }}>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: { xs: 1.5, sm: 2 } }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
width: { xs: 60, sm: 80 },
|
||||
height: { xs: 60, sm: 80 },
|
||||
borderRadius: '50%',
|
||||
mb: 2,
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
mb: 1,
|
||||
fontSize: { xs: '1rem', sm: '1.25rem' },
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: 1.2,
|
||||
minHeight: { xs: '2.4rem', sm: '2.5rem' },
|
||||
}}
|
||||
>
|
||||
{guild.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
{botStatus[guild.id] ? (
|
||||
<Button variant="contained" color="error" size="small" onClick={(e) => handleLeaveBot(e, guild)} startIcon={<RemoveCircleOutlineIcon />}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => handleLeaveBot(e, guild)}
|
||||
startIcon={<RemoveCircleOutlineIcon />}
|
||||
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||
>
|
||||
Leave
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" color="success" size="small" onClick={(e) => handleInviteBot(e, guild)} startIcon={<PersonAddIcon />}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="small"
|
||||
onClick={(e) => handleInviteBot(e, guild)}
|
||||
startIcon={<PersonAddIcon />}
|
||||
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }} aria-label="server menu">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }}
|
||||
aria-label="server menu"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -206,26 +284,26 @@ const Dashboard = () => {
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
|
||||
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
|
||||
</Menu>
|
||||
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
|
||||
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
|
||||
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
|
||||
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
|
||||
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
|
||||
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<ConfirmDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onConfirm={handleConfirmLeave}
|
||||
title="Confirm Leave"
|
||||
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
|
||||
/>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onConfirm={handleConfirmLeave}
|
||||
title="Confirm Leave"
|
||||
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const NavBar = () => {
|
||||
const closeMenu = () => { setAnchorEl(null); setOpen(false); };
|
||||
|
||||
return (
|
||||
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<AppBar position="static" color="default" elevation={1} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.12)' }}>
|
||||
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: { xs: 2, sm: 4 } }}>
|
||||
<Box>
|
||||
<IconButton onClick={toggleOpen} aria-label="menu" size="large" sx={{ bgcolor: open ? 'primary.main' : 'transparent', color: open ? 'white' : 'text.primary' }}>
|
||||
@@ -33,8 +33,12 @@ const NavBar = () => {
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'block', sm: 'none' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
||||
ECS
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
||||
ECS - EHDCHADSWORTH
|
||||
EhChadServices
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
|
||||
32
frontend/src/components/common/Footer.js
Normal file
32
frontend/src/components/common/Footer.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: { xs: 1, sm: 2 },
|
||||
px: { xs: 1, sm: 2 },
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.grey[200]
|
||||
: theme.palette.grey[800],
|
||||
textAlign: 'center',
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
||||
}}
|
||||
>
|
||||
© ehchadservices.com 2025
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};;
|
||||
|
||||
export default Footer;
|
||||
@@ -26,10 +26,10 @@ const HelpPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
|
||||
<Typography variant="h5">Commands List</Typography>
|
||||
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, mb: 2 }}>
|
||||
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
|
||||
<Typography variant={{ xs: 'h5', sm: 'h5' }}>Commands List</Typography>
|
||||
</Box>
|
||||
<Box sx={{ marginTop: 2 }}>
|
||||
{commands.length === 0 && <Typography>No commands available.</Typography>}
|
||||
@@ -43,7 +43,7 @@ const HelpPage = () => {
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useBackend } from '../../contexts/BackendContext';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, Snackbar, Alert } from '@mui/material';
|
||||
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, InputLabel, Snackbar, Alert, Autocomplete } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
// UserSettings moved to NavBar
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { UserContext } from '../../contexts/UserContext';
|
||||
|
||||
// Use a relative API base by default so the frontend talks to the same origin that served it.
|
||||
// In development you can set REACT_APP_API_BASE to a full URL if needed.
|
||||
@@ -18,6 +19,7 @@ const ServerSettings = () => {
|
||||
const { guildId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
// settings state removed (not used) to avoid lint warnings
|
||||
const [isBotInServer, setIsBotInServer] = useState(false);
|
||||
@@ -35,8 +37,6 @@ const ServerSettings = () => {
|
||||
const [deleting, setDeleting] = useState({});
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
// SSE connection status (not currently displayed)
|
||||
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
|
||||
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
|
||||
@@ -44,18 +44,6 @@ const ServerSettings = () => {
|
||||
const [pendingKickUser, setPendingKickUser] = useState(null);
|
||||
const [commandsExpanded, setCommandsExpanded] = useState(false);
|
||||
const [liveExpanded, setLiveExpanded] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
|
||||
const [liveEnabled, setLiveEnabled] = useState(false);
|
||||
const [liveChannelId, setLiveChannelId] = useState('');
|
||||
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
||||
const [liveMessage, setLiveMessage] = useState('');
|
||||
const [liveCustomMessage, setLiveCustomMessage] = useState('');
|
||||
const [watchedUsers, setWatchedUsers] = useState([]);
|
||||
const [liveStatus, setLiveStatus] = useState({});
|
||||
const [liveTabValue, setLiveTabValue] = useState(0);
|
||||
const [kickUsers, setKickUsers] = useState([]);
|
||||
const [kickStatus, setKickStatus] = useState({});
|
||||
const [kickUser, setKickUser] = useState('');
|
||||
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
||||
welcome: {
|
||||
enabled: false,
|
||||
@@ -70,6 +58,39 @@ const ServerSettings = () => {
|
||||
customMessage: '',
|
||||
},
|
||||
});
|
||||
const [adminLogsSettings, setAdminLogsSettings] = useState({
|
||||
enabled: false,
|
||||
channelId: '',
|
||||
commands: { kick: true, ban: true, timeout: true }
|
||||
});
|
||||
const [adminLogs, setAdminLogs] = useState([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState('');
|
||||
const [moderationTarget, setModerationTarget] = useState('');
|
||||
const [moderationReason, setModerationReason] = useState('');
|
||||
const [timeoutDuration, setTimeoutDuration] = useState('');
|
||||
const [serverMembers, setServerMembers] = useState([]);
|
||||
|
||||
// Add back missing state variables that are still used in the code
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
maxUses: '',
|
||||
expiresAt: '',
|
||||
channelId: ''
|
||||
});
|
||||
const [liveTabValue, setLiveTabValue] = useState(0);
|
||||
const [liveEnabled, setLiveEnabled] = useState(false);
|
||||
const [liveChannelId, setLiveChannelId] = useState('');
|
||||
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
||||
const [liveMessage, setLiveMessage] = useState('Twitch user {user} is now live!');
|
||||
const [liveCustomMessage, setLiveCustomMessage] = useState('');
|
||||
const [watchedUsers, setWatchedUsers] = useState([]);
|
||||
const [kickUsers, setKickUsers] = useState([]);
|
||||
const [liveStatus, setLiveStatus] = useState({});
|
||||
|
||||
// Confirm dialog states for admin logs
|
||||
const [deleteLogDialog, setDeleteLogDialog] = useState({ open: false, logId: null, logAction: '' });
|
||||
const [deleteAllLogsDialog, setDeleteAllLogsDialog] = useState(false);
|
||||
|
||||
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
|
||||
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
|
||||
@@ -103,13 +124,13 @@ const ServerSettings = () => {
|
||||
}).catch(() => {
|
||||
// ignore when offline
|
||||
});
|
||||
|
||||
// Fetch bot status
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
|
||||
setIsBotInServer(response.data.isBotInServer);
|
||||
}).catch(() => setIsBotInServer(false));
|
||||
|
||||
// Fetch channels
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
|
||||
setChannels(response.data);
|
||||
}).catch(() => {
|
||||
setChannels([]);
|
||||
});
|
||||
// Fetch channels - moved to separate useEffect to depend on bot status
|
||||
|
||||
// Fetch welcome/leave settings
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
|
||||
@@ -144,18 +165,46 @@ const ServerSettings = () => {
|
||||
setLiveCustomMessage(s.customMessage || '');
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch admin logs settings
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`).then(response => {
|
||||
if (response.data) {
|
||||
setAdminLogsSettings(response.data);
|
||||
setSelectedChannelId(response.data.channelId || '');
|
||||
}
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
|
||||
// Fetch admin logs
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/admin-logs`).then(response => {
|
||||
setAdminLogs(response.data || []);
|
||||
}).catch(() => setAdminLogs([]));
|
||||
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
|
||||
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/kick-users`).then(resp => setKickUsers(resp.data || [])).catch(() => setKickUsers([]));
|
||||
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
|
||||
|
||||
// Fetch server members for moderation
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/members`).then(resp => setServerMembers(resp.data || [])).catch(() => setServerMembers([]));
|
||||
|
||||
// Open commands accordion if navigated from Help back button
|
||||
if (location.state && location.state.openCommands) {
|
||||
setCommandsExpanded(true);
|
||||
}
|
||||
|
||||
}, [guildId, location.state]);
|
||||
}, [guildId, location.state, isBotInServer]);
|
||||
|
||||
// Fetch channels only when bot is in server
|
||||
useEffect(() => {
|
||||
if (!guildId || !isBotInServer) return;
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
|
||||
setChannels(response.data);
|
||||
}).catch(() => {
|
||||
setChannels([]);
|
||||
});
|
||||
}, [guildId, isBotInServer]);
|
||||
|
||||
// Listen to backend events for live notifications and twitch user updates
|
||||
const { eventTarget } = useBackend();
|
||||
@@ -198,10 +247,35 @@ const ServerSettings = () => {
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
|
||||
};
|
||||
|
||||
const onAdminLogAdded = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
// Add the new log to the beginning of the list
|
||||
setAdminLogs(prev => [data.log, ...prev.slice(0, 49)]); // Keep only latest 50
|
||||
};
|
||||
|
||||
const onAdminLogDeleted = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
setAdminLogs(prev => prev.filter(log => log.id !== data.logId));
|
||||
};
|
||||
|
||||
const onAdminLogsCleared = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
setAdminLogs([]);
|
||||
};
|
||||
|
||||
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
|
||||
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
||||
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||
eventTarget.addEventListener('commandToggle', onCommandToggle);
|
||||
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
|
||||
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
@@ -209,12 +283,15 @@ const ServerSettings = () => {
|
||||
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
|
||||
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||
eventTarget.removeEventListener('commandToggle', onCommandToggle);
|
||||
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
|
||||
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||
} catch (err) {}
|
||||
};
|
||||
}, [eventTarget, guildId]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
const handleToggleLive = async (e) => {
|
||||
@@ -229,10 +306,6 @@ const ServerSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbarOpen(false);
|
||||
};
|
||||
|
||||
const handleAutoroleSettingUpdate = (newSettings) => {
|
||||
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
|
||||
.then(response => {
|
||||
@@ -330,6 +403,100 @@ const ServerSettings = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleAdminLogsSettingChange = async (key, value) => {
|
||||
const newSettings = { ...adminLogsSettings, [key]: value };
|
||||
setAdminLogsSettings(newSettings);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`, newSettings);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to update admin logs settings.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminLogsCommandChange = async (command, enabled) => {
|
||||
const newSettings = {
|
||||
...adminLogsSettings,
|
||||
commands: { ...adminLogsSettings.commands, [command]: enabled }
|
||||
};
|
||||
setAdminLogsSettings(newSettings);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`, newSettings);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to update admin logs settings.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (logId) => {
|
||||
try {
|
||||
await axios.delete(`${API_BASE}/api/servers/${guildId}/admin-logs/${logId}`);
|
||||
setAdminLogs(prev => prev.filter(log => log.id !== logId));
|
||||
setSnackbarMessage('Log deleted successfully.');
|
||||
setSnackbarOpen(true);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to delete log.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
setDeleteLogDialog({ open: false, logId: null, logAction: '' });
|
||||
};
|
||||
|
||||
const handleDeleteAllLogs = async () => {
|
||||
try {
|
||||
await axios.delete(`${API_BASE}/api/servers/${guildId}/admin-logs`);
|
||||
setAdminLogs([]);
|
||||
setSnackbarMessage('All logs deleted successfully.');
|
||||
setSnackbarOpen(true);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to delete all logs.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
setDeleteAllLogsDialog(false);
|
||||
};
|
||||
|
||||
const handleModerationAction = async (action) => {
|
||||
// Validate reason has at least 3 words
|
||||
const reasonWords = moderationReason.trim().split(/\s+/);
|
||||
if (reasonWords.length < 3) {
|
||||
setSnackbarMessage('Reason must be at least 3 words long.');
|
||||
setSnackbarOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
action,
|
||||
target: moderationTarget.trim(),
|
||||
reason: moderationReason.trim(),
|
||||
moderator: {
|
||||
id: user?.id,
|
||||
username: user?.username,
|
||||
global_name: user?.global_name,
|
||||
discriminator: user?.discriminator
|
||||
}
|
||||
};
|
||||
|
||||
if (action === 'timeout') {
|
||||
payload.duration = parseInt(timeoutDuration);
|
||||
}
|
||||
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/moderate`, payload);
|
||||
|
||||
setSnackbarMessage(`${action.charAt(0).toUpperCase() + action.slice(1)} action completed successfully!`);
|
||||
setSnackbarOpen(true);
|
||||
setModerationTarget('');
|
||||
setModerationReason('');
|
||||
if (action === 'timeout') {
|
||||
setTimeoutDuration('');
|
||||
}
|
||||
} catch (error) {
|
||||
setSnackbarMessage(`Failed to ${action} user.`);
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll Twitch live status for watched users (simple interval). Avoid spamming when list empty or feature disabled.
|
||||
useEffect(() => {
|
||||
let timer = null;
|
||||
@@ -368,7 +535,7 @@ const ServerSettings = () => {
|
||||
const login = (s.user_login || '').toLowerCase();
|
||||
map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count };
|
||||
}
|
||||
setKickStatus(map);
|
||||
// kickStatus not used since Kick functionality is disabled
|
||||
} catch (e) {
|
||||
// network errors ignored
|
||||
}
|
||||
@@ -379,13 +546,31 @@ const ServerSettings = () => {
|
||||
}, [kickUsers]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
mb: 2
|
||||
}}>
|
||||
<IconButton onClick={handleBack} sx={{ borderRadius: '50%', boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)' }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h4" component="h1" sx={{ margin: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
variant={{ xs: 'h5', sm: 'h4' }}
|
||||
component="h1"
|
||||
sx={{
|
||||
margin: 0,
|
||||
textAlign: { xs: 'center', sm: 'left' },
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{server ? `Server Settings for ${server.name}` : 'Loading...'}
|
||||
</Typography>
|
||||
{isBotInServer ? (
|
||||
@@ -418,7 +603,8 @@ const ServerSettings = () => {
|
||||
{(() => {
|
||||
const protectedOrder = ['help', 'manage-commands'];
|
||||
const protectedCmds = protectedOrder.map(name => commandsList.find(c => c.name === name)).filter(Boolean);
|
||||
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
|
||||
const adminCommands = ['kick', 'ban', 'timeout'];
|
||||
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name) && !adminCommands.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
|
||||
return (
|
||||
<>
|
||||
{protectedCmds.map(cmd => (
|
||||
@@ -664,7 +850,7 @@ const ServerSettings = () => {
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Live Notifications Accordion */}
|
||||
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}>
|
||||
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Live Notifications</Typography>
|
||||
</AccordionSummary>
|
||||
@@ -834,7 +1020,282 @@ const ServerSettings = () => {
|
||||
<Typography variant="h6">Admin Commands</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>Coming soon...</Typography>
|
||||
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable admin commands.</Typography>}
|
||||
<Accordion sx={{ marginTop: '10px' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1">Moderation Commands</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>/kick</Typography>
|
||||
<Typography variant="body2">Kick a user from the server</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Requires: Kick Members permission</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>/ban</Typography>
|
||||
<Typography variant="body2">Ban a user from the server</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Requires: Ban Members permission</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>/timeout</Typography>
|
||||
<Typography variant="body2">Timeout a user in the server</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Requires: Moderate Members permission</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Admin Logs</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable admin logs.</Typography>}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.enabled || false}
|
||||
onChange={(e) => handleAdminLogsSettingChange('enabled', e.target.checked)}
|
||||
disabled={!isBotInServer}
|
||||
/>
|
||||
}
|
||||
label="Enable Admin Logging"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Log Channel</InputLabel>
|
||||
<Select
|
||||
value={selectedChannelId}
|
||||
onChange={(e) => {
|
||||
setSelectedChannelId(e.target.value);
|
||||
handleAdminLogsSettingChange('channelId', e.target.value);
|
||||
}}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
label="Log Channel"
|
||||
>
|
||||
{channels.filter(channel => channel.type === 0).map(channel => (
|
||||
<MenuItem key={channel.id} value={channel.id}>
|
||||
# {channel.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>Log Commands:</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, pl: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.commands?.kick !== false}
|
||||
onChange={(e) => handleAdminLogsCommandChange('kick', e.target.checked)}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
/>
|
||||
}
|
||||
label="Log Kick Actions"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.commands?.ban !== false}
|
||||
onChange={(e) => handleAdminLogsCommandChange('ban', e.target.checked)}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
/>
|
||||
}
|
||||
label="Log Ban Actions"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.commands?.timeout !== false}
|
||||
onChange={(e) => handleAdminLogsCommandChange('timeout', e.target.checked)}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
/>
|
||||
}
|
||||
label="Log Timeout Actions"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2, mb: 1 }}>
|
||||
<Typography variant="subtitle1">Recent Logs:</Typography>
|
||||
{adminLogs.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => setDeleteAllLogsDialog(true)}
|
||||
startIcon={<DeleteIcon />}
|
||||
>
|
||||
Delete All Logs
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #ddd', borderRadius: 1, p: 1 }}>
|
||||
{adminLogs.length === 0 ? (
|
||||
<Typography>No logs available.</Typography>
|
||||
) : (
|
||||
adminLogs.map(log => (
|
||||
<Box key={log.id} sx={{ p: 1, border: '1px solid #eee', mb: 1, borderRadius: 1, bgcolor: 'background.paper' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{log.action.toUpperCase()} - {log.targetUsername || 'Unknown User'} by {log.moderatorUsername || 'Unknown User'}
|
||||
</Typography>
|
||||
<Typography variant="body2">Reason: {log.reason}</Typography>
|
||||
{log.duration && <Typography variant="body2">Duration: {log.duration}</Typography>}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteLogDialog({ open: true, logId: log.id, logAction: log.action })}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Moderation Actions</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable moderation actions.</Typography>}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Perform moderation actions directly from the web interface. You must have the appropriate Discord permissions.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Autocomplete
|
||||
options={serverMembers}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') return option;
|
||||
return option.globalName || option.username || option.displayName || option.id;
|
||||
}}
|
||||
value={serverMembers.find(member => member.id === moderationTarget) || null}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
setModerationTarget(newValue.id);
|
||||
} else {
|
||||
setModerationTarget('');
|
||||
}
|
||||
}}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
// Allow manual input of user IDs
|
||||
if (event && event.type === 'change') {
|
||||
setModerationTarget(newInputValue);
|
||||
}
|
||||
}}
|
||||
inputValue={moderationTarget}
|
||||
freeSolo
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="User"
|
||||
placeholder="Select user or enter user ID"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{option.globalName || option.username}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@{option.username} • ID: {option.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
if (!inputValue) return options.slice(0, 50); // Limit to 50 for performance
|
||||
|
||||
const filtered = options.filter(option =>
|
||||
option.username.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
(option.globalName && option.globalName.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
option.displayName.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
option.id.includes(inputValue)
|
||||
);
|
||||
|
||||
return filtered.slice(0, 50); // Limit results
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Reason (minimum 3 words)"
|
||||
value={moderationReason}
|
||||
onChange={(e) => setModerationReason(e.target.value)}
|
||||
placeholder="Enter reason for moderation action"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => handleModerationAction('kick')}
|
||||
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim()}
|
||||
>
|
||||
Kick User
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => handleModerationAction('ban')}
|
||||
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim()}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={timeoutDuration}
|
||||
onChange={(e) => setTimeoutDuration(e.target.value)}
|
||||
sx={{ width: 150 }}
|
||||
inputProps={{ min: 1, max: 40320 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => handleModerationAction('timeout')}
|
||||
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim() || !timeoutDuration}
|
||||
>
|
||||
Timeout User
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<ConfirmDialog
|
||||
@@ -936,7 +1397,28 @@ const ServerSettings = () => {
|
||||
title="Delete Kick User"
|
||||
message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`}
|
||||
/>
|
||||
</div>
|
||||
{/* Confirm dialog for deleting individual admin log */}
|
||||
<ConfirmDialog
|
||||
open={deleteLogDialog.open}
|
||||
onClose={() => setDeleteLogDialog({ open: false, logId: null, logAction: '' })}
|
||||
onConfirm={() => handleDeleteLog(deleteLogDialog.logId)}
|
||||
title="Delete Admin Log"
|
||||
message={`Are you sure you want to delete this ${deleteLogDialog.logAction} log? This action cannot be undone.`}
|
||||
/>
|
||||
{/* Confirm dialog for deleting all admin logs */}
|
||||
<ConfirmDialog
|
||||
open={deleteAllLogsDialog}
|
||||
onClose={() => setDeleteAllLogsDialog(false)}
|
||||
onConfirm={handleDeleteAllLogs}
|
||||
title="Delete All Admin Logs"
|
||||
message="Are you sure you want to delete all admin logs? This action cannot be undone."
|
||||
/>
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={() => setSnackbarOpen(false)}>
|
||||
<Alert onClose={() => setSnackbarOpen(false)} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ export function BackendProvider({ children }) {
|
||||
es.addEventListener('commandToggle', forward('commandToggle'));
|
||||
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
|
||||
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
|
||||
es.addEventListener('adminLogAdded', forward('adminLogAdded'));
|
||||
es.addEventListener('adminLogDeleted', forward('adminLogDeleted'));
|
||||
es.addEventListener('adminLogsCleared', forward('adminLogsCleared'));
|
||||
|
||||
es.onerror = () => {
|
||||
// Let consumers react to backendOnline state changes instead of surfacing connection errors
|
||||
|
||||
@@ -11,3 +11,28 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Global responsive styles */
|
||||
#root {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ensure content doesn't overflow on ultra-wide screens */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Responsive typography adjustments */
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
/* Ultra-wide screen adjustments */
|
||||
.MuiContainer-root {
|
||||
max-width: 1200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import { createTheme } from '@mui/material/styles';
|
||||
export const lightTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
background: {
|
||||
default: '#e8e8e8', // More greyish background, less bright white
|
||||
paper: '#ffffff',
|
||||
},
|
||||
primary: {
|
||||
main: '#1565c0', // Slightly darker blue for less brightness
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user