Add dashboard UI and secure API endpoints

This commit is contained in:
2026-01-27 15:20:10 +01:00
parent 41949fe596
commit 5905b16d50
12 changed files with 1193 additions and 181 deletions
+12
View File
@@ -0,0 +1,12 @@
<!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>
+19
View File
@@ -0,0 +1,19 @@
{
"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"
}
}
+576
View File
@@ -0,0 +1,576 @@
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;
+11
View File
@@ -0,0 +1,11 @@
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>
);
+265
View File
@@ -0,0 +1,265 @@
: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;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});