Compare commits

...

3 Commits

Author SHA1 Message Date
54cc4ea697 readme update 2025-10-04 10:41:29 -04:00
053ffe51f7 tweaked ui and updated invite command 2025-10-04 10:27:45 -04:00
834e77a93e fixed themes and ui added new features 2025-10-04 08:39:54 -04:00
12 changed files with 974 additions and 346 deletions

128
README.md
View File

@@ -1 +1,127 @@
# ECS FULL STACK
# 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.
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.
## Repository layout
- `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.
## What this project does
- 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.
## Quickstart — prerequisites
- 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
## Environment configuration (.env)
There are env files used by the backend and frontend. Create `.env` files in the `backend/` and `frontend/` folders for local development. Examples follow.
### backend/.env (example)
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
- `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.
### 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
```
2. Frontend
```powershell
cd frontend
npm install
# create frontend/.env with REACT_APP_API_BASE pointing to the backend
npm run start
```
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.
## 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.
- 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.
## Developer notes
- 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`.
## Next steps / suggestions
- 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.
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.
---
Updated: Oct 4, 2025

22
backend/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Example backend .env
# Set the host/interface to bind to (for Tailscale use your 100.x.y.z address)
HOST=0.0.0.0
PORT=3002
# Optional: fully-qualified base URLs
BACKEND_BASE=http://100.x.y.z:3002
FRONTEND_BASE=http://100.x.y.z:3000
# CORS origin (frontend origin) - set to frontend base for tighter security
CORS_ORIGIN=http://100.x.y.z:3000
# Optional invite delete protection
INVITE_API_KEY=replace-with-a-secret
# Discord credentials
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_BOT_TOKEN=your_bot_token
# Encryption key for backend db.json
ENCRYPTION_KEY=pick-a-long-random-string

View File

@@ -4,17 +4,68 @@ const cors = require('cors');
const app = express();
const port = process.env.PORT || 3001;
const host = process.env.HOST || '0.0.0.0';
const corsOrigin = process.env.CORS_ORIGIN || null; // e.g. 'http://example.com' or '*' or 'http://127.0.0.1:3000'
app.use(cors());
// Convenience base URLs (override if you want fully-qualified URLs)
const BACKEND_BASE = process.env.BACKEND_BASE || `http://${host}:${port}`;
const FRONTEND_BASE = process.env.FRONTEND_BASE || 'http://localhost:3000';
if (corsOrigin) {
app.use(cors({ origin: corsOrigin }));
} else {
app.use(cors());
}
app.use(express.json());
const axios = require('axios');
const crypto = require('crypto');
// Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes
const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret';
function generateInviteToken(guildId) {
const payload = JSON.stringify({ gid: guildId, iat: Date.now() });
const payloadB64 = Buffer.from(payload).toString('base64url');
const h = crypto.createHmac('sha256', inviteTokenSecret).update(payloadB64).digest('base64url');
return `${payloadB64}.${h}`;
}
function verifyInviteToken(token) {
try {
if (!token) return null;
const parts = token.split('.');
if (parts.length !== 2) return null;
const [payloadB64, sig] = parts;
const expected = crypto.createHmac('sha256', inviteTokenSecret).update(payloadB64).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null;
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
if (!payload || !payload.gid || !payload.iat) return null;
if (Date.now() - payload.iat > INVITE_TOKEN_TTL_MS) return null;
return payload;
} catch (e) {
return null;
}
}
app.get('/auth/discord', (req, res) => {
const url = `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent('http://localhost:3002/auth/discord/callback')}&response_type=code&scope=identify%20guilds`;
const redirectUri = `${BACKEND_BASE}/auth/discord/callback`;
const url = `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20guilds`;
res.redirect(url);
});
// Provide a short-lived invite token for frontend actions (delete). Not a replacement for proper auth
app.get('/api/servers/:guildId/invite-token', (req, res) => {
const { guildId } = req.params;
try {
const token = generateInviteToken(guildId);
res.json({ token });
} catch (err) {
res.status(500).json({ success: false, message: 'Failed to generate token' });
}
});
app.get('/auth/discord/callback', async (req, res) => {
const code = req.query.code;
if (!code) {
@@ -27,7 +78,7 @@ app.get('/auth/discord/callback', async (req, res) => {
params.append('client_secret', process.env.DISCORD_CLIENT_SECRET);
params.append('grant_type', 'authorization_code');
params.append('code', code);
params.append('redirect_uri', 'http://localhost:3002/auth/discord/callback');
params.append('redirect_uri', `${BACKEND_BASE}/auth/discord/callback`);
const response = await axios.post('https://discord.com/api/oauth2/token', params, {
headers: {
@@ -55,7 +106,7 @@ app.get('/auth/discord/callback', async (req, res) => {
const db = readDb();
user.theme = db.users && db.users[user.id] ? db.users[user.id].theme : 'light';
const guilds = adminGuilds;
res.redirect(`http://localhost:3000/dashboard?user=${encodeURIComponent(JSON.stringify(user))}&guilds=${encodeURIComponent(JSON.stringify(guilds))}`);
res.redirect(`${FRONTEND_BASE}/dashboard?user=${encodeURIComponent(JSON.stringify(user))}&guilds=${encodeURIComponent(JSON.stringify(guilds))}`);
} catch (error) {
console.error('Error during Discord OAuth2 callback:', error);
res.status(500).send('Internal Server Error');
@@ -283,10 +334,133 @@ app.get('/api/servers/:guildId/commands', (req, res) => {
}
});
// INVITES: create, list, delete
app.get('/api/servers/:guildId/invites', async (req, res) => {
try {
const { guildId } = req.params;
const db = readDb();
const saved = (db[guildId] && db[guildId].invites) ? db[guildId].invites : [];
// try to enrich with live data where possible
const guild = bot.client.guilds.cache.get(guildId);
let liveInvites = [];
if (guild) {
try {
const fetched = await guild.invites.fetch();
liveInvites = Array.from(fetched.values());
} catch (e) {
// ignore fetch errors
}
}
const combined = saved.map(inv => {
const live = liveInvites.find(li => li.code === inv.code);
return {
...inv,
uses: live ? live.uses : inv.uses || 0,
maxUses: inv.maxUses || (live ? live.maxUses : 0),
maxAge: inv.maxAge || (live ? live.maxAge : 0),
};
});
res.json(combined);
} catch (error) {
console.error('Error listing invites:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/invites', async (req, res) => {
try {
const { guildId } = req.params;
const { channelId, maxAge, maxUses, temporary } = req.body || {};
const guild = bot.client.guilds.cache.get(guildId);
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
let channel = null;
if (channelId) {
try { channel = await guild.channels.fetch(channelId); } catch (e) { channel = null; }
}
if (!channel) {
// fall back to first text channel
const channels = await guild.channels.fetch();
channel = channels.find(c => c.type === 0) || channels.first();
}
if (!channel) return res.status(400).json({ success: false, message: 'No channel available to create invite' });
const inviteOptions = {
maxAge: typeof maxAge === 'number' ? maxAge : 0,
maxUses: typeof maxUses === 'number' ? maxUses : 0,
temporary: !!temporary,
unique: true,
};
const invite = await channel.createInvite(inviteOptions);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
if (!db[guildId].invites) db[guildId].invites = [];
const item = {
code: invite.code,
url: invite.url,
channelId: channel.id,
createdAt: new Date().toISOString(),
maxUses: invite.maxUses || inviteOptions.maxUses || 0,
maxAge: invite.maxAge || inviteOptions.maxAge || 0,
temporary: !!invite.temporary,
};
db[guildId].invites.push(item);
writeDb(db);
res.json({ success: true, invite: item });
} catch (error) {
console.error('Error creating invite:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
// Require a short-lived invite token issued by /api/servers/:guildId/invite-token
const providedToken = req.headers['x-invite-token'];
const payload = verifyInviteToken(providedToken);
if (!payload || payload.gid !== req.params.guildId) {
return res.status(401).json({ success: false, message: 'Unauthorized: missing or invalid invite token' });
}
try {
const { guildId, code } = req.params;
const db = readDb();
const guild = bot.client.guilds.cache.get(guildId);
// Try to delete on Discord if possible
if (guild) {
try {
// fetch invites and delete matching code
const fetched = await guild.invites.fetch();
const inv = fetched.find(i => i.code === code);
if (inv) await inv.delete();
} catch (e) {
// ignore
}
}
if (db[guildId] && db[guildId].invites) {
db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
writeDb(db);
}
res.json({ success: true });
} catch (error) {
console.error('Error deleting invite:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
const bot = require('../discord-bot');
bot.login();
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
app.listen(port, host, () => {
console.log(`Server is running on ${host}:${port}`);
});

View File

@@ -1,136 +1,124 @@
# Project Checklist
# Project Checklist (tidy & current)
Below are the implemented features and current status as reflected in the repository. Items marked [x] are implemented and wired; unchecked items are pending.
## Backend
- [x] Create backend directory
- [x] Initialize Node.js project
- [x] Install backend dependencies
- [x] Create a basic Express server
- [x] Set up Discord OAuth2
- [x] Create API endpoint to get user's servers
- [x] Store user theme preference on the backend
- [x] Create API endpoint to make bot leave a server
- [x] Encrypt user information in `db.json`.
- [x] Basic Express server and project setup
- [x] Discord OAuth2 endpoints
- [x] API endpoints for servers, channels, roles, leave, settings
- [x] Persist encrypted data to `db.json`
## Frontend
- [x] Create login page
- [x] Create dashboard page
- [x] Connect frontend to backend
- [x] Improve frontend UI with a component library
- [x] Implement server-specific settings pages
- [x] Persist user data on the frontend
- [x] Add logout functionality
- [x] Add more styling and animations to the UI
- [x] Remember scroll position
- [x] Fix issue with fetching server names
- [x] Implement theme switching (light, dark, Discord grey)
- [x] Create a user settings menu
- [x] Set Discord grey as the default theme
- [x] Refine user settings menu UI
- [x] Further refine user settings menu UI
- [x] Add server icons to dashboard cards
- [x] Ensure responsive and uniform card sizing
- [x] Fine-tune card sizing
- [x] Further fine-tune card UI
- [x] Add Commands section to server settings page
- [x] Refine 'Back to Dashboard' button
- [x] Remove 'Open Help Page' button from individual command controls (moved to dedicated Commands List page)
- [x] Rename Help nav/button to 'Commands List' and update page title
- [x] Restructure Commands list UI to show per-command toggles and cleaner layout
- [x] Ensure frontend persists selections and doesn't overwrite other settings
- [x] Improve NavBar layout and styling for clarity and compactness
- [x] Implement single-hamburger NavBar (hamburger toggles to X; buttons hidden when collapsed)
- [x] Commands List button added above Commands accordion in Server Settings
- [x] Place 'Invite' button beside the server title on dashboard/server cards
- Acceptance criteria: the invite button appears horizontally adjacent to the server title (to the right), remains visible and usable on tablet and desktop layouts, is keyboard-focusable, and has an accessible aria-label (e.g. "Invite bot to SERVER_NAME").
- [x] Show the server name in a rounded "bubble" and render it bold
- Acceptance criteria: server name is inside a rounded container (padding + border-radius), the text is bold, background provides sufficient contrast, and the bubble adapts to long names (truncation or wrapping) to avoid layout breakage.
- [x] Update Dashboard component to use bubble title + invite-button layout
- Acceptance criteria: visual matches design spec above; small-screen fallback stacks invite button under the title or shows a compact icon; no regressions to other card elements.
- [x] Add pre-invite check to dashboard invite button
- Acceptance criteria: Clicking invite button checks if bot is already in the server. If so, show a dismissible message (e.g., a snackbar or modal) saying "Bot is already in this server." If not, proceed with the invite.
- [x] Center invite button on Server Settings page
- Acceptance criteria: On the server-specific settings page, the "Invite Bot" button (or the "Bot is already in this server" text) should be vertically and horizontally aligned with the main server name title for a cleaner look.
- [x] Fix incorrect invite link on dashboard cards
- Acceptance criteria: The invite link generated for the dashboard cards should correctly redirect to the Discord OAuth2 authorization page with the proper client ID, permissions, and scope, matching the functionality of the invite button in the server settings panel.
- [x] ~~by default hide or gray out bot options in each server dashboard panel if the bot is not in the server. Only allow to edit the features if the bot is in the discord server.~~ (User changed their mind)
- [x] Allow dashboard cards to be clickable even if the bot is not in the server. Inside the server settings, all commands and categories should be greyed out and not touchable. Only allow the invite button to be clicked within.
- [x] Add a button to the server cards that allows the user to make the bot leave the server. The button should only be visible if the bot is in the server.
- [x] In the server settings, replace the text "The bot is already in this server" with a "Leave" button.
- [x] Add a confirmation dialog to the "Leave" buttons on both the dashboard cards and the server settings page.
- [x] Redesign the login page to be more bubbly, centered, and eye-catching, with bigger text and responsive design.
- [x] Make server settings panels collapsible for a cleaner mobile UI.
- [x] Login page
- [x] Dashboard page
- [x] Backend connectivity for APIs
- [x] UI components using MUI
- [x] Server-specific settings pages
- [x] Persist user data (localStorage + backend)
- [x] Logout
- [x] Responsive UI and improved styling
- [x] Theme switching (light, dark, Discord grey)
- [x] User settings menu
- [x] Commands section in Server Settings (per-command toggles)
- [x] Commands list sorted alphabetically in Server Settings
- [x] Help → renamed to 'Commands List' and moved to dedicated page
- [x] NavBar redesigned (single-hamburger, title 'ECS - EHDCHADSWORTH')
- [x] Invite button on dashboard and server cards (with pre-invite check)
- [x] Invite button on dashboard (single action below the server title) and server cards (with pre-invite check)
- [x] Invite management UI in Server Settings (create/list/delete invites)
## Invite Management (implemented)
- [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites`
- [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites` (supports optional `INVITE_API_KEY` or short-lived invite tokens via `/api/servers/:guildId/invite-token`)
- [x] Frontend: invite creation form (channel optional, expiry, max uses, temporary), labels added, mobile-friendly layout
- [x] Frontend: invite list with Copy and Delete actions and metadata
- [x] Frontend: invite list with Copy and Delete actions and metadata (copy/delete fixed UI handlers)
- [x] Discord bot commands: `/create-invite`, `/list-invites` and interaction handlers for copy/delete
- [x] Invites persisted in encrypted `db.json`
## Security
- [x] Invite DELETE route now requires a short-lived invite token issued by `/api/servers/:guildId/invite-token` and sent in the `x-invite-token` header. The old `INVITE_API_KEY` header is no longer used.
- [x] Invite delete UI now shows a confirmation dialog before deleting an invite.
## Theme & UX
- [x] Theme changes persist immediately (localStorage) and are applied across navigation
- [x] Theme preference priority: local selection > server preference > default (default only used on first visit)
## Discord Bot
- [x] Create a basic Discord bot
- [x] Add a feature with both slash and web commands
- [x] Implement bot invitation functionality
- [x] Reorganize bot file structure
- [x] Implement command handler
- [x] Implement event handler
- [x] Set bot status on ready event
- [x] Automatically register slash commands on server join.
- [x] On startup, automatically register all slash commands and remove any obsolete commands.
- [x] Add a mechanism to enable or disable commands from being registered and displayed.
- [x] In `ready.js`, set the bot's activity to change every 3 seconds with the following streaming activities: "Watch EhChad Live!", "Follow EhChad!", "/help", and "EhChadServices", all pointing to `https://twitch.tv/ehchad`.
- [x] Bot with event & command handlers
- [x] Slash command registration and runtime enable/disable mechanism
- [x] `/help` and `/manage-commands` (manage persists toggles to backend)
- [x] Invite-related slash commands implemented (`/create-invite`, `/list-invites`)
## Features
- [x] **Welcome/Leave Messages**
- [x] Add "Welcome/Leave" section to server settings.
- [x] **Welcome Messages:**
- [x] Add toggle to enable/disable welcome messages.
- [x] Add dropdown to select a channel for welcome messages.
- [x] Add 3 default welcome message options.
- [x] Add a custom welcome message option with a text input and apply button.
- [x] **Leave Messages:**
- [x] Add toggle to enable/disable leave messages.
- [x] Add dropdown to select a channel for leave messages.
- [x] Add 3 default leave message options.
- [x] Add a custom leave message option with a text input and apply button.
- [x] **Bot Integration:**
- [x] Connect frontend settings to the backend.
- [x] Implement bot logic to send welcome/leave messages based on server settings.
- [x] Fix: Leave messages now fetch channel reliably, ensure bot has permissions (ViewChannel/SendMessages), and use mention-friendly user formatting. Added debug logging.
- [x] Fix: Removed verbose console logging of incoming settings and messages in backend and bot (no sensitive or noisy payloads logged).
- [x] **Slash Command Integration:**
- [x] ~~Create a `/config-welcome` slash command.~~
- [x] ~~Add a subcommand to `set-channel` for welcome messages.~~
- [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~
- [x] ~~Add a subcommand to `disable` welcome messages.~~
- [x] ~~Create a `/config-leave` slash command.~~
- [x] ~~Add a subcommand to `set-channel` for leave messages.~~
- [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~
- [x] ~~Add a subcommand to `disable` leave messages.~~
- [x] ~~Create a `/view-config` slash command to display the current welcome and leave channels.~~
- [x] Refactor `/config-welcome` to `/setup-welcome` with interactive setup for channel and message.
- [x] Refactor `/config-leave` to `/setup-leave` with interactive setup for channel and message.
- [x] Rename `/view-config` to `/view-welcome-leave`.
- [x] Ensure settings updated via slash commands are reflected on the frontend.
- [x] Ensure settings updated via the frontend are reflected in the bot's behavior.
- [x] **New:** Interactive setup should prompt for channel, then for message (default or custom, matching frontend options).
- [x] Persist the selected message option (default or custom) for welcome and leave messages.
- [x] Added `/view-autorole` slash command to report autorole status and selected role.
- [x] Added `/manage-commands` admin slash command to list and toggle commands per-server (persists toggles to backend DB).
- [x] Refactor: `/manage-commands` now renders a single message with toggle buttons reflecting each command's current state and updates in-place.
- [x] Ensure `/manage-commands` lists all loaded commands (including non-slash/simple commands like `ping`) and will include future commands automatically.
- [x] Ensure the `/help` command is locked (protected) and cannot be disabled via `/manage-commands`.
- [x] Add a Help tab in the frontend Server Settings that lists all bot commands and their descriptions per-server.
- [x] Move Help to a dedicated page within the server dashboard and add a top NavBar (collapsible) with Dashboard, Discord!, Contact, and Help (when on a server) buttons. Ensure Help page has a back arrow to return to the Commands section.
- [x] Move Help to a dedicated page within the server dashboard and add a top NavBar (collapsible) with Dashboard, Discord!, and Commands List (when on a server) buttons. Ensure Help page has a back arrow to return to the Commands section.
- [x] Remove Contact page from the frontend and App routes (no longer needed).
- [x] Redesign NavBar for cleaner layout and prettier appearance.
- [x] Redesign NavBar for cleaner layout and prettier appearance. (Title updated to 'ECS - EHDCHADSWORTH')
- [x] Added `/help` slash command that lists commands and their descriptions and shows per-server enable/disable status.
- [x] **Autorole**
- [x] Add "Autorole" section to server settings.
- [x] **Backend:**
- [x] Create API endpoint to get/set autorole settings.
- [x] Create API endpoint to fetch server roles.
- [x] **Bot Integration:**
- [x] Create a `/setup-autorole` slash command to enable/disable and select a role.
- [x] Update `guildMemberAdd` event to assign the selected role on join.
- [x] **Frontend:**
- [x] Add toggle to enable/disable autorole.
- [x] Add dropdown to select a role for autorole.
- [x] Ensure settings updated via slash commands are reflected on the frontend.
- [x] Ensure settings updated via the frontend are reflected in the bot's behavior.
- [x] Fix: Autorole dropdown excludes @everyone and shows only roles the bot can manage. Assignment is validated at join.
- [x] Welcome/Leave messages (frontend + backend + bot integration)
- [x] Autorole (frontend + backend + bot integration)
## Pending / Suggested improvements
- [ ] Consider stronger auth for invite delete (e.g., require user auth or signed requests); currently an optional API key is supported via `INVITE_API_KEY`.
## Deployment notes (VPS / Tailscale)
Quick guidance to run the backend, frontend and bot on a VPS or make the API accessible over a Tailscale network:
- Environment variables you'll want to set (backend `.env`):
- `PORT` (e.g. 3002)
- `HOST` the bind address (e.g. `100.x.y.z` Tailscale IP for the VPS or `0.0.0.0` to bind all interfaces)
- `CORS_ORIGIN` origin allowed for cross-origin requests (e.g. `http://100.x.y.z:3000` or `*` during testing)
- `INVITE_API_KEY` (optional) secret to protect invite DELETE requests
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `ENCRYPTION_KEY` (existing bot secrets)
- Frontend config:
- Build the frontend (`npm run build`) and serve it with a static server (nginx) or host separately.
- Configure `REACT_APP_API_BASE` before building to point to your backend (e.g. `http://100.x.y.z:3002`).
Current local dev hosts used in this workspace (update these values in your `.env` files if you change ports):
- Frontend dev server: `http://100.70.209.56:3001` (set in `frontend/.env` as HOST=100.70.209.56 and PORT=3001)
- Backend server: `http://100.70.209.56:3002` (set in `backend/.env` as HOST=100.70.209.56 and PORT=3002)
Discord Developer Portal settings (must match your BACKEND_BASE and FRONTEND_BASE):
- OAuth2 Redirect URI to add: `http://100.70.209.56:3002/auth/discord/callback`
- OAuth2 Allowed Origin (CORS / Application Origin): `http://100.70.209.56:3001`
- Tailscale notes:
- Ensure the VPS has Tailscale installed and is in your Tailnet.
- Use the VPS Tailscale IP (100.x.y.z) as `HOST` or to reach the API from other machines on the tailnet.
- For convenience and security, only expose ports on the Tailscale interface and avoid opening them to the public internet.
- Example systemd service (backend) on VPS (/etc/systemd/system/ecs-backend.service):
- Set `Environment=` entries for your `.env` or point to a `.env` file in the service unit, and run `node index.js` in the `backend` folder.
Where to change host/port and base URLs
- Backend: edit `backend/.env` (or set the environment variables) — key entries:
- `HOST` — bind address (e.g., your Tailscale IP like `100.x.y.z` or `0.0.0.0`)
- `PORT` — port the backend listens on (e.g., `3002`)
- `BACKEND_BASE` — optional fully-qualified base URL (defaults to `http://HOST:PORT`)
- `FRONTEND_BASE` — used for OAuth redirect to frontend (e.g., `http://100.x.y.z:3000`)
- Frontend: set `REACT_APP_API_BASE` in `frontend/.env` before running `npm run build` (or export at runtime for development). Example:
- `REACT_APP_API_BASE=http://100.x.y.z:3002`
I've added `backend/.env.example` and `frontend/.env.example` as templates — copy them to `.env` and fill in values for your environment.
- Example nginx (reverse proxy) snippet if you want to expose via a domain (optional):
- Proxy `https://yourdomain.example` to the backend (or to the frontend build directory) with TLS termination at nginx.
If you'd like, I can:
- Add a small `deploy.md` with exact steps and example `systemd` unit and `nginx` config.
- Update frontend to read a runtime-config file (useful when you don't want to rebuild to change API base).
- [ ] Add unit/integration tests for invite endpoints and ThemeContext behavior
- [ ] Accessibility improvements (ARIA attributes, focus styles) across the settings forms
- [ ] Small UI polish (spacing/visuals) for invite list items and commands list
If you'd like, I can immediately:
- Pin protected commands (e.g., `help`, `manage-commands`) to the top of the Commands list while keeping the rest alphabetical.
- Add ARIA labels and keyboard navigation tweaks for the invite dropdowns.
- Add tests for ThemeContext.
UI tweaks applied:
- Server cards on the Dashboard have been updated to enforce exact identical size per breakpoint (fixed heights), images are cropped uniformly (object-fit: cover) so icons are the same visible area across cards, and long server names are clamped to two lines to prevent layout differences.
- Mobile spacing, paddings, and typography adjusted for better legibility on small screens.
- Mobile fix: Title clamping and CardContent overflow were tightened so cards no longer expand on mobile; images use a background-image approach and white background to keep visible areas identical.
- Dashboard action buttons moved: Invite/Leave action now appears below the server title with a left label 'Invite:' or 'Leave:' and the action button to the right.

View File

@@ -0,0 +1,54 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { readDb, writeDb } = require('../../backend/db');
module.exports = {
name: 'create-invite',
description: 'Create a Discord invite with options (channel optional, maxAge seconds, maxUses, temporary).',
enabled: true,
builder: new SlashCommandBuilder()
.setName('create-invite')
.setDescription('Create a Discord invite with options (channel optional, maxAge seconds, maxUses, temporary).')
.addChannelOption(opt => opt.setName('channel').setDescription('Channel to create invite in').setRequired(false))
.addIntegerOption(opt => opt.setName('maxage').setDescription('Duration in seconds (0 means never expire)').setRequired(false))
.addIntegerOption(opt => opt.setName('maxuses').setDescription('Number of uses allowed (0 means unlimited)').setRequired(false))
.addBooleanOption(opt => opt.setName('temporary').setDescription('Temporary membership?').setRequired(false))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction) {
try {
const channel = interaction.options.getChannel('channel');
const maxAge = interaction.options.getInteger('maxage') || 0;
const maxUses = interaction.options.getInteger('maxuses') || 0;
const temporary = interaction.options.getBoolean('temporary') || false;
const targetChannel = channel || interaction.guild.channels.cache.find(c => c.type === 0);
if (!targetChannel) {
await interaction.reply({ content: 'No valid channel found to create an invite.', ephemeral: true });
return;
}
const invite = await targetChannel.createInvite({ maxAge, maxUses, temporary, unique: true });
const db = readDb();
if (!db[interaction.guildId]) db[interaction.guildId] = {};
if (!db[interaction.guildId].invites) db[interaction.guildId].invites = [];
const item = {
code: invite.code,
url: invite.url,
channelId: targetChannel.id,
createdAt: new Date().toISOString(),
maxUses: invite.maxUses || maxUses || 0,
maxAge: invite.maxAge || maxAge || 0,
temporary: !!invite.temporary,
};
db[interaction.guildId].invites.push(item);
writeDb(db);
await interaction.reply({ content: `Invite created: ${invite.url}`, ephemeral: true });
} catch (error) {
console.error('Error in create-invite:', error);
await interaction.reply({ content: 'Failed to create invite.', ephemeral: true });
}
},
};

View File

@@ -0,0 +1,42 @@
const { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const { readDb } = require('../../backend/db');
module.exports = {
name: 'list-invites',
description: 'List invites created by the bot for this guild',
enabled: true,
builder: new SlashCommandBuilder()
.setName('list-invites')
.setDescription('List invites created by the bot for this guild')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction) {
try {
const db = readDb();
const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
if (!invites.length) {
await interaction.reply({ content: 'No invites created by the bot in this server.', ephemeral: true });
return;
}
// Build a message with invite details and action buttons
for (const inv of invites) {
const created = inv.createdAt || 'Unknown';
const uses = inv.uses || inv.maxUses || 0;
const temporary = inv.temporary ? 'Yes' : 'No';
const content = `Invite: ${inv.url}\nCreated: ${created}\nUses: ${uses}\nMax Uses: ${inv.maxUses || 0}\nMax Age (s): ${inv.maxAge || 0}\nTemporary: ${temporary}`;
const row = new ActionRowBuilder()
.addComponents(
new ButtonBuilder().setLabel('Copy Invite').setStyle(ButtonStyle.Secondary).setCustomId(`copy_inv_${inv.code}`),
new ButtonBuilder().setLabel('Delete Invite').setStyle(ButtonStyle.Danger).setCustomId(`delete_inv_${inv.code}`),
);
await interaction.reply({ content, components: [row], ephemeral: true });
}
} catch (error) {
console.error('Error in list-invites:', error);
await interaction.reply({ content: 'Failed to list invites.', ephemeral: true });
}
},
};

View File

@@ -15,6 +15,42 @@ commandHandler(client);
eventHandler(client);
client.on('interactionCreate', async interaction => {
// Handle button/component interactions for invites
if (interaction.isButton && interaction.isButton()) {
const id = interaction.customId || '';
if (id.startsWith('copy_inv_')) {
const code = id.replace('copy_inv_', '');
const db = readDb();
const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
const inv = invites.find(i => i.code === code);
if (inv) {
await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
} else {
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
}
} else if (id.startsWith('delete_inv_')) {
const code = id.replace('delete_inv_', '');
// permission check: admin only
const member = interaction.member;
if (!member.permissions.has('Administrator')) {
await interaction.reply({ content: 'You must be an administrator to delete invites.', ephemeral: true });
return;
}
try {
// call backend delete endpoint
const fetch = require('node-fetch');
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
const url = `${backendBase}/api/servers/${interaction.guildId}/invites/${code}`;
await fetch(url, { method: 'DELETE' });
await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
} catch (e) {
console.error('Error deleting invite via API:', e);
await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true });
}
}
return;
}
if (!interaction.isCommand()) return;
const command = client.commands.get(interaction.commandName);

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, CardMedia, IconButton, Snackbar, Alert } from '@mui/material';
import React, { useState, useEffect, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert } from '@mui/material';
import { UserContext } from '../contexts/UserContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
@@ -9,82 +9,79 @@ import axios from 'axios';
import ConfirmDialog from './ConfirmDialog';
const Dashboard = () => {
const { user, setUser } = useContext(UserContext);
const navigate = useNavigate();
const location = useLocation();
const { user } = useContext(UserContext);
const [guilds, setGuilds] = useState([]);
const [botStatus, setBotStatus] = useState({});
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedGuild, setSelectedGuild] = useState(null);
const navigate = useNavigate();
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const userParam = urlParams.get('user');
const guildsParam = urlParams.get('guilds');
if (userParam && guildsParam) {
const parsedUser = JSON.parse(decodeURIComponent(userParam));
const parsedGuilds = JSON.parse(decodeURIComponent(guildsParam));
localStorage.setItem('user', JSON.stringify(parsedUser));
localStorage.setItem('guilds', JSON.stringify(parsedGuilds));
setUser(parsedUser);
setGuilds(parsedGuilds);
// Clean the URL
window.history.replaceState({}, document.title, "/dashboard");
} else {
const storedUser = localStorage.getItem('user');
const storedGuilds = localStorage.getItem('guilds');
if (storedUser && storedGuilds) {
setUser(JSON.parse(storedUser));
setGuilds(JSON.parse(storedGuilds));
}
}
}, [setUser]);
useEffect(() => {
const fetchBotStatus = async () => {
const statusPromises = guilds.map(async (guild) => {
const params = new URLSearchParams(location.search);
const guildsParam = params.get('guilds');
if (guildsParam) {
try {
const response = await axios.get(`http://localhost:3002/api/servers/${guild.id}/bot-status`);
return { guildId: guild.id, isBotInServer: response.data.isBotInServer };
} catch (error) {
console.error(`Error fetching bot status for guild ${guild.id}:`, error);
return { guildId: guild.id, isBotInServer: false };
const parsed = JSON.parse(decodeURIComponent(guildsParam));
setGuilds(parsed || []);
localStorage.setItem('guilds', JSON.stringify(parsed || []));
} catch (err) {
// ignore
}
});
const results = await Promise.all(statusPromises);
const newBotStatus = results.reduce((acc, curr) => {
acc[curr.guildId] = curr.isBotInServer;
return acc;
}, {});
setBotStatus(newBotStatus);
} else {
const stored = localStorage.getItem('guilds');
if (stored) {
try {
setGuilds(JSON.parse(stored));
} catch (err) {
setGuilds([]);
}
}
}
}, [location.search]);
useEffect(() => {
if (!guilds || guilds.length === 0) return;
const fetchStatuses = async () => {
const statuses = {};
await Promise.all(guilds.map(async (g) => {
try {
const resp = await axios.get(`${API_BASE}/api/servers/${g.id}/bot-status`);
statuses[g.id] = resp.data.isBotInServer;
} catch (err) {
statuses[g.id] = false;
}
}));
setBotStatus(statuses);
};
if (guilds.length > 0) {
fetchBotStatus();
}
}, [guilds]);
useLayoutEffect(() => {
const scrollPosition = sessionStorage.getItem('scrollPosition');
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
sessionStorage.removeItem('scrollPosition');
}
}, []);
fetchStatuses();
}, [guilds, API_BASE]);
const handleCardClick = (guild) => {
sessionStorage.setItem('scrollPosition', window.scrollY);
navigate(`/server/${guild.id}`, { state: { guild } });
};
const handleInviteBot = async (e, guild) => {
const handleInviteBot = (e, guild) => {
e.stopPropagation();
const clientId = '1423377662055026840'; // Hardcoded client ID from user request
const permissions = 8; // Administrator
const inviteUrl = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guild.id}&disable_guild_select=true`;
window.open(inviteUrl, '_blank', 'noopener,noreferrer');
axios.get(`${API_BASE}/api/client-id`).then(resp => {
const clientId = resp.data.clientId;
if (!clientId) {
setSnackbarMessage('No client ID available');
setSnackbarOpen(true);
return;
}
const permissions = 8; // admin
const url = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guild.id}&disable_guild_select=true`;
window.open(url, '_blank');
}).catch(() => {
setSnackbarMessage('Failed to fetch client id');
setSnackbarOpen(true);
});
};
const handleLeaveBot = (e, guild) => {
@@ -96,114 +93,119 @@ const Dashboard = () => {
const handleConfirmLeave = async () => {
if (!selectedGuild) return;
try {
await axios.post(`http://localhost:3002/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prevStatus => ({ ...prevStatus, [selectedGuild.id]: false }));
setSnackbarMessage('Bot has left the server.');
await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
setSnackbarMessage('Bot left the server');
setSnackbarOpen(true);
} catch (error) {
console.error('Error leaving server:', error);
setSnackbarMessage('Failed to make the bot leave the server.');
} catch (err) {
setSnackbarMessage('Failed to leave server');
setSnackbarOpen(true);
}
setDialogOpen(false);
setSelectedGuild(null);
};
const handleSnackbarClose = (event, reason) => {
if (reason === 'clickaway') {
return;
}
setSnackbarOpen(false);
};
const handleSnackbarClose = () => setSnackbarOpen(false);
return (
<div style={{ padding: '20px' }}>
{/* UserSettings moved to NavBar */}
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
{user && (
<Typography variant="h5" gutterBottom>
Welcome, {user.username}
</Typography>
)}
<Typography variant="h6" gutterBottom>
Your Admin Servers:
</Typography>
<Grid container spacing={3}>
<div style={{ padding: 20 }}>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
<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}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Card
onClick={() => handleCardClick(guild)}
sx={{
cursor: 'pointer',
borderRadius: '20px',
boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)',
transition: 'transform 0.3s',
height: '250px',
borderRadius: 2,
boxShadow: '0 6px 12px rgba(0,0,0,0.10)',
transition: 'transform 0.18s ease-in-out, box-shadow 0.18s',
height: { xs: 320, sm: 260 },
minHeight: { xs: 320, sm: 260 },
maxHeight: { xs: 320, sm: 260 },
width: { xs: '100%', sm: 260 },
minWidth: { xs: '100%', sm: 260 },
maxWidth: { xs: '100%', sm: 260 },
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
transform: 'scale(1.05)'
}
overflow: 'hidden',
boxSizing: 'border-box'
}}
>
<CardMedia
component="img"
sx={{ height: '60%', objectFit: 'cover' }}
image={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
alt={guild.name}
/>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexDirection: { xs: 'column', sm: 'row' } }}>
{/* slightly larger image area for better visibility */}
<Box sx={{ height: { xs: 196, sm: 168 }, width: '100%', bgcolor: '#fff', backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', backgroundImage: `url(${guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'})`, boxSizing: 'border-box' }} />
<Box sx={{ height: { xs: 72, sm: 56 }, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 2, boxSizing: 'border-box' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: '1.1rem', maxHeight: { xs: '2.2rem', sm: '2.2rem' } }}>{guild.name}</Typography>
</Box>
<Box sx={{ height: { xs: 64, sm: 48 }, display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, px: 2, boxSizing: 'border-box' }}>
{botStatus[guild.id] ? (
<>
<Typography variant="body2" sx={{ fontWeight: 600, mr: 1 }}>Leave:</Typography>
<IconButton aria-label={`Make bot leave ${guild.name}`} size="small" onClick={(e) => handleLeaveBot(e, guild)} sx={{ flexShrink: 0 }}>
<RemoveCircleOutlineIcon />
</IconButton>
</>
) : (
<>
<Typography variant="body2" sx={{ fontWeight: 600, mr: 1 }}>Invite:</Typography>
<IconButton aria-label={`Invite bot to ${guild.name}`} size="small" onClick={(e) => handleInviteBot(e, guild)} sx={{ flexShrink: 0 }}>
<PersonAddIcon />
</IconButton>
</>
)}
</Box>
{/* CardContent reduced a bit to compensate for larger image */}
<CardContent sx={{ height: { xs: '124px', sm: '92px' }, boxSizing: 'border-box', py: { xs: 1, sm: 1.5 }, px: { xs: 1.25, sm: 2 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexDirection: { xs: 'column', sm: 'row' }, height: '100%', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: { xs: 'center', sm: 'flex-start' } }}>
<Box
title={guild.name}
sx={{
px: 2,
px: { xs: 1, sm: 2 },
py: 0.5,
borderRadius: '999px',
fontWeight: 'bold',
bgcolor: 'rgba(0,0,0,0.06)',
fontWeight: 700,
fontSize: { xs: '0.95rem', sm: '1rem' },
bgcolor: 'rgba(0,0,0,0.04)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: { xs: 'center', sm: 'left' }
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
whiteSpace: 'normal',
textAlign: { xs: 'center', sm: 'left' },
lineHeight: '1.2rem',
maxHeight: { xs: '2.4rem', sm: '2.4rem', md: '2.4rem' }
}}
>
{guild.name}
</Box>
</Box>
{botStatus[guild.id] ? (
<IconButton
aria-label={`Make bot leave ${guild.name}`}
size="small"
onClick={(e) => handleLeaveBot(e, guild)}
>
<RemoveCircleOutlineIcon />
</IconButton>
) : (
<IconButton
aria-label={`Invite bot to ${guild.name}`}
size="small"
onClick={(e) => handleInviteBot(e, guild)}
>
<PersonAddIcon />
</IconButton>
)}
{/* Button removed from this location to avoid duplication; action is the labeled button above the CardContent */}
</Box>
</CardContent>
</Card>
</Box>
</Grid>
))}
</Grid>
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
<ConfirmDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { Box, IconButton, Typography } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -7,11 +7,11 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
const HelpPage = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [commands, setCommands] = useState([]);
useEffect(() => {
axios.get(`http://localhost:3002/api/servers/${guildId}/commands`)
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(res => setCommands(res.data || []))
.catch(() => setCommands([]));
}, [guildId]);

View File

@@ -13,7 +13,8 @@ const Login = () => {
}, [navigate]);
const handleLogin = () => {
window.location.href = 'http://localhost:3002/auth/discord';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
window.location.href = `${API_BASE}/auth/discord`;
};
return (

View File

@@ -1,17 +1,19 @@
import React, { useState, useEffect } from 'react';
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 } from '@mui/material';
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Snackbar, Alert } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from './ConfirmDialog';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
const ServerSettings = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [settings, setSettings] = useState({ pingCommand: false });
// settings state removed (not used) to avoid lint warnings
const [isBotInServer, setIsBotInServer] = useState(false);
const [clientId, setClientId] = useState(null);
const [server, setServer] = useState(null);
@@ -23,6 +25,13 @@ const ServerSettings = () => {
roleId: '',
});
const [commandsList, setCommandsList] = useState([]);
const [invites, setInvites] = useState([]);
const [deleting, setDeleting] = useState({});
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
@@ -54,32 +63,31 @@ const ServerSettings = () => {
}
}
// Fetch settings
axios.get(`http://localhost:3002/api/servers/${guildId}/settings`)
.then(response => {
setSettings(response.data);
});
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
// Fetch settings (not used directly in this component)
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
// Check if bot is in server
axios.get(`http://localhost:3002/api/servers/${guildId}/bot-status`)
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`)
.then(response => {
setIsBotInServer(response.data.isBotInServer);
});
// Fetch client ID
axios.get('http://localhost:3002/api/client-id')
axios.get(`${API_BASE}/api/client-id`)
.then(response => {
setClientId(response.data.clientId);
});
// Fetch channels
axios.get(`http://localhost:3002/api/servers/${guildId}/channels`)
axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
.then(response => {
setChannels(response.data);
});
// Fetch welcome/leave settings
axios.get(`http://localhost:3002/api/servers/${guildId}/welcome-leave-settings`)
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`)
.then(response => {
if (response.data) {
setWelcomeLeaveSettings(response.data);
@@ -87,13 +95,13 @@ const ServerSettings = () => {
});
// Fetch roles
axios.get(`http://localhost:3002/api/servers/${guildId}/roles`)
axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
.then(response => {
setRoles(response.data);
});
// Fetch autorole settings
axios.get(`http://localhost:3002/api/servers/${guildId}/autorole-settings`)
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
.then(response => {
if (response.data) {
setAutoroleSettings(response.data);
@@ -101,12 +109,17 @@ const ServerSettings = () => {
});
// Fetch commands/help list
axios.get(`http://localhost:3002/api/servers/${guildId}/commands`)
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(response => {
setCommandsList(response.data || []);
})
.catch(() => setCommandsList([]));
// Fetch invites
axios.get(`${API_BASE}/api/servers/${guildId}/invites`)
.then(resp => setInvites(resp.data || []))
.catch(() => setInvites([]));
// Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) {
setCommandsExpanded(true);
@@ -115,7 +128,7 @@ const ServerSettings = () => {
}, [guildId, location.state]);
const handleAutoroleSettingUpdate = (newSettings) => {
axios.post(`http://localhost:3002/api/servers/${guildId}/autorole-settings`, newSettings)
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/autorole-settings`, newSettings)
.then(response => {
if (response.data.success) {
setAutoroleSettings(newSettings);
@@ -134,7 +147,7 @@ const ServerSettings = () => {
};
const handleSettingUpdate = (newSettings) => {
axios.post(`http://localhost:3002/api/servers/${guildId}/welcome-leave-settings`, newSettings)
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
.then(response => {
if (response.data.success) {
setWelcomeLeaveSettings(newSettings);
@@ -188,16 +201,6 @@ const ServerSettings = () => {
return 'custom';
}
const togglePingCommand = () => {
const newSettings = { ...settings, pingCommand: !settings.pingCommand };
axios.post(`http://localhost:3002/api/servers/${guildId}/settings`, newSettings)
.then(response => {
if (response.data.success) {
setSettings(newSettings);
}
});
};
const handleInviteBot = () => {
if (!clientId) return;
const permissions = 8; // Administrator
@@ -211,7 +214,7 @@ const ServerSettings = () => {
const handleConfirmLeave = async () => {
try {
await axios.post(`http://localhost:3002/api/servers/${guildId}/leave`);
await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/leave`);
setIsBotInServer(false);
} catch (error) {
console.error('Error leaving server:', error);
@@ -255,33 +258,149 @@ const ServerSettings = () => {
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}>
{commandsList.map(cmd => (
{/** Render protected commands first in a fixed order **/}
{(() => {
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'}));
return (
<>
{protectedCmds.map(cmd => (
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
<Typography variant="body2">{cmd.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel control={<Switch checked={true} disabled />} label="Locked" />
</Box>
</Box>
))}
{otherCmds.map(cmd => (
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
<Typography variant="body2">{cmd.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{['help', 'manage-commands'].includes(cmd.name) ? (
<FormControlLabel
control={<Switch checked={true} disabled />}
label="Locked"
/>
) : (
<FormControlLabel
control={<Switch checked={cmd.enabled} onChange={async (e) => {
const newVal = e.target.checked;
// optimistic update
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
try {
await axios.post(`http://localhost:3002/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
} catch (err) {
// revert on error
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
}
}} disabled={!isBotInServer} label={cmd.enabled ? 'Enabled' : 'Disabled'} />}
/>
)}
</Box>
</Box>
))}
</>
);
})()}
</Box>
</AccordionDetails>
</Accordion>
{/* Invite creation and list */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Invites</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite features require the bot to be in the server.</Typography>}
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' }, marginTop: 1 }}>
<Box sx={{ width: { xs: '100%', sm: '40%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Channel (optional)</Typography>
<FormControl fullWidth>
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
<MenuItem value="">(Any channel)</MenuItem>
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
</Select>
</FormControl>
</Box>
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Expiry</Typography>
<FormControl fullWidth>
<Select value={inviteForm.maxAge} onChange={(e) => setInviteForm(f => ({ ...f, maxAge: Number(e.target.value) }))}>
<MenuItem value={0}>Never expire</MenuItem>
<MenuItem value={3600}>1 hour</MenuItem>
<MenuItem value={86400}>1 day</MenuItem>
<MenuItem value={604800}>7 days</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Max Uses</Typography>
<FormControl fullWidth>
<Select value={inviteForm.maxUses} onChange={(e) => setInviteForm(f => ({ ...f, maxUses: Number(e.target.value) }))}>
<MenuItem value={0}>Unlimited</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={5}>5</MenuItem>
<MenuItem value={10}>10</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: '20%' } }}>
<Box sx={{ width: '100%' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Temporary</Typography>
<FormControlLabel control={<Switch checked={inviteForm.temporary} onChange={(e) => setInviteForm(f => ({ ...f, temporary: e.target.checked }))} />} label="" />
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<Button variant="contained" onClick={async () => {
try {
const resp = await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/invites`, inviteForm);
if (resp.data && resp.data.success) {
setInvites(prev => [...prev, resp.data.invite]);
}
} catch (err) {
console.error('Error creating invite:', err);
}
}} disabled={!isBotInServer}>Create Invite</Button>
</Box>
</Box>
<Box sx={{ marginTop: 2 }}>
{invites.length === 0 && <Typography>No invites created by the bot.</Typography>}
{invites.map(inv => (
<Box key={inv.code} sx={{ border: '1px solid #eee', borderRadius: 1, padding: 1, marginTop: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography>{inv.url}</Typography>
<Typography variant="caption">Created: {new Date(inv.createdAt).toLocaleString()} Uses: {inv.uses || 0} MaxUses: {inv.maxUses || 0} MaxAge(s): {inv.maxAge || 0} Temporary: {inv.temporary ? 'Yes' : 'No'}</Typography>
</Box>
<Box>
<Button startIcon={<ContentCopyIcon />} onClick={async () => {
// robust clipboard copy with fallback
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(inv.url);
} else {
// fallback for older browsers
const input = document.createElement('input');
input.value = inv.url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
setSnackbarMessage('Copied invite URL to clipboard');
setSnackbarOpen(true);
} catch (err) {
console.error('Clipboard copy failed:', err);
setSnackbarMessage('Failed to copy — please copy manually');
setSnackbarOpen(true);
}
}}>Copy</Button>
<Button startIcon={<DeleteIcon />} color="error" disabled={!!deleting[inv.code]} onClick={() => {
// open confirm dialog for this invite
setPendingDeleteInvite(inv);
setConfirmOpen(true);
}}>Delete</Button>
</Box>
</Box>
))}
@@ -420,6 +539,61 @@ const ServerSettings = () => {
title="Confirm Leave"
message={`Are you sure you want the bot to leave ${server?.name}?`}
/>
{/* Confirm dialog for invite deletion */}
<ConfirmDialog
open={confirmOpen}
onClose={() => { setConfirmOpen(false); setPendingDeleteInvite(null); }}
onConfirm={async () => {
// perform deletion for pendingDeleteInvite
if (!pendingDeleteInvite) {
setConfirmOpen(false);
return;
}
const code = pendingDeleteInvite.code;
setConfirmOpen(false);
setDeleting(prev => ({ ...prev, [code]: true }));
try {
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
// fetch token (one retry)
let token = null;
try {
const tokenResp = await axios.get(`${API_BASE}/api/servers/${guildId}/invite-token`);
token = tokenResp && tokenResp.data && tokenResp.data.token;
} catch (tErr) {
try {
const tokenResp2 = await axios.get(`${API_BASE}/api/servers/${guildId}/invite-token`);
token = tokenResp2 && tokenResp2.data && tokenResp2.data.token;
} catch (tErr2) {
throw new Error('Failed to obtain delete token from server');
}
}
if (!token) throw new Error('No delete token received from server');
await axios.delete(`${API_BASE}/api/servers/${guildId}/invites/${code}`, { headers: { 'x-invite-token': token } });
setInvites(prev => prev.filter(i => i.code !== code));
setSnackbarMessage('Invite deleted');
setSnackbarOpen(true);
} catch (err) {
console.error('Error deleting invite:', err);
const msg = (err && err.message) || (err && err.response && err.response.data && err.response.data.message) || 'Failed to delete invite';
setSnackbarMessage(msg);
setSnackbarOpen(true);
} finally {
setDeleting(prev => {
const copy = { ...prev };
delete copy[pendingDeleteInvite?.code];
return copy;
});
setPendingDeleteInvite(null);
}
}}
title="Delete Invite"
message={`Are you sure you want to delete invite ${pendingDeleteInvite ? pendingDeleteInvite.url : ''}?`}
/>
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={() => setSnackbarOpen(false)}>
<Alert onClose={() => setSnackbarOpen(false)} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
</div>
);
};

View File

@@ -11,16 +11,25 @@ export const ThemeProvider = ({ children }) => {
const [themeName, setThemeName] = useState(localStorage.getItem('themeName') || 'discord');
useEffect(() => {
if (user && user.theme) {
setThemeName(user.theme);
} else {
// Prefer an explicit user selection (stored in localStorage) over defaults or server values.
// Behavior:
// - If localStorage has a themeName, use that (user's explicit choice always wins).
// - Else if the authenticated user has a server-side preference, adopt that and persist it locally.
// - Else (first visit, no local choice and no server preference) use default 'discord'.
const storedTheme = localStorage.getItem('themeName');
if (storedTheme) {
setThemeName(storedTheme);
} else {
return;
}
if (user && user.theme) {
setThemeName(user.theme);
localStorage.setItem('themeName', user.theme);
return;
}
// First-time visitor: fall back to default
setThemeName('discord');
}
}
}, [user]);
const theme = useMemo(() => {
@@ -36,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
const changeTheme = (name) => {
if (user) {
axios.post('http://localhost:3002/api/user/theme', { userId: user.id, theme: name });
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/user/theme`, { userId: user.id, theme: name });
}
localStorage.setItem('themeName', name);
setThemeName(name);