Merge pull request #13 from Fretuks/revert-12-add-web-dashboard-for-discord-bot-configuration
Revert "Add dashboard UI and secure API endpoints"
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
# Backend configuration
|
|
||||||
DISCORD_CLIENT_ID=
|
|
||||||
DISCORD_CLIENT_SECRET=
|
|
||||||
DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
|
|
||||||
DISCORD_BOT_TOKEN=
|
|
||||||
MONGODB_URI=mongodb://localhost:27017
|
|
||||||
SESSION_SECRET=change-me
|
|
||||||
DASHBOARD_CLIENT_URL=http://localhost:5173
|
|
||||||
DASHBOARD_PORT=3000
|
|
||||||
DASHBOARD_SESSION_TTL_MS=86400000
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Frontend configuration (Vite)
|
|
||||||
VITE_API_BASE_URL=http://localhost:3000
|
|
||||||
VITE_BOT_INVITE_URL=
|
|
||||||
@@ -1,65 +1,80 @@
|
|||||||
# Discord Bot
|
Im going to make a To-do List for the features i want to implement for my Bot.
|
||||||
|
|
||||||
## Dashboard setup
|
### **To-Do List for My Discord Bot**
|
||||||
|
|
||||||
This repository includes a lightweight dashboard for configuring permissions and warnings without editing JSON. The dashboard uses Discord OAuth2 and the existing MongoDB collections.
|
#### **Core Features**
|
||||||
|
(I will try to make these tools optional, because alot of bots already feature them)
|
||||||
|
|
||||||
### Environment variables
|
1. **Basic Commands**
|
||||||
|
- [x] Add a `/help` command to display a list of available commands.
|
||||||
|
- [x] Implement a `/ping` command to check bot responsiveness.
|
||||||
|
- [x] Add an `/about` command to describe the bot's purpose.
|
||||||
|
|
||||||
Copy `.env.example` and fill in the values:
|
2. **Role Management**
|
||||||
|
- [ ] Set up a self-assignable role system (e.g., using reaction roles).
|
||||||
|
- [x] Allow admins to assign/remove roles with commands.
|
||||||
|
|
||||||
```bash
|
3. **Moderation Tools**
|
||||||
cp .env.example .env
|
- [x] Add commands to mute, kick, and temp_ban users.
|
||||||
```
|
- [ ] Create an auto-moderation feature to delete spam or inappropriate content.
|
||||||
|
- [x] Implement a warning system with a strike counter.
|
||||||
Required backend variables:
|
|
||||||
|
|
||||||
- `DISCORD_CLIENT_ID`
|
|
||||||
- `DISCORD_CLIENT_SECRET`
|
|
||||||
- `DISCORD_REDIRECT_URI` (example: `http://localhost:3000/auth/discord/callback`)
|
|
||||||
- `DISCORD_BOT_TOKEN`
|
|
||||||
- `MONGODB_URI`
|
|
||||||
- `SESSION_SECRET`
|
|
||||||
- `DASHBOARD_CLIENT_URL` (example: `http://localhost:5173`)
|
|
||||||
|
|
||||||
Frontend variables (Vite):
|
|
||||||
|
|
||||||
- `VITE_API_BASE_URL` (example: `http://localhost:3000`)
|
|
||||||
- `VITE_BOT_INVITE_URL`
|
|
||||||
|
|
||||||
> Make sure the redirect URI is added in the Discord Developer Portal OAuth2 settings.
|
|
||||||
|
|
||||||
### Running the backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node dashboardServer.js
|
|
||||||
```
|
|
||||||
|
|
||||||
The API will run on `DASHBOARD_PORT` (default `3000`).
|
|
||||||
|
|
||||||
### Running the dashboard frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd dashboard
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend will run on `http://localhost:5173` by default.
|
|
||||||
|
|
||||||
### API health check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Existing feature notes
|
#### **Unique Features**
|
||||||
|
1. **Currency System**
|
||||||
|
- [x] Design a virtual currency system with basic commands:
|
||||||
|
- [x] `/balance` to check currency balance.
|
||||||
|
- [x] `/earn` to earn daily or periodic rewards.
|
||||||
|
- [x] `/transfer @user [amount]` to send currency to another user.
|
||||||
|
- [ ] Link the currency system to role management:
|
||||||
|
- [ ] Allow users to purchase roles using the currency with a command (e.g., `!buyrole [role name]`).
|
||||||
|
|
||||||
### Server-Specific Permission Configuration
|
2. **Datetime Converter**
|
||||||
|
(Still in Progress, maybe i'll add visual Menu)
|
||||||
|
- [x] Create a command to convert timezones:
|
||||||
|
- [x] `/timestamp [date]` to generate Discord timestamps.
|
||||||
|
- [x] Support multiple Discord timestamp formats.
|
||||||
|
- [ ] Include a command to display server-specific or user-specific timezones.
|
||||||
|
|
||||||
Moderation commands support per-server permission overrides stored in MongoDB. Each guild can define which users or roles are allowed to run specific commands, plus optional admin overrides that apply to all moderation commands.
|
3. **Minigames**
|
||||||
|
- [ ] Implement a Quiz Game:
|
||||||
|
- [x] Add a `/quiz` command to start the game.
|
||||||
|
- [x] Provide multiple-choice or true/false questions.
|
||||||
|
- [x] Award virtual currency for correct answers.
|
||||||
|
- [ ] Add a simple number-guessing game (e.g., `!guess [number]`).
|
||||||
|
- [ ] Create a leaderboard for game winners.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Utility Tools**
|
||||||
|
1. **Search and Lookups**
|
||||||
|
- [x] Add a `/wiki [topic]` command to fetch summaries from Wikipedia.
|
||||||
|
- [x] Include a `/weather [location]` command for weather updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Advanced and Fun Features (very optional)**
|
||||||
|
1. **AI Chat Integration**
|
||||||
|
- [ ] Integrate GPT for conversational AI:
|
||||||
|
- [ ] Respond to mentions with intelligent replies.
|
||||||
|
- [ ] Allow users to ask general questions with `!ask [question]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Deployment (will happen if im completly satisfied with the bot)**
|
||||||
|
1. **Hosting**
|
||||||
|
- [ ] Set up local hosting for development.
|
||||||
|
- [ ] Deploy the bot on a cloud platform (e.g., Heroku, AWS, or Railway).
|
||||||
|
|
||||||
|
2. **Monitoring**
|
||||||
|
- [ ] Add logging to track errors and usage statistics.
|
||||||
|
- [ ] Implement uptime monitoring to ensure the bot stays online.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Server-Specific Permission Configuration**
|
||||||
|
Moderation commands support per-server permission overrides stored in MongoDB. Each guild can define which users or roles are allowed to run specific commands, plus optional admin overrides that apply to all moderation commands. This is intended for future web-based configuration.
|
||||||
|
|
||||||
Example document stored in the `discord.guild_permissions` collection:
|
Example document stored in the `discord.guild_permissions` collection:
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Discord Bot Dashboard</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "discord-bot-dashboard",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
|
||||||
"vite": "^5.4.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,576 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
|
||||||
const BOT_INVITE_URL = import.meta.env.VITE_BOT_INVITE_URL || '';
|
|
||||||
|
|
||||||
const apiFetch = async (path, options = {}) => {
|
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
const data = contentType && contentType.includes('application/json')
|
|
||||||
? await response.json()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = new Error(data?.error || 'Request failed');
|
|
||||||
error.details = data?.details;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const usePath = () => {
|
|
||||||
const [path, setPath] = useState(window.location.pathname);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => setPath(window.location.pathname);
|
|
||||||
window.addEventListener('popstate', handler);
|
|
||||||
return () => window.removeEventListener('popstate', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigate = useCallback((nextPath) => {
|
|
||||||
window.history.pushState({}, '', nextPath);
|
|
||||||
setPath(nextPath);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { path, navigate };
|
|
||||||
};
|
|
||||||
|
|
||||||
const Toast = ({ message, type, onDismiss }) => {
|
|
||||||
if (!message) return null;
|
|
||||||
return (
|
|
||||||
<div className={`toast toast-${type}`}>
|
|
||||||
<span>{message}</span>
|
|
||||||
<button type="button" onClick={onDismiss} aria-label="Dismiss">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Landing = () => (
|
|
||||||
<div className="card">
|
|
||||||
<h1>Discord Bot Dashboard</h1>
|
|
||||||
<p>Manage bot permissions and warnings without editing raw JSON.</p>
|
|
||||||
<div className="button-row">
|
|
||||||
{BOT_INVITE_URL ? (
|
|
||||||
<a className="button" href={BOT_INVITE_URL} target="_blank" rel="noreferrer">
|
|
||||||
Add bot to server
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="muted">Set VITE_BOT_INVITE_URL to enable the invite link.</span>
|
|
||||||
)}
|
|
||||||
<a className="button primary" href={`${API_BASE_URL}/auth/discord`}>
|
|
||||||
Login with Discord
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const GuildPicker = ({ onManage }) => {
|
|
||||||
const [guilds, setGuilds] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let ignore = false;
|
|
||||||
setLoading(true);
|
|
||||||
apiFetch('/api/guilds')
|
|
||||||
.then((data) => {
|
|
||||||
if (!ignore) {
|
|
||||||
setGuilds(data.guilds || []);
|
|
||||||
setError('');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!ignore) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!ignore) setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Your Guilds</h1>
|
|
||||||
<p className="muted">Choose a server you manage to update permissions and warnings.</p>
|
|
||||||
{loading ? <p>Loading guilds…</p> : null}
|
|
||||||
{error ? <p className="error">{error}</p> : null}
|
|
||||||
<div className="guild-grid">
|
|
||||||
{guilds.map((guild) => (
|
|
||||||
<div key={guild.id} className="guild-card">
|
|
||||||
{guild.icon ? (
|
|
||||||
<img
|
|
||||||
src={`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="guild-placeholder" />
|
|
||||||
)}
|
|
||||||
<div className="guild-meta">
|
|
||||||
<h3>{guild.name}</h3>
|
|
||||||
<button className="button" type="button" onClick={() => onManage(guild.id)}>
|
|
||||||
Manage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!loading && guilds.length === 0 ? (
|
|
||||||
<p className="muted">No manageable guilds found for this account.</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminUserList = ({ value, onChange }) => {
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
|
|
||||||
const addUser = () => {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
if (!value.includes(trimmed)) {
|
|
||||||
onChange([...value, trimmed]);
|
|
||||||
}
|
|
||||||
setInput('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="chip-row">
|
|
||||||
{value.map((userId) => (
|
|
||||||
<span key={userId} className="chip">
|
|
||||||
{userId}
|
|
||||||
<button type="button" onClick={() => onChange(value.filter((id) => id !== userId))}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{value.length === 0 ? <span className="muted">No admin users yet.</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="input-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={input}
|
|
||||||
onChange={(event) => setInput(event.target.value)}
|
|
||||||
placeholder="Add user ID"
|
|
||||||
/>
|
|
||||||
<button type="button" className="button" onClick={addUser}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CommandPermissionEditor = ({ roles, value, onChange }) => {
|
|
||||||
const [newCommand, setNewCommand] = useState('');
|
|
||||||
const commandEntries = Object.entries(value || {});
|
|
||||||
|
|
||||||
const updateCommand = (commandName, nextData) => {
|
|
||||||
onChange({
|
|
||||||
...value,
|
|
||||||
[commandName]: {
|
|
||||||
allowedRoleIds: nextData.allowedRoleIds,
|
|
||||||
allowedUserIds: nextData.allowedUserIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeCommand = (commandName) => {
|
|
||||||
const updated = { ...value };
|
|
||||||
delete updated[commandName];
|
|
||||||
onChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCommand = () => {
|
|
||||||
const trimmed = newCommand.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
if (!value[trimmed]) {
|
|
||||||
onChange({
|
|
||||||
...value,
|
|
||||||
[trimmed]: { allowedRoleIds: [], allowedUserIds: [] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setNewCommand('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="command-editor">
|
|
||||||
<div className="input-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newCommand}
|
|
||||||
onChange={(event) => setNewCommand(event.target.value)}
|
|
||||||
placeholder="Add command name"
|
|
||||||
/>
|
|
||||||
<button type="button" className="button" onClick={addCommand}>
|
|
||||||
Add command
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{commandEntries.length === 0 ? (
|
|
||||||
<p className="muted">No command overrides yet. Empty means commands are allowed for everyone.</p>
|
|
||||||
) : null}
|
|
||||||
{commandEntries.map(([commandName, entry]) => (
|
|
||||||
<div key={commandName} className="command-card">
|
|
||||||
<div className="command-header">
|
|
||||||
<h4>{commandName}</h4>
|
|
||||||
<button type="button" className="text-button" onClick={() => removeCommand(commandName)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="muted">
|
|
||||||
If both lists are empty, this command remains available to everyone.
|
|
||||||
</p>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Allowed roles</label>
|
|
||||||
<div className="checkbox-grid">
|
|
||||||
{roles.map((role) => (
|
|
||||||
<label key={role.id} className="checkbox-item">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={entry.allowedRoleIds?.includes(role.id)}
|
|
||||||
onChange={(event) => {
|
|
||||||
const next = event.target.checked
|
|
||||||
? [...entry.allowedRoleIds, role.id]
|
|
||||||
: entry.allowedRoleIds.filter((id) => id !== role.id);
|
|
||||||
updateCommand(commandName, {
|
|
||||||
...entry,
|
|
||||||
allowedRoleIds: next,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{role.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{roles.length === 0 ? <span className="muted">No roles available.</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Allowed user IDs</label>
|
|
||||||
<AdminUserList
|
|
||||||
value={entry.allowedUserIds || []}
|
|
||||||
onChange={(nextUsers) => updateCommand(commandName, {
|
|
||||||
...entry,
|
|
||||||
allowedUserIds: nextUsers,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PermissionsTab = ({ guildId, onToast }) => {
|
|
||||||
const [roles, setRoles] = useState([]);
|
|
||||||
const [permissions, setPermissions] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let ignore = false;
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
apiFetch(`/api/guilds/${guildId}/roles`),
|
|
||||||
apiFetch(`/api/guilds/${guildId}/permissions`),
|
|
||||||
])
|
|
||||||
.then(([rolesData, permissionsData]) => {
|
|
||||||
if (ignore) return;
|
|
||||||
setRoles(rolesData.roles || []);
|
|
||||||
setPermissions(permissionsData.permissions || {});
|
|
||||||
setError('');
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!ignore) setError(err.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!ignore) setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, [guildId]);
|
|
||||||
|
|
||||||
const toggleRole = (roleId) => {
|
|
||||||
if (!permissions) return;
|
|
||||||
const adminRoleIds = permissions.adminRoleIds || [];
|
|
||||||
const next = adminRoleIds.includes(roleId)
|
|
||||||
? adminRoleIds.filter((id) => id !== roleId)
|
|
||||||
: [...adminRoleIds, roleId];
|
|
||||||
setPermissions({ ...permissions, adminRoleIds: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const savePermissions = async () => {
|
|
||||||
if (!permissions) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`/api/guilds/${guildId}/permissions`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(permissions),
|
|
||||||
});
|
|
||||||
setPermissions(response.permissions);
|
|
||||||
onToast('Permissions saved.', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
onToast(err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p>Loading permissions…</p>;
|
|
||||||
if (error) return <p className="error">{error}</p>;
|
|
||||||
if (!permissions) return <p className="error">Failed to load permissions.</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tab-content">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Admin roles</label>
|
|
||||||
<div className="checkbox-grid">
|
|
||||||
{roles.map((role) => (
|
|
||||||
<label key={role.id} className="checkbox-item">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={permissions.adminRoleIds?.includes(role.id)}
|
|
||||||
onChange={() => toggleRole(role.id)}
|
|
||||||
/>
|
|
||||||
<span>{role.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{roles.length === 0 ? <span className="muted">No roles available.</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Admin users</label>
|
|
||||||
<AdminUserList
|
|
||||||
value={permissions.adminUserIds || []}
|
|
||||||
onChange={(nextUsers) => setPermissions({ ...permissions, adminUserIds: nextUsers })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Per-command permissions</label>
|
|
||||||
<CommandPermissionEditor
|
|
||||||
roles={roles}
|
|
||||||
value={permissions.commandPermissions || {}}
|
|
||||||
onChange={(nextCommands) =>
|
|
||||||
setPermissions({ ...permissions, commandPermissions: nextCommands })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="button-row">
|
|
||||||
<button type="button" className="button primary" onClick={savePermissions} disabled={saving}>
|
|
||||||
{saving ? 'Saving…' : 'Save permissions'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const WarningsTab = ({ guildId, onToast }) => {
|
|
||||||
const [userId, setUserId] = useState('');
|
|
||||||
const [warnings, setWarnings] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [reason, setReason] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const fetchWarnings = async () => {
|
|
||||||
if (!userId.trim()) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await apiFetch(`/api/guilds/${guildId}/warnings/${userId.trim()}`);
|
|
||||||
setWarnings(data.warnings || []);
|
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addWarning = async () => {
|
|
||||||
if (!userId.trim()) return;
|
|
||||||
try {
|
|
||||||
const data = await apiFetch(`/api/guilds/${guildId}/warnings/${userId.trim()}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ reason }),
|
|
||||||
});
|
|
||||||
setWarnings(data.warnings || []);
|
|
||||||
setReason('');
|
|
||||||
onToast('Warning added.', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
onToast(err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tab-content">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>User ID</label>
|
|
||||||
<div className="input-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={userId}
|
|
||||||
onChange={(event) => setUserId(event.target.value)}
|
|
||||||
placeholder="Enter user ID"
|
|
||||||
/>
|
|
||||||
<button type="button" className="button" onClick={fetchWarnings} disabled={loading}>
|
|
||||||
{loading ? 'Loading…' : 'Load warnings'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error ? <p className="error">{error}</p> : null}
|
|
||||||
<div className="warnings-list">
|
|
||||||
{warnings.length === 0 ? (
|
|
||||||
<p className="muted">No warnings found for this user.</p>
|
|
||||||
) : (
|
|
||||||
warnings.map((warning, index) => (
|
|
||||||
<div key={`${warning.createdAt}-${index}`} className="warning-card">
|
|
||||||
<p>{warning.reason}</p>
|
|
||||||
<div className="warning-meta">
|
|
||||||
<span>Moderator: {warning.moderatorId || 'Unknown'}</span>
|
|
||||||
<span>{warning.createdAt ? new Date(warning.createdAt).toLocaleString() : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Add warning</label>
|
|
||||||
<textarea
|
|
||||||
value={reason}
|
|
||||||
onChange={(event) => setReason(event.target.value)}
|
|
||||||
rows={4}
|
|
||||||
placeholder="Reason for this warning"
|
|
||||||
/>
|
|
||||||
<button type="button" className="button primary" onClick={addWarning}>
|
|
||||||
Add warning
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GuildSettings = ({ guildId }) => {
|
|
||||||
const [activeTab, setActiveTab] = useState('permissions');
|
|
||||||
const [toast, setToast] = useState({ message: '', type: 'success' });
|
|
||||||
|
|
||||||
const dismissToast = () => setToast({ message: '', type: 'success' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="tabs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeTab === 'permissions' ? 'active' : ''}
|
|
||||||
onClick={() => setActiveTab('permissions')}
|
|
||||||
>
|
|
||||||
Permissions
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeTab === 'warnings' ? 'active' : ''}
|
|
||||||
onClick={() => setActiveTab('warnings')}
|
|
||||||
>
|
|
||||||
Warnings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{activeTab === 'permissions' ? (
|
|
||||||
<PermissionsTab
|
|
||||||
guildId={guildId}
|
|
||||||
onToast={(message, type) => setToast({ message, type })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<WarningsTab
|
|
||||||
guildId={guildId}
|
|
||||||
onToast={(message, type) => setToast({ message, type })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Toast message={toast.message} type={toast.type} onDismiss={dismissToast} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const { path, navigate } = usePath();
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
apiFetch('/api/me')
|
|
||||||
.then((data) => {
|
|
||||||
setUser(data.user);
|
|
||||||
setError('');
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setUser(null);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const guildId = useMemo(() => {
|
|
||||||
const match = path.match(/^\/guilds\/(.+)$/);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}, [path]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="container"><p>Loading dashboard…</p></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<Landing />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<header className="header">
|
|
||||||
<div>
|
|
||||||
<h2>Welcome, {user.username}</h2>
|
|
||||||
<p className="muted">{user.id}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button ghost"
|
|
||||||
onClick={() => apiFetch('/auth/logout', { method: 'POST' }).then(() => window.location.reload())}
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
{error ? <p className="error">{error}</p> : null}
|
|
||||||
{guildId ? (
|
|
||||||
<div>
|
|
||||||
<button type="button" className="text-button" onClick={() => navigate('/')}>← Back to guilds</button>
|
|
||||||
<GuildSettings guildId={guildId} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<GuildPicker onManage={(id) => navigate(`/guilds/${id}`)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import App from './App.jsx';
|
|
||||||
import './styles.css';
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
|
||||||
createRoot(rootElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
color: #e6e6f0;
|
|
||||||
background-color: #0f1117;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: #0f1117;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1080px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 32px 20px 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid #24283b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #171b26;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: #2f3348;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.primary {
|
|
||||||
background: #5865f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.ghost {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #2f3348;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #8b93ff;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guild-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guild-card {
|
|
||||||
background: #171b26;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guild-card img,
|
|
||||||
.guild-placeholder {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 16px;
|
|
||||||
object-fit: cover;
|
|
||||||
background: #2f3348;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guild-meta h3 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: #9aa0b4;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ff8a8a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #2f3348;
|
|
||||||
background: #0f1117;
|
|
||||||
color: #e6e6f0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
background: #0f1117;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #2f3348;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
background: #2f3348;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip button {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-card {
|
|
||||||
background: #171b26;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs button {
|
|
||||||
background: #2f3348;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs button.active {
|
|
||||||
background: #5865f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-card {
|
|
||||||
background: #171b26;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9aa0b4;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
right: 24px;
|
|
||||||
bottom: 24px;
|
|
||||||
background: #2f3348;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-success {
|
|
||||||
border-left: 4px solid #4bd37b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-error {
|
|
||||||
border-left: 4px solid #ff8a8a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.input-row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+109
-167
@@ -2,18 +2,9 @@ const crypto = require('node:crypto');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const cookieParser = require('cookie-parser');
|
|
||||||
const cors = require('cors');
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const session = require('express-session');
|
|
||||||
const MongoStore = require('connect-mongo');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const {addWarning, getWarnings} = require('./services/warnings');
|
const {addWarning, getWarnings} = require('./services/warnings');
|
||||||
const {
|
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
|
||||||
getGuildPermissions,
|
|
||||||
updateGuildPermissions,
|
|
||||||
sanitizePermissionPayload,
|
|
||||||
} = require('./services/permissions');
|
|
||||||
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const PERMISSION_BITS = {
|
const PERMISSION_BITS = {
|
||||||
ADMINISTRATOR: 0x8n,
|
ADMINISTRATOR: 0x8n,
|
||||||
@@ -47,8 +38,6 @@ const DISCORD_BOT_TOKEN =
|
|||||||
process.env.DISCORD_BOT_TOKEN ||
|
process.env.DISCORD_BOT_TOKEN ||
|
||||||
config.discordBotToken ||
|
config.discordBotToken ||
|
||||||
config.token;
|
config.token;
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || config.mongodb;
|
|
||||||
const SESSION_SECRET = process.env.SESSION_SECRET || config.sessionSecret;
|
|
||||||
const DASHBOARD_CLIENT_URL =
|
const DASHBOARD_CLIENT_URL =
|
||||||
process.env.DASHBOARD_CLIENT_URL ||
|
process.env.DASHBOARD_CLIENT_URL ||
|
||||||
config.dashboardClientUrl ||
|
config.dashboardClientUrl ||
|
||||||
@@ -67,14 +56,77 @@ console.log('[oauth config]', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DISCORD_API_BASE = 'https://discord.com/api';
|
const DISCORD_API_BASE = 'https://discord.com/api';
|
||||||
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
|
|
||||||
|
|
||||||
const logInfo = (message, meta = {}) => {
|
const sessionStore = new Map();
|
||||||
console.log(JSON.stringify({level: 'info', message, ...meta}));
|
|
||||||
|
const parseCookies = (headerValue) => {
|
||||||
|
const cookies = {};
|
||||||
|
if (!headerValue) {
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerValue.split(';').forEach((cookie) => {
|
||||||
|
const [rawName, ...rest] = cookie.trim().split('=');
|
||||||
|
if (!rawName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cookies[rawName] = decodeURIComponent(rest.join('='));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cookies;
|
||||||
};
|
};
|
||||||
|
|
||||||
const logError = (message, meta = {}) => {
|
const setCookie = (res, name, value, options = {}) => {
|
||||||
console.error(JSON.stringify({level: 'error', message, ...meta}));
|
const directives = [`${name}=${encodeURIComponent(value)}`];
|
||||||
|
if (options.maxAge) {
|
||||||
|
directives.push(`Max-Age=${options.maxAge}`);
|
||||||
|
}
|
||||||
|
directives.push(`Path=${options.path || '/'}`);
|
||||||
|
if (options.httpOnly !== false) {
|
||||||
|
directives.push('HttpOnly');
|
||||||
|
}
|
||||||
|
if (options.sameSite) {
|
||||||
|
directives.push(`SameSite=${options.sameSite}`);
|
||||||
|
}
|
||||||
|
if (options.secure) {
|
||||||
|
directives.push('Secure');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.append('Set-Cookie', directives.join('; '));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCookie = (res, name) => {
|
||||||
|
setCookie(res, name, '', {maxAge: 0});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSession = (payload) => {
|
||||||
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS);
|
||||||
|
|
||||||
|
sessionStore.set(sessionId, {
|
||||||
|
...payload,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {sessionId, expiresAt};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSession = (sessionId) => {
|
||||||
|
const session = sessionStore.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.expiresAt <= Date.now()) {
|
||||||
|
sessionStore.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroySession = (sessionId) => {
|
||||||
|
sessionStore.delete(sessionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureOAuthConfig = () => {
|
const ensureOAuthConfig = () => {
|
||||||
@@ -99,7 +151,7 @@ const buildAuthorizationUrl = (state) => {
|
|||||||
prompt: 'consent',
|
prompt: 'consent',
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
|
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDiscordAccessToken = async (code) => {
|
const fetchDiscordAccessToken = async (code) => {
|
||||||
@@ -175,10 +227,19 @@ const canManageGuild = (guild) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureSession = (req, res, next) => {
|
const ensureSession = (req, res, next) => {
|
||||||
if (!req.session?.user || !req.session?.accessToken) {
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const sessionId = cookies.dashboard_session;
|
||||||
|
if (!sessionId) {
|
||||||
return res.status(401).json({error: 'Unauthorized'});
|
return res.status(401).json({error: 'Unauthorized'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(401).json({error: 'Unauthorized'});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = session;
|
||||||
|
req.sessionId = sessionId;
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,138 +257,40 @@ const ensureManageableGuild = async (req, res, next) => {
|
|||||||
req.guild = guild;
|
req.guild = guild;
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to fetch guilds for permission check', {
|
|
||||||
message: error.message,
|
|
||||||
status: error.response?.status,
|
|
||||||
});
|
|
||||||
return res.status(502).json({error: 'Failed to fetch guilds'});
|
return res.status(502).json({error: 'Failed to fetch guilds'});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCorsOrigins = () => {
|
|
||||||
if (!DASHBOARD_CLIENT_URL) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const entries = DASHBOARD_CLIENT_URL.split(',').map((value) => value.trim()).filter(Boolean);
|
|
||||||
return entries.length ? entries : true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSessionStore = () => {
|
|
||||||
if (!MONGODB_URI) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return MongoStore.create({
|
|
||||||
mongoUrl: MONGODB_URI,
|
|
||||||
collectionName: 'dashboard_sessions',
|
|
||||||
ttl: Math.floor(
|
|
||||||
(Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS) / 1000,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePermissionPayload = (payload = {}) => {
|
|
||||||
const errors = [];
|
|
||||||
if (payload.adminRoleIds && !Array.isArray(payload.adminRoleIds)) {
|
|
||||||
errors.push('adminRoleIds must be an array of role IDs');
|
|
||||||
}
|
|
||||||
if (payload.adminUserIds && !Array.isArray(payload.adminUserIds)) {
|
|
||||||
errors.push('adminUserIds must be an array of user IDs');
|
|
||||||
}
|
|
||||||
if (payload.commandPermissions && typeof payload.commandPermissions !== 'object') {
|
|
||||||
errors.push('commandPermissions must be an object');
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateWarningReason = (reason) => {
|
|
||||||
if (typeof reason !== 'string') {
|
|
||||||
return 'Reason must be a string';
|
|
||||||
}
|
|
||||||
const trimmed = reason.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return 'Reason is required';
|
|
||||||
}
|
|
||||||
if (trimmed.length > 500) {
|
|
||||||
return 'Reason must be 500 characters or fewer';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDashboardApp = () => {
|
const createDashboardApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
app.set('trust proxy', 1);
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
cors({
|
|
||||||
origin: buildCorsOrigins(),
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
if (!SESSION_SECRET) {
|
app.get('/auth/discord', (req, res) => {
|
||||||
logError('Missing SESSION_SECRET configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionStore = buildSessionStore();
|
|
||||||
if (!sessionStore) {
|
|
||||||
logError('Missing MONGODB_URI configuration for session store');
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
session({
|
|
||||||
secret: SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
|
||||||
cookie: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: isProduction,
|
|
||||||
maxAge: Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS,
|
|
||||||
},
|
|
||||||
store: sessionStore || undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const authLimiter = rateLimit({
|
|
||||||
windowMs: 60 * 1000,
|
|
||||||
max: 30,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
return res.json({status: 'ok'});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/auth/discord', authLimiter, (req, res) => {
|
|
||||||
if (!ensureOAuthConfig()) {
|
if (!ensureOAuthConfig()) {
|
||||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
res.cookie('oauth_state', state, {
|
setCookie(res, 'oauth_state', state, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'Lax',
|
||||||
secure: isProduction,
|
secure: req.secure,
|
||||||
maxAge: 300 * 1000,
|
maxAge: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.redirect(buildAuthorizationUrl(state));
|
return res.redirect(buildAuthorizationUrl(state));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
|
app.get('/auth/discord/callback', async (req, res) => {
|
||||||
if (!ensureOAuthConfig()) {
|
if (!ensureOAuthConfig()) {
|
||||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const {code, state} = req.query;
|
const {code, state} = req.query;
|
||||||
if (!code || !state || req.cookies.oauth_state !== state) {
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
if (!code || !state || cookies.oauth_state !== state) {
|
||||||
return res.status(400).json({error: 'Invalid OAuth state'});
|
return res.status(400).json({error: 'Invalid OAuth state'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,15 +298,21 @@ const createDashboardApp = () => {
|
|||||||
const tokenData = await fetchDiscordAccessToken(code);
|
const tokenData = await fetchDiscordAccessToken(code);
|
||||||
const user = await fetchDiscordUser(tokenData.access_token);
|
const user = await fetchDiscordUser(tokenData.access_token);
|
||||||
|
|
||||||
const expiresAt = Date.now() + (tokenData.expires_in || 0) * 1000;
|
const {sessionId, expiresAt} = createSession({
|
||||||
req.session.accessToken = tokenData.access_token;
|
accessToken: tokenData.access_token,
|
||||||
req.session.refreshToken = tokenData.refresh_token;
|
refreshToken: tokenData.refresh_token,
|
||||||
req.session.tokenType = tokenData.token_type;
|
tokenType: tokenData.token_type,
|
||||||
req.session.scope = tokenData.scope;
|
scope: tokenData.scope,
|
||||||
req.session.expiresAt = expiresAt;
|
user,
|
||||||
req.session.user = user;
|
});
|
||||||
|
|
||||||
res.clearCookie('oauth_state');
|
setCookie(res, 'dashboard_session', sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
secure: req.secure,
|
||||||
|
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
|
||||||
|
});
|
||||||
|
clearCookie(res, 'oauth_state');
|
||||||
|
|
||||||
if (DASHBOARD_CLIENT_URL) {
|
if (DASHBOARD_CLIENT_URL) {
|
||||||
return res.redirect(DASHBOARD_CLIENT_URL);
|
return res.redirect(DASHBOARD_CLIENT_URL);
|
||||||
@@ -355,7 +324,7 @@ const createDashboardApp = () => {
|
|||||||
|
|
||||||
return res.status(200).json({user});
|
return res.status(200).json({user});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Discord OAuth failed', {
|
console.error('Discord OAuth failed', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
data: error.response?.data,
|
data: error.response?.data,
|
||||||
@@ -369,19 +338,10 @@ const createDashboardApp = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/auth/logout', ensureSession, (req, res) => {
|
app.post('/auth/logout', ensureSession, (req, res) => {
|
||||||
req.session.destroy((err) => {
|
destroySession(req.sessionId);
|
||||||
if (err) {
|
clearCookie(res, 'dashboard_session');
|
||||||
logError('Failed to destroy session', {message: err.message});
|
|
||||||
return res.status(500).json({error: 'Failed to log out'});
|
|
||||||
}
|
|
||||||
res.clearCookie('connect.sid');
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/me', ensureSession, (req, res) => {
|
|
||||||
return res.json({user: req.session.user});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/guilds', ensureSession, async (req, res) => {
|
app.get('/api/guilds', ensureSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -389,10 +349,6 @@ const createDashboardApp = () => {
|
|||||||
const manageableGuilds = guilds.filter(canManageGuild);
|
const manageableGuilds = guilds.filter(canManageGuild);
|
||||||
return res.json({guilds: manageableGuilds});
|
return res.json({guilds: manageableGuilds});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to fetch guilds', {
|
|
||||||
message: error.message,
|
|
||||||
status: error.response?.status,
|
|
||||||
});
|
|
||||||
return res.status(502).json({error: 'Failed to fetch guilds'});
|
return res.status(502).json({error: 'Failed to fetch guilds'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -406,7 +362,6 @@ const createDashboardApp = () => {
|
|||||||
const permissions = await getGuildPermissions(req.params.guildId);
|
const permissions = await getGuildPermissions(req.params.guildId);
|
||||||
return res.json({permissions});
|
return res.json({permissions});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to fetch permissions', {message: error.message});
|
|
||||||
return res.status(500).json({error: 'Failed to fetch permissions'});
|
return res.status(500).json({error: 'Failed to fetch permissions'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -417,19 +372,13 @@ const createDashboardApp = () => {
|
|||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const validationErrors = validatePermissionPayload(req.body || {});
|
|
||||||
if (validationErrors.length) {
|
|
||||||
return res.status(400).json({error: 'Invalid permissions payload', details: validationErrors});
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const sanitizedPermissions = sanitizePermissionPayload(req.body || {});
|
|
||||||
const permissions = await updateGuildPermissions({
|
const permissions = await updateGuildPermissions({
|
||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
permissions: sanitizedPermissions,
|
permissions: req.body || {},
|
||||||
});
|
});
|
||||||
return res.json({permissions});
|
return res.json({permissions});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to update permissions', {message: error.message});
|
|
||||||
return res.status(500).json({error: 'Failed to update permissions'});
|
return res.status(500).json({error: 'Failed to update permissions'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -444,10 +393,6 @@ const createDashboardApp = () => {
|
|||||||
const roles = await fetchDiscordGuildRoles(req.params.guildId);
|
const roles = await fetchDiscordGuildRoles(req.params.guildId);
|
||||||
return res.json({roles});
|
return res.json({roles});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to fetch roles', {
|
|
||||||
message: error.message,
|
|
||||||
status: error.response?.status,
|
|
||||||
});
|
|
||||||
return res.status(500).json({error: 'Failed to fetch roles'});
|
return res.status(500).json({error: 'Failed to fetch roles'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -465,7 +410,6 @@ const createDashboardApp = () => {
|
|||||||
});
|
});
|
||||||
return res.json({warnings});
|
return res.json({warnings});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to fetch warnings', {message: error.message});
|
|
||||||
return res.status(500).json({error: 'Failed to fetch warnings'});
|
return res.status(500).json({error: 'Failed to fetch warnings'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -477,20 +421,18 @@ const createDashboardApp = () => {
|
|||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const {reason} = req.body || {};
|
const {reason} = req.body || {};
|
||||||
const reasonError = validateWarningReason(reason);
|
if (!reason) {
|
||||||
if (reasonError) {
|
return res.status(400).json({error: 'Reason is required'});
|
||||||
return res.status(400).json({error: reasonError});
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await addWarning({
|
const result = await addWarning({
|
||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
moderatorId: req.session.user?.id,
|
moderatorId: req.session.user?.id,
|
||||||
reason: reason.trim(),
|
reason,
|
||||||
});
|
});
|
||||||
return res.status(201).json(result);
|
return res.status(201).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to add warning', {message: error.message});
|
|
||||||
return res.status(500).json({error: 'Failed to add warning'});
|
return res.status(500).json({error: 'Failed to add warning'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const {MongoClient, ServerApiVersion} = require('mongodb');
|
const {MongoClient, ServerApiVersion} = require('mongodb');
|
||||||
|
const {mongodb} = require("./config.json");
|
||||||
const loadConfig = () => {
|
|
||||||
const configPath = path.join(__dirname, 'config.json');
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
return require(configPath);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = loadConfig();
|
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || config.mongodb;
|
|
||||||
|
|
||||||
let dbClient;
|
let dbClient;
|
||||||
|
|
||||||
async function connectToDatabase() {
|
async function connectToDatabase() {
|
||||||
if (!dbClient) {
|
if (!dbClient) {
|
||||||
if (!MONGODB_URI) {
|
dbClient = new MongoClient(mongodb, {
|
||||||
throw new Error('Missing MongoDB connection string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
dbClient = new MongoClient(MONGODB_URI, {
|
|
||||||
serverApi: {
|
serverApi: {
|
||||||
version: ServerApiVersion.v1,
|
version: ServerApiVersion.v1,
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|||||||
+1
-7
@@ -3,8 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
"dashboard": "node dashboardServer.js"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -13,13 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"connect-mongo": "^5.1.0",
|
|
||||||
"cookie-parser": "^1.4.6",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"discord.js": "^14.17.3",
|
"discord.js": "^14.17.3",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.4.0",
|
|
||||||
"express-session": "^1.18.1",
|
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mongodb": "^6.12.0",
|
"mongodb": "^6.12.0",
|
||||||
"openai": "^4.67.3",
|
"openai": "^4.67.3",
|
||||||
|
|||||||
+1
-41
@@ -14,43 +14,6 @@ const normalizePermissionConfig = (config = {}) => ({
|
|||||||
commandPermissions: config.commandPermissions ?? {},
|
commandPermissions: config.commandPermissions ?? {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sanitizePermissionPayload = (config = {}) => {
|
|
||||||
const adminRoleIds = Array.isArray(config.adminRoleIds)
|
|
||||||
? config.adminRoleIds.filter((entry) => typeof entry === 'string' && entry.trim())
|
|
||||||
: [];
|
|
||||||
const adminUserIds = Array.isArray(config.adminUserIds)
|
|
||||||
? config.adminUserIds.filter((entry) => typeof entry === 'string' && entry.trim())
|
|
||||||
: [];
|
|
||||||
const commandPermissions = {};
|
|
||||||
|
|
||||||
if (config.commandPermissions && typeof config.commandPermissions === 'object') {
|
|
||||||
Object.entries(config.commandPermissions).forEach(([commandName, entry]) => {
|
|
||||||
if (!commandName || typeof commandName !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!entry || typeof entry !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allowedRoleIds = Array.isArray(entry.allowedRoleIds)
|
|
||||||
? entry.allowedRoleIds.filter((roleId) => typeof roleId === 'string' && roleId.trim())
|
|
||||||
: [];
|
|
||||||
const allowedUserIds = Array.isArray(entry.allowedUserIds)
|
|
||||||
? entry.allowedUserIds.filter((userId) => typeof userId === 'string' && userId.trim())
|
|
||||||
: [];
|
|
||||||
commandPermissions[commandName] = {
|
|
||||||
allowedRoleIds,
|
|
||||||
allowedUserIds,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adminRoleIds,
|
|
||||||
adminUserIds,
|
|
||||||
commandPermissions,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const memberHasRole = (member, roleIds) =>
|
const memberHasRole = (member, roleIds) =>
|
||||||
roleIds.some((roleId) => member.roles?.cache?.has(roleId));
|
roleIds.some((roleId) => member.roles?.cache?.has(roleId));
|
||||||
|
|
||||||
@@ -72,8 +35,7 @@ const updateGuildPermissions = async ({ guildId, permissions }) => {
|
|||||||
const db = dbClient.db('discord');
|
const db = dbClient.db('discord');
|
||||||
const collection = db.collection('guild_permissions');
|
const collection = db.collection('guild_permissions');
|
||||||
|
|
||||||
const sanitizedPermissions = sanitizePermissionPayload(permissions);
|
const normalizedPermissions = normalizePermissionConfig(permissions);
|
||||||
const normalizedPermissions = normalizePermissionConfig(sanitizedPermissions);
|
|
||||||
const payload = {
|
const payload = {
|
||||||
guildId,
|
guildId,
|
||||||
...normalizedPermissions,
|
...normalizedPermissions,
|
||||||
@@ -130,6 +92,4 @@ module.exports = {
|
|||||||
getGuildPermissions,
|
getGuildPermissions,
|
||||||
updateGuildPermissions,
|
updateGuildPermissions,
|
||||||
isCommandAllowed,
|
isCommandAllowed,
|
||||||
normalizePermissionConfig,
|
|
||||||
sanitizePermissionPayload,
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user