tweaked ui and updated invite command
This commit is contained in:
22
backend/.env.example
Normal file
22
backend/.env.example
Normal 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
|
||||
@@ -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');
|
||||
@@ -371,6 +422,12 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -404,6 +461,6 @@ 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}`);
|
||||
});
|
||||
|
||||
253
checklist.md
253
checklist.md
@@ -1,151 +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
|
||||
- [ ] Add server invite management
|
||||
- [ ] Add UI to create invites: optional channel dropdown, maxAge dropdown, maxUses dropdown, temporary toggle, create button
|
||||
- [ ] Allow invite creation without selecting a channel (use default)
|
||||
- [ ] Persist created invites to backend encrypted DB
|
||||
- [ ] Add front-end list showing created invites with Copy and Delete actions and metadata (url, createdAt, uses, maxUses, maxAge, temporary)
|
||||
- [ ] Add `/create-invite` and `/list-invites` slash commands in the bot; ensure actions sync with backend
|
||||
- [ ] Add enable/disable toggles for these commands in Commands list
|
||||
- [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)
|
||||
|
||||
## Recent frontend tweaks
|
||||
## 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`
|
||||
|
||||
- [x] Commands list sorted alphabetically in Server Settings for easier scanning
|
||||
- [x] Invite creation form: labels added above dropdowns (Channel, Expiry, Max Uses, Temporary) and layout improved for mobile (stacked inputs)
|
||||
- [x] Theme persistence: theme changes now persist immediately (localStorage) and are not overwritten on page navigation; server-side preference is respected when different from local selection
|
||||
- [x] Theme preference behavior: UI now prefers an explicit user selection (localStorage) over defaults; default is used only on first visit when no prior selection exists
|
||||
## 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.
|
||||
@@ -39,7 +39,8 @@ client.on('interactionCreate', async interaction => {
|
||||
try {
|
||||
// call backend delete endpoint
|
||||
const fetch = require('node-fetch');
|
||||
const url = `http://localhost:${process.env.PORT || 3002}/api/servers/${interaction.guildId}/invites/${code}`;
|
||||
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) {
|
||||
|
||||
@@ -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");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const guildsParam = params.get('guilds');
|
||||
if (guildsParam) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(guildsParam));
|
||||
setGuilds(parsed || []);
|
||||
localStorage.setItem('guilds', JSON.stringify(parsed || []));
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const storedGuilds = localStorage.getItem('guilds');
|
||||
if (storedUser && storedGuilds) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
setGuilds(JSON.parse(storedGuilds));
|
||||
const stored = localStorage.getItem('guilds');
|
||||
if (stored) {
|
||||
try {
|
||||
setGuilds(JSON.parse(stored));
|
||||
} catch (err) {
|
||||
setGuilds([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [setUser]);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBotStatus = async () => {
|
||||
const statusPromises = guilds.map(async (guild) => {
|
||||
if (!guilds || guilds.length === 0) return;
|
||||
const fetchStatuses = async () => {
|
||||
const statuses = {};
|
||||
await Promise.all(guilds.map(async (g) => {
|
||||
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 resp = await axios.get(`${API_BASE}/api/servers/${g.id}/bot-status`);
|
||||
statuses[g.id] = resp.data.isBotInServer;
|
||||
} catch (err) {
|
||||
statuses[g.id] = false;
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(statusPromises);
|
||||
const newBotStatus = results.reduce((acc, curr) => {
|
||||
acc[curr.guildId] = curr.isBotInServer;
|
||||
return acc;
|
||||
}, {});
|
||||
setBotStatus(newBotStatus);
|
||||
}));
|
||||
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}>
|
||||
<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',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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' } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: { xs: 'center', sm: 'flex-start' } }}>
|
||||
<Box
|
||||
title={guild.name}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: '999px',
|
||||
fontWeight: 'bold',
|
||||
bgcolor: 'rgba(0,0,0,0.06)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: { xs: 'center', sm: 'left' }
|
||||
}}
|
||||
>
|
||||
{guild.name}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Card
|
||||
onClick={() => handleCardClick(guild)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
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',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* 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] ? (
|
||||
<IconButton
|
||||
aria-label={`Make bot leave ${guild.name}`}
|
||||
size="small"
|
||||
onClick={(e) => handleLeaveBot(e, guild)}
|
||||
>
|
||||
<RemoveCircleOutlineIcon />
|
||||
</IconButton>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
aria-label={`Invite bot to ${guild.name}`}
|
||||
size="small"
|
||||
onClick={(e) => handleInviteBot(e, guild)}
|
||||
>
|
||||
<PersonAddIcon />
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{/* 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: { xs: 1, sm: 2 },
|
||||
py: 0.5,
|
||||
borderRadius: '999px',
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '0.95rem', sm: '1rem' },
|
||||
bgcolor: 'rgba(0,0,0,0.04)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
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>
|
||||
|
||||
{/* 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)}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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
|
||||
@@ -26,6 +26,11 @@ const ServerSettings = () => {
|
||||
});
|
||||
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({
|
||||
@@ -58,29 +63,31 @@ const ServerSettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
|
||||
|
||||
// Fetch settings (not used directly in this component)
|
||||
axios.get(`http://localhost:3002/api/servers/${guildId}/settings`).catch(() => {});
|
||||
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);
|
||||
@@ -88,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);
|
||||
@@ -102,14 +109,14 @@ 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(`http://localhost:3002/api/servers/${guildId}/invites`)
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/invites`)
|
||||
.then(resp => setInvites(resp.data || []))
|
||||
.catch(() => setInvites([]));
|
||||
|
||||
@@ -121,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);
|
||||
@@ -140,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);
|
||||
@@ -207,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);
|
||||
@@ -251,36 +258,51 @@ const ServerSettings = () => {
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}>
|
||||
{commandsList && [...commandsList].sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'})).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 });
|
||||
} 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>
|
||||
))}
|
||||
{/** 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 }}>
|
||||
<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(`${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>
|
||||
@@ -332,7 +354,7 @@ const ServerSettings = () => {
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<Button variant="contained" onClick={async () => {
|
||||
try {
|
||||
const resp = await axios.post(`http://localhost:3002/api/servers/${guildId}/invites`, inviteForm);
|
||||
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]);
|
||||
}
|
||||
@@ -352,12 +374,32 @@ const ServerSettings = () => {
|
||||
<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={() => { navigator.clipboard.writeText(inv.url); }}>Copy</Button>
|
||||
<Button startIcon={<DeleteIcon />} color="error" onClick={async () => {
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={async () => {
|
||||
// robust clipboard copy with fallback
|
||||
try {
|
||||
await axios.delete(`http://localhost:3002/api/servers/${guildId}/invites/${inv.code}`);
|
||||
setInvites(prev => prev.filter(i => i.code !== inv.code));
|
||||
} catch (err) { console.error('Error deleting invite:', err); }
|
||||
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>
|
||||
@@ -497,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,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);
|
||||
|
||||
Reference in New Issue
Block a user