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 app = express();
const port = process.env.PORT || 3001; 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()); app.use(express.json());
const axios = require('axios'); 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) => { 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); 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) => { app.get('/auth/discord/callback', async (req, res) => {
const code = req.query.code; const code = req.query.code;
if (!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('client_secret', process.env.DISCORD_CLIENT_SECRET);
params.append('grant_type', 'authorization_code'); params.append('grant_type', 'authorization_code');
params.append('code', 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, { const response = await axios.post('https://discord.com/api/oauth2/token', params, {
headers: { headers: {
@@ -55,7 +106,7 @@ app.get('/auth/discord/callback', async (req, res) => {
const db = readDb(); const db = readDb();
user.theme = db.users && db.users[user.id] ? db.users[user.id].theme : 'light'; user.theme = db.users && db.users[user.id] ? db.users[user.id].theme : 'light';
const guilds = adminGuilds; 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) { } catch (error) {
console.error('Error during Discord OAuth2 callback:', error); console.error('Error during Discord OAuth2 callback:', error);
res.status(500).send('Internal Server 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'); const bot = require('../discord-bot');
bot.login(); bot.login();
app.listen(port, () => { app.listen(port, host, () => {
console.log(`Server is running on port ${port}`); 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 ## Backend
- [x] Create backend directory - [x] Basic Express server and project setup
- [x] Initialize Node.js project - [x] Discord OAuth2 endpoints
- [x] Install backend dependencies - [x] API endpoints for servers, channels, roles, leave, settings
- [x] Create a basic Express server - [x] Persist encrypted data to `db.json`
- [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`.
## Frontend ## Frontend
- [x] Create login page - [x] Login page
- [x] Create dashboard page - [x] Dashboard page
- [x] Connect frontend to backend - [x] Backend connectivity for APIs
- [x] Improve frontend UI with a component library - [x] UI components using MUI
- [x] Implement server-specific settings pages - [x] Server-specific settings pages
- [x] Persist user data on the frontend - [x] Persist user data (localStorage + backend)
- [x] Add logout functionality - [x] Logout
- [x] Add more styling and animations to the UI - [x] Responsive UI and improved styling
- [x] Remember scroll position - [x] Theme switching (light, dark, Discord grey)
- [x] Fix issue with fetching server names - [x] User settings menu
- [x] Implement theme switching (light, dark, Discord grey) - [x] Commands section in Server Settings (per-command toggles)
- [x] Create a user settings menu - [x] Commands list sorted alphabetically in Server Settings
- [x] Set Discord grey as the default theme - [x] Help → renamed to 'Commands List' and moved to dedicated page
- [x] Refine user settings menu UI - [x] NavBar redesigned (single-hamburger, title 'ECS - EHDCHADSWORTH')
- [x] Further refine user settings menu UI - [x] Invite button on dashboard and server cards (with pre-invite check)
- [x] Add server icons to dashboard cards - [x] Invite button on dashboard (single action below the server title) and server cards (with pre-invite check)
- [x] Ensure responsive and uniform card sizing - [x] Invite management UI in Server Settings (create/list/delete invites)
- [x] Fine-tune card sizing
- [x] Further fine-tune card UI ## Invite Management (implemented)
- [x] Add Commands section to server settings page - [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites`
- [x] Refine 'Back to Dashboard' button - [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] Remove 'Open Help Page' button from individual command controls (moved to dedicated Commands List page) - [x] Frontend: invite creation form (channel optional, expiry, max uses, temporary), labels added, mobile-friendly layout
- [x] Rename Help nav/button to 'Commands List' and update page title - [x] Frontend: invite list with Copy and Delete actions and metadata
- [x] Restructure Commands list UI to show per-command toggles and cleaner layout - [x] Frontend: invite list with Copy and Delete actions and metadata (copy/delete fixed UI handlers)
- [x] Ensure frontend persists selections and doesn't overwrite other settings - [x] Discord bot commands: `/create-invite`, `/list-invites` and interaction handlers for copy/delete
- [x] Improve NavBar layout and styling for clarity and compactness - [x] Invites persisted in encrypted `db.json`
- [x] Implement single-hamburger NavBar (hamburger toggles to X; buttons hidden when collapsed)
- [x] Commands List button added above Commands accordion in Server Settings ## Security
- [x] Place 'Invite' button beside the server title on dashboard/server cards - [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.
- 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] Invite delete UI now shows a confirmation dialog before deleting an invite.
- [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. ## Theme & UX
- [x] Update Dashboard component to use bubble title + invite-button layout - [x] Theme changes persist immediately (localStorage) and are applied across navigation
- 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] Theme preference priority: local selection > server preference > default (default only used on first visit)
- [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.
## Discord Bot ## Discord Bot
- [x] Create a basic Discord bot - [x] Bot with event & command handlers
- [x] Add a feature with both slash and web commands - [x] Slash command registration and runtime enable/disable mechanism
- [x] Implement bot invitation functionality - [x] `/help` and `/manage-commands` (manage persists toggles to backend)
- [x] Reorganize bot file structure - [x] Invite-related slash commands implemented (`/create-invite`, `/list-invites`)
- [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`.
## Features ## Features
- [x] **Welcome/Leave Messages** - [x] Welcome/Leave messages (frontend + backend + bot integration)
- [x] Add "Welcome/Leave" section to server settings. - [x] Autorole (frontend + backend + bot integration)
- [x] **Welcome Messages:**
- [x] Add toggle to enable/disable welcome messages. ## Pending / Suggested improvements
- [x] Add dropdown to select a channel for welcome messages. - [ ] 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`.
- [x] Add 3 default welcome message options.
- [x] Add a custom welcome message option with a text input and apply button. ## Deployment notes (VPS / Tailscale)
- [x] **Leave Messages:**
- [x] Add toggle to enable/disable leave messages. Quick guidance to run the backend, frontend and bot on a VPS or make the API accessible over a Tailscale network:
- [x] Add dropdown to select a channel for leave messages.
- [x] Add 3 default leave message options. - Environment variables you'll want to set (backend `.env`):
- [x] Add a custom leave message option with a text input and apply button. - `PORT` (e.g. 3002)
- [x] **Bot Integration:** - `HOST` the bind address (e.g. `100.x.y.z` Tailscale IP for the VPS or `0.0.0.0` to bind all interfaces)
- [x] Connect frontend settings to the backend. - `CORS_ORIGIN` origin allowed for cross-origin requests (e.g. `http://100.x.y.z:3000` or `*` during testing)
- [x] Implement bot logic to send welcome/leave messages based on server settings. - `INVITE_API_KEY` (optional) secret to protect invite DELETE requests
- [x] Fix: Leave messages now fetch channel reliably, ensure bot has permissions (ViewChannel/SendMessages), and use mention-friendly user formatting. Added debug logging. - `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `ENCRYPTION_KEY` (existing bot secrets)
- [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:** - Frontend config:
- [x] ~~Create a `/config-welcome` slash command.~~ - Build the frontend (`npm run build`) and serve it with a static server (nginx) or host separately.
- [x] ~~Add a subcommand to `set-channel` for welcome messages.~~ - Configure `REACT_APP_API_BASE` before building to point to your backend (e.g. `http://100.x.y.z:3002`).
- [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~
- [x] ~~Add a subcommand to `disable` welcome messages.~~ Current local dev hosts used in this workspace (update these values in your `.env` files if you change ports):
- [x] ~~Create a `/config-leave` slash command.~~
- [x] ~~Add a subcommand to `set-channel` for leave messages.~~ - Frontend dev server: `http://100.70.209.56:3001` (set in `frontend/.env` as HOST=100.70.209.56 and PORT=3001)
- [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~ - Backend server: `http://100.70.209.56:3002` (set in `backend/.env` as HOST=100.70.209.56 and PORT=3002)
- [x] ~~Add a subcommand to `disable` leave messages.~~
- [x] ~~Create a `/view-config` slash command to display the current welcome and leave channels.~~ Discord Developer Portal settings (must match your BACKEND_BASE and FRONTEND_BASE):
- [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. - OAuth2 Redirect URI to add: `http://100.70.209.56:3002/auth/discord/callback`
- [x] Rename `/view-config` to `/view-welcome-leave`. - OAuth2 Allowed Origin (CORS / Application Origin): `http://100.70.209.56:3001`
- [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. - Tailscale notes:
- [x] **New:** Interactive setup should prompt for channel, then for message (default or custom, matching frontend options). - Ensure the VPS has Tailscale installed and is in your Tailnet.
- [x] Persist the selected message option (default or custom) for welcome and leave messages. - Use the VPS Tailscale IP (100.x.y.z) as `HOST` or to reach the API from other machines on the tailnet.
- [x] Added `/view-autorole` slash command to report autorole status and selected role. - For convenience and security, only expose ports on the Tailscale interface and avoid opening them to the public internet.
- [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. - Example systemd service (backend) on VPS (/etc/systemd/system/ecs-backend.service):
- [x] Ensure `/manage-commands` lists all loaded commands (including non-slash/simple commands like `ping`) and will include future commands automatically. - 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.
- [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. Where to change host/port and base URLs
- [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. - Backend: edit `backend/.env` (or set the environment variables) — key entries:
- [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. - `HOST` — bind address (e.g., your Tailscale IP like `100.x.y.z` or `0.0.0.0`)
- [x] Remove Contact page from the frontend and App routes (no longer needed). - `PORT` — port the backend listens on (e.g., `3002`)
- [x] Redesign NavBar for cleaner layout and prettier appearance. - `BACKEND_BASE` — optional fully-qualified base URL (defaults to `http://HOST:PORT`)
- [x] Redesign NavBar for cleaner layout and prettier appearance. (Title updated to 'ECS - EHDCHADSWORTH') - `FRONTEND_BASE` — used for OAuth redirect to frontend (e.g., `http://100.x.y.z:3000`)
- [x] Added `/help` slash command that lists commands and their descriptions and shows per-server enable/disable status. - Frontend: set `REACT_APP_API_BASE` in `frontend/.env` before running `npm run build` (or export at runtime for development). Example:
- [x] **Autorole** - `REACT_APP_API_BASE=http://100.x.y.z:3002`
- [x] Add "Autorole" section to server settings.
- [x] **Backend:** I've added `backend/.env.example` and `frontend/.env.example` as templates — copy them to `.env` and fill in values for your environment.
- [x] Create API endpoint to get/set autorole settings.
- [x] Create API endpoint to fetch server roles. - Example nginx (reverse proxy) snippet if you want to expose via a domain (optional):
- [x] **Bot Integration:** - Proxy `https://yourdomain.example` to the backend (or to the frontend build directory) with TLS termination at nginx.
- [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. If you'd like, I can:
- [x] **Frontend:** - Add a small `deploy.md` with exact steps and example `systemd` unit and `nginx` config.
- [x] Add toggle to enable/disable autorole. - Update frontend to read a runtime-config file (useful when you don't want to rebuild to change API base).
- [x] Add dropdown to select a role for autorole. - [ ] Add unit/integration tests for invite endpoints and ThemeContext behavior
- [x] Ensure settings updated via slash commands are reflected on the frontend. - [ ] Accessibility improvements (ARIA attributes, focus styles) across the settings forms
- [x] Ensure settings updated via the frontend are reflected in the bot's behavior. - [ ] Small UI polish (spacing/visuals) for invite list items and commands list
- [x] Fix: Autorole dropdown excludes @everyone and shows only roles the bot can manage. Assignment is validated at join.
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); eventHandler(client);
client.on('interactionCreate', async interaction => { 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; if (!interaction.isCommand()) return;
const command = client.commands.get(interaction.commandName); const command = client.commands.get(interaction.commandName);

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, CardMedia, IconButton, Snackbar, Alert } from '@mui/material'; import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert } from '@mui/material';
import { UserContext } from '../contexts/UserContext'; import { UserContext } from '../contexts/UserContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
@@ -9,82 +9,79 @@ import axios from 'axios';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
const Dashboard = () => { const Dashboard = () => {
const { user, setUser } = useContext(UserContext); const navigate = useNavigate();
const location = useLocation();
const { user } = useContext(UserContext);
const [guilds, setGuilds] = useState([]); const [guilds, setGuilds] = useState([]);
const [botStatus, setBotStatus] = useState({}); const [botStatus, setBotStatus] = useState({});
const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState(''); const [snackbarMessage, setSnackbarMessage] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [selectedGuild, setSelectedGuild] = useState(null); const [selectedGuild, setSelectedGuild] = useState(null);
const navigate = useNavigate();
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const params = new URLSearchParams(location.search);
const userParam = urlParams.get('user'); const guildsParam = params.get('guilds');
const guildsParam = urlParams.get('guilds'); if (guildsParam) {
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) => {
try { try {
const response = await axios.get(`http://localhost:3002/api/servers/${guild.id}/bot-status`); const parsed = JSON.parse(decodeURIComponent(guildsParam));
return { guildId: guild.id, isBotInServer: response.data.isBotInServer }; setGuilds(parsed || []);
} catch (error) { localStorage.setItem('guilds', JSON.stringify(parsed || []));
console.error(`Error fetching bot status for guild ${guild.id}:`, error); } catch (err) {
return { guildId: guild.id, isBotInServer: false }; // ignore
} }
}); } else {
const results = await Promise.all(statusPromises); const stored = localStorage.getItem('guilds');
const newBotStatus = results.reduce((acc, curr) => { if (stored) {
acc[curr.guildId] = curr.isBotInServer; try {
return acc; setGuilds(JSON.parse(stored));
}, {}); } catch (err) {
setBotStatus(newBotStatus); 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);
}; };
fetchStatuses();
if (guilds.length > 0) { }, [guilds, API_BASE]);
fetchBotStatus();
}
}, [guilds]);
useLayoutEffect(() => {
const scrollPosition = sessionStorage.getItem('scrollPosition');
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
sessionStorage.removeItem('scrollPosition');
}
}, []);
const handleCardClick = (guild) => { const handleCardClick = (guild) => {
sessionStorage.setItem('scrollPosition', window.scrollY);
navigate(`/server/${guild.id}`, { state: { guild } }); navigate(`/server/${guild.id}`, { state: { guild } });
}; };
const handleInviteBot = async (e, guild) => { const handleInviteBot = (e, guild) => {
e.stopPropagation(); e.stopPropagation();
const clientId = '1423377662055026840'; // Hardcoded client ID from user request axios.get(`${API_BASE}/api/client-id`).then(resp => {
const permissions = 8; // Administrator const clientId = resp.data.clientId;
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`; if (!clientId) {
window.open(inviteUrl, '_blank', 'noopener,noreferrer'); 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) => { const handleLeaveBot = (e, guild) => {
@@ -96,114 +93,119 @@ const Dashboard = () => {
const handleConfirmLeave = async () => { const handleConfirmLeave = async () => {
if (!selectedGuild) return; if (!selectedGuild) return;
try { try {
await axios.post(`http://localhost:3002/api/servers/${selectedGuild.id}/leave`); await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prevStatus => ({ ...prevStatus, [selectedGuild.id]: false })); setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
setSnackbarMessage('Bot has left the server.'); setSnackbarMessage('Bot left the server');
setSnackbarOpen(true); setSnackbarOpen(true);
} catch (error) { } catch (err) {
console.error('Error leaving server:', error); setSnackbarMessage('Failed to leave server');
setSnackbarMessage('Failed to make the bot leave the server.');
setSnackbarOpen(true); setSnackbarOpen(true);
} }
setDialogOpen(false); setDialogOpen(false);
setSelectedGuild(null); setSelectedGuild(null);
}; };
const handleSnackbarClose = (event, reason) => { const handleSnackbarClose = () => setSnackbarOpen(false);
if (reason === 'clickaway') {
return;
}
setSnackbarOpen(false);
};
return ( return (
<div style={{ padding: '20px' }}> <div style={{ padding: 20 }}>
{/* UserSettings moved to NavBar */} <Typography variant="h4" gutterBottom>Dashboard</Typography>
<Typography variant="h4" gutterBottom> {user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
Dashboard <Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
</Typography>
{user && ( <Grid container spacing={3} justifyContent="center">
<Typography variant="h5" gutterBottom>
Welcome, {user.username}
</Typography>
)}
<Typography variant="h6" gutterBottom>
Your Admin Servers:
</Typography>
<Grid container spacing={3}>
{guilds.map(guild => ( {guilds.map(guild => (
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id}> <Grid item xs={12} sm={6} md={4} lg={3} key={guild.id}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Card <Card
onClick={() => handleCardClick(guild)} onClick={() => handleCardClick(guild)}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
borderRadius: '20px', borderRadius: 2,
boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)', boxShadow: '0 6px 12px rgba(0,0,0,0.10)',
transition: 'transform 0.3s', transition: 'transform 0.18s ease-in-out, box-shadow 0.18s',
height: '250px', 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', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', overflow: 'hidden',
'&:hover': { boxSizing: 'border-box'
transform: 'scale(1.05)'
}
}} }}
> >
<CardMedia {/* slightly larger image area for better visibility */}
component="img" <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' }} />
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'} <Box sx={{ height: { xs: 72, sm: 56 }, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 2, boxSizing: 'border-box' }}>
alt={guild.name} <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>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexDirection: { xs: 'column', sm: 'row' } }}> <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 sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: { xs: 'center', sm: 'flex-start' } }}>
<Box <Box
title={guild.name} title={guild.name}
sx={{ sx={{
px: 2, px: { xs: 1, sm: 2 },
py: 0.5, py: 0.5,
borderRadius: '999px', borderRadius: '999px',
fontWeight: 'bold', fontWeight: 700,
bgcolor: 'rgba(0,0,0,0.06)', fontSize: { xs: '0.95rem', sm: '1rem' },
bgcolor: 'rgba(0,0,0,0.04)',
maxWidth: '100%', maxWidth: '100%',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', display: '-webkit-box',
textAlign: { xs: 'center', sm: 'left' } 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} {guild.name}
</Box> </Box>
</Box> </Box>
{botStatus[guild.id] ? (
<IconButton {/* Button removed from this location to avoid duplication; action is the labeled button above the CardContent */}
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>
)}
</Box> </Box>
</CardContent> </CardContent>
</Card> </Card>
</Box>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}> <Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}> <Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
{snackbarMessage} {snackbarMessage}
</Alert> </Alert>
</Snackbar> </Snackbar>
<ConfirmDialog <ConfirmDialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; 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 axios from 'axios';
import { Box, IconButton, Typography } from '@mui/material'; import { Box, IconButton, Typography } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -7,11 +7,11 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
const HelpPage = () => { const HelpPage = () => {
const { guildId } = useParams(); const { guildId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [commands, setCommands] = useState([]); const [commands, setCommands] = useState([]);
useEffect(() => { 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 || [])) .then(res => setCommands(res.data || []))
.catch(() => setCommands([])); .catch(() => setCommands([]));
}, [guildId]); }, [guildId]);

View File

@@ -13,7 +13,8 @@ const Login = () => {
}, [navigate]); }, [navigate]);
const handleLogin = () => { 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 ( return (

View File

@@ -1,17 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios'; 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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar // UserSettings moved to NavBar
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
const ServerSettings = () => { const ServerSettings = () => {
const { guildId } = useParams(); const { guildId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [settings, setSettings] = useState({ pingCommand: false }); // settings state removed (not used) to avoid lint warnings
const [isBotInServer, setIsBotInServer] = useState(false); const [isBotInServer, setIsBotInServer] = useState(false);
const [clientId, setClientId] = useState(null); const [clientId, setClientId] = useState(null);
const [server, setServer] = useState(null); const [server, setServer] = useState(null);
@@ -23,6 +25,13 @@ const ServerSettings = () => {
roleId: '', roleId: '',
}); });
const [commandsList, setCommandsList] = useState([]); 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 [commandsExpanded, setCommandsExpanded] = useState(false);
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({ const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: { welcome: {
@@ -54,32 +63,31 @@ const ServerSettings = () => {
} }
} }
// Fetch settings const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
axios.get(`http://localhost:3002/api/servers/${guildId}/settings`)
.then(response => { // Fetch settings (not used directly in this component)
setSettings(response.data); axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
});
// Check if bot is in server // 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 => { .then(response => {
setIsBotInServer(response.data.isBotInServer); setIsBotInServer(response.data.isBotInServer);
}); });
// Fetch client ID // Fetch client ID
axios.get('http://localhost:3002/api/client-id') axios.get(`${API_BASE}/api/client-id`)
.then(response => { .then(response => {
setClientId(response.data.clientId); setClientId(response.data.clientId);
}); });
// Fetch channels // Fetch channels
axios.get(`http://localhost:3002/api/servers/${guildId}/channels`) axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
.then(response => { .then(response => {
setChannels(response.data); setChannels(response.data);
}); });
// Fetch welcome/leave settings // 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 => { .then(response => {
if (response.data) { if (response.data) {
setWelcomeLeaveSettings(response.data); setWelcomeLeaveSettings(response.data);
@@ -87,13 +95,13 @@ const ServerSettings = () => {
}); });
// Fetch roles // Fetch roles
axios.get(`http://localhost:3002/api/servers/${guildId}/roles`) axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
.then(response => { .then(response => {
setRoles(response.data); setRoles(response.data);
}); });
// Fetch autorole settings // Fetch autorole settings
axios.get(`http://localhost:3002/api/servers/${guildId}/autorole-settings`) axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
.then(response => { .then(response => {
if (response.data) { if (response.data) {
setAutoroleSettings(response.data); setAutoroleSettings(response.data);
@@ -101,12 +109,17 @@ const ServerSettings = () => {
}); });
// Fetch commands/help list // Fetch commands/help list
axios.get(`http://localhost:3002/api/servers/${guildId}/commands`) axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(response => { .then(response => {
setCommandsList(response.data || []); setCommandsList(response.data || []);
}) })
.catch(() => setCommandsList([])); .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 // Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) { if (location.state && location.state.openCommands) {
setCommandsExpanded(true); setCommandsExpanded(true);
@@ -115,7 +128,7 @@ const ServerSettings = () => {
}, [guildId, location.state]); }, [guildId, location.state]);
const handleAutoroleSettingUpdate = (newSettings) => { 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 => { .then(response => {
if (response.data.success) { if (response.data.success) {
setAutoroleSettings(newSettings); setAutoroleSettings(newSettings);
@@ -134,7 +147,7 @@ const ServerSettings = () => {
}; };
const handleSettingUpdate = (newSettings) => { 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 => { .then(response => {
if (response.data.success) { if (response.data.success) {
setWelcomeLeaveSettings(newSettings); setWelcomeLeaveSettings(newSettings);
@@ -188,16 +201,6 @@ const ServerSettings = () => {
return 'custom'; 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 = () => { const handleInviteBot = () => {
if (!clientId) return; if (!clientId) return;
const permissions = 8; // Administrator const permissions = 8; // Administrator
@@ -211,7 +214,7 @@ const ServerSettings = () => {
const handleConfirmLeave = async () => { const handleConfirmLeave = async () => {
try { 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); setIsBotInServer(false);
} catch (error) { } catch (error) {
console.error('Error leaving server:', error); console.error('Error leaving server:', error);
@@ -255,33 +258,149 @@ const ServerSettings = () => {
<AccordionDetails> <AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>} {!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}> <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 key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box> <Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography> <Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
<Typography variant="body2">{cmd.description}</Typography> <Typography variant="body2">{cmd.description}</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{['help', 'manage-commands'].includes(cmd.name) ? (
<FormControlLabel
control={<Switch checked={true} disabled />}
label="Locked"
/>
) : (
<FormControlLabel <FormControlLabel
control={<Switch checked={cmd.enabled} onChange={async (e) => { control={<Switch checked={cmd.enabled} onChange={async (e) => {
const newVal = e.target.checked; const newVal = e.target.checked;
// optimistic update // optimistic update
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c)); setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
try { 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) { } catch (err) {
// revert on error // revert on error
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c)); setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
} }
}} disabled={!isBotInServer} label={cmd.enabled ? 'Enabled' : 'Disabled'} />} }} 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>
</Box> </Box>
))} ))}
@@ -420,6 +539,61 @@ const ServerSettings = () => {
title="Confirm Leave" title="Confirm Leave"
message={`Are you sure you want the bot to leave ${server?.name}?`} 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> </div>
); );
}; };

View File

@@ -11,16 +11,25 @@ export const ThemeProvider = ({ children }) => {
const [themeName, setThemeName] = useState(localStorage.getItem('themeName') || 'discord'); const [themeName, setThemeName] = useState(localStorage.getItem('themeName') || 'discord');
useEffect(() => { useEffect(() => {
if (user && user.theme) { // Prefer an explicit user selection (stored in localStorage) over defaults or server values.
setThemeName(user.theme); // Behavior:
} else { // - 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'); const storedTheme = localStorage.getItem('themeName');
if (storedTheme) { if (storedTheme) {
setThemeName(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'); setThemeName('discord');
}
}
}, [user]); }, [user]);
const theme = useMemo(() => { const theme = useMemo(() => {
@@ -36,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
const changeTheme = (name) => { const changeTheme = (name) => {
if (user) { 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); localStorage.setItem('themeName', name);
setThemeName(name); setThemeName(name);