Server-Tab (DiscordGSM-Stil), Team-Rechte und Bot-Profil-Beschreibung
- Game-Server: eigene Tabelle statt CSV-Setting (automatische Migration), eigener Setup-Tab mit Server-Builder (Name, Typ fivem/http, Query-URL, Anzeige-Adresse); pro Server ein Live-Embed (gruen/rot, Spieler-Balken, Map, Adresse als Code-Block), edit in place; Embed wird beim Server-Loeschen mit entfernt - Team-System: web_admins mit Bereichs-Scopes (content/community/rollen/bewerbungen/ server/settings/devlogs); Owner behaelt Brand/System/API-Keys/Team exklusiv; 32 Routen-Guards auf Scopes umgestellt, sensible PUT-Felder fuer Team gestrippt; Team-Tab (Owner) + Tab-Filterung im Frontend, /api/me liefert Scopes - Bot-Karte: Profil-Beschreibung (Ueber mich) via application.edit - Fix: settings-Tabelle wird vor der Gameserver-Migration angelegt (frische DBs crashten sonst beim Start) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,13 +48,13 @@ function Particles() {
|
||||
|
||||
export default function App() {
|
||||
// Login-Status einmal beim Laden holen
|
||||
const [me, setMe] = useState({ user: null, admin: false, loading: true });
|
||||
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/me')
|
||||
.then((data) => setMe({ ...data, loading: false }))
|
||||
.catch(() => setMe({ user: null, admin: false, loading: false }));
|
||||
.then((data) => setMe({ scopes: [], ...data, loading: false }))
|
||||
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,7 +80,7 @@ export default function App() {
|
||||
<NavLink to="/events">Events</NavLink>
|
||||
<NavLink to="/changelog">Changelog</NavLink>
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Setup</NavLink>}
|
||||
</nav>
|
||||
|
||||
<div className="auth">
|
||||
|
||||
+219
-19
@@ -17,10 +17,38 @@ const TABS = [
|
||||
{ id: 'support', icon: '📬', label: 'Support' },
|
||||
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
|
||||
{ id: 'composer', icon: '📝', label: 'Composer' },
|
||||
{ id: 'server', icon: '🎮', label: 'Server' },
|
||||
{ id: 'system', icon: '⚙️', label: 'System' },
|
||||
{ id: 'api', icon: '🔑', label: 'API' },
|
||||
{ id: 'team', icon: '👥', label: 'Team' },
|
||||
];
|
||||
|
||||
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
||||
const TAB_SCOPES = {
|
||||
status: 'any',
|
||||
brand: null,
|
||||
feeds: 'settings',
|
||||
community: 'community',
|
||||
rollen: 'rollen',
|
||||
support: 'settings',
|
||||
bewerbungen: 'bewerbungen',
|
||||
composer: 'content',
|
||||
server: 'server',
|
||||
system: null,
|
||||
api: null,
|
||||
team: null,
|
||||
};
|
||||
|
||||
const TEAM_SCOPE_LABELS = {
|
||||
content: 'Composer, Tags, Triggers & geplante Posts',
|
||||
community: 'Playtester & Alpha-Keys',
|
||||
rollen: 'Rollen-Menüs',
|
||||
bewerbungen: 'Bewerbungen',
|
||||
server: 'Game-Server',
|
||||
settings: 'Kanäle & Feed-Einstellungen',
|
||||
devlogs: 'Devlogs löschen',
|
||||
};
|
||||
|
||||
/** Sekunden → "2d 4h 13m" */
|
||||
function fmtUptime(s) {
|
||||
const d = Math.floor(s / 86400);
|
||||
@@ -68,6 +96,22 @@ export default function Settings({ me }) {
|
||||
// Triggers
|
||||
const [triggers, setTriggers] = useState([]);
|
||||
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||||
// Game-Server
|
||||
const emptyServer = { id: null, name: '', type: 'fivem', query_url: '', address: '' };
|
||||
const [servers, setServers] = useState([]);
|
||||
const [serverDraft, setServerDraft] = useState(emptyServer);
|
||||
// Bot-Beschreibung + Team
|
||||
const [botDesc, setBotDesc] = useState('');
|
||||
const [team, setTeam] = useState([]);
|
||||
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
|
||||
|
||||
const canTab = (id) => {
|
||||
if (me.admin) return true;
|
||||
const need = TAB_SCOPES[id];
|
||||
if (need === 'any') return (me.scopes?.length ?? 0) > 0;
|
||||
return need ? me.scopes?.includes(need) : false;
|
||||
};
|
||||
const visibleTabs = TABS.filter((t) => canTab(t.id));
|
||||
// Tags + geplante Posts
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||||
@@ -83,7 +127,7 @@ export default function Settings({ me }) {
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me.admin) return;
|
||||
if (!me.admin && !(me.scopes?.length > 0)) return;
|
||||
apiGet('/api/settings')
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
@@ -98,12 +142,64 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
|
||||
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||||
}, [me.admin]);
|
||||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||||
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||||
}, [me.admin, me.scopes?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.status?.botName) setBotName(data.status.botName);
|
||||
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
|
||||
}, [data]);
|
||||
|
||||
// Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
|
||||
useEffect(() => {
|
||||
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
|
||||
}, [me.admin, me.scopes]);
|
||||
|
||||
async function saveTeam() {
|
||||
if (!teamDraft.user_id.trim() || teamDraft.scopes.length === 0) {
|
||||
flash('✗ User-ID und mindestens ein Bereich nötig');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiPut('/api/webadmins', teamDraft);
|
||||
setTeam(res.admins);
|
||||
setTeamDraft({ user_id: '', scopes: [] });
|
||||
flash('✓ Team-Mitglied gespeichert');
|
||||
} catch {
|
||||
flash('✗ Fehlgeschlagen — User-ID prüfen');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTeam(userId) {
|
||||
if (!window.confirm('Team-Mitglied entfernen? Der Web-Zugriff erlischt sofort.')) return;
|
||||
const res = await apiDelete(`/api/webadmins/${userId}`).catch(() => null);
|
||||
if (res) setTeam(res.admins);
|
||||
}
|
||||
|
||||
async function saveServer() {
|
||||
if (!serverDraft.name.trim() || !serverDraft.query_url.trim()) {
|
||||
flash('✗ Name und Query-URL nötig');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (serverDraft.id) await apiPut(`/api/gameservers/${serverDraft.id}`, serverDraft);
|
||||
else await apiPost('/api/gameservers', serverDraft);
|
||||
setServers((await apiGet('/api/gameservers')).servers);
|
||||
setServerDraft(emptyServer);
|
||||
flash('✓ Server gespeichert — Embed erscheint beim nächsten Tick');
|
||||
} catch {
|
||||
flash('✗ Speichern fehlgeschlagen (URL mit http/https?)');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(id) {
|
||||
if (!window.confirm('Server entfernen? Das Status-Embed wird mit gelöscht.')) return;
|
||||
await apiDelete(`/api/gameservers/${id}`).catch(() => {});
|
||||
setServers((await apiGet('/api/gameservers')).servers);
|
||||
if (serverDraft.id === id) setServerDraft(emptyServer);
|
||||
}
|
||||
|
||||
async function uploadAlphaKeys() {
|
||||
if (!keyUpload.trim()) return;
|
||||
try {
|
||||
@@ -190,7 +286,7 @@ export default function Settings({ me }) {
|
||||
|
||||
async function saveBrand() {
|
||||
try {
|
||||
const body = { ...form, bot_name: botName };
|
||||
const body = { ...form, bot_name: botName, bot_description: botDesc };
|
||||
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
||||
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||||
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||||
@@ -377,7 +473,7 @@ export default function Settings({ me }) {
|
||||
);
|
||||
|
||||
if (me.loading) return <>{header}<section className="content"><p className="notice">Lade …</p></section></>;
|
||||
if (!me.user || !me.admin) {
|
||||
if (!me.user || (!me.admin && !(me.scopes?.length > 0))) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
@@ -448,6 +544,10 @@ export default function Settings({ me }) {
|
||||
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
||||
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Beschreibung <span className="field-hint">„Über mich" im Bot-Profil (max 400 Zeichen)</span></label>
|
||||
<textarea rows={3} maxLength={400} placeholder="z. B. Community-Bot für EcoGame — Devlogs, Level, Tickets & mehr. 🤖" value={botDesc} onChange={(e) => setBotDesc(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bot-Status</label>
|
||||
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
||||
@@ -1085,20 +1185,6 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Game-Server</h2>
|
||||
<div className="field">
|
||||
<label>Status-Kanal <span className="field-hint">persistentes Status-Embed, alle 2 min</span></label>
|
||||
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Server <span className="field-hint">kommagetrennt Name=URL — FiveM-kompatibel (/dynamic.json)</span></label>
|
||||
<input type="text" placeholder="z. B. FiveM=http://1.2.3.4:30120" value={form.gameservers} onChange={(e) => setForm({ ...form, gameservers: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||||
@@ -1216,6 +1302,118 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabServer = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Game-Server</h2>
|
||||
<p className="section-intro">
|
||||
Jeder Server bekommt sein eigenes Live-Status-Embed im Status-Kanal (🟢/🔴,
|
||||
Spieler-Balken, Map, Adresse) — aktualisiert alle 2 Minuten, DiscordGSM-Style.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label>Status-Kanal</label>
|
||||
<select value={form.status_channel_id ?? ''} onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ marginBottom: '1.4rem' }}>
|
||||
<button className="btn btn-save" onClick={save}>Kanal speichern</button>
|
||||
</div>
|
||||
|
||||
{servers.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{servers.map((s) => (
|
||||
<div className="key-row" key={s.id}>
|
||||
<span className="key-name">{s.name}</span>
|
||||
<span className="key-scopes">{s.type}</span>
|
||||
<span className="key-used" title={s.query_url}>{s.query_url}</span>
|
||||
<button className="btn btn-mini" onClick={() => setServerDraft({
|
||||
id: s.id, name: s.name, type: s.type, query_url: s.query_url, address: s.address ?? '',
|
||||
})}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Server + Embed entfernen" onClick={() => removeServer(s.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>{serverDraft.id ? `Server #${serverDraft.id} bearbeiten` : 'Neuer Server'}</label>
|
||||
<div className="field-row">
|
||||
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Name, z. B. FiveM" value={serverDraft.name} onChange={(e) => setServerDraft({ ...serverDraft, name: e.target.value })} />
|
||||
<select style={{ flex: 'none', width: '8rem' }} value={serverDraft.type} onChange={(e) => setServerDraft({ ...serverDraft, type: e.target.value })}>
|
||||
<option value="fivem">FiveM</option>
|
||||
<option value="http">HTTP-Check</option>
|
||||
</select>
|
||||
<input type="text" placeholder={serverDraft.type === 'fivem' ? 'http://ip:30120' : 'https://dienst.example/health'} value={serverDraft.query_url} onChange={(e) => setServerDraft({ ...serverDraft, query_url: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Anzeige-Adresse <span className="field-hint">optional — steht als Copy-Paste-Block im Embed (z. B. connect play.d4rkst3r.de)</span></label>
|
||||
<div className="field-row">
|
||||
<input type="text" placeholder="connect play.d4rkst3r.de" value={serverDraft.address} onChange={(e) => setServerDraft({ ...serverDraft, address: e.target.value })} />
|
||||
<button className="btn btn-save" onClick={saveServer}>{serverDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
|
||||
{serverDraft.id && <button className="btn btn-ghost" onClick={() => setServerDraft(emptyServer)}>Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const tabTeam = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Team</h2>
|
||||
<p className="section-intro">
|
||||
Gib Team-Mitgliedern gezielten Zugriff aufs Webinterface — sie loggen sich ganz
|
||||
normal mit Discord ein und sehen nur ihre Bereiche. Brand, System, API-Keys
|
||||
und diese Team-Verwaltung bleiben immer dir vorbehalten.
|
||||
</p>
|
||||
|
||||
{team.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{team.map((t) => (
|
||||
<div className="key-row" key={t.user_id}>
|
||||
<span className="key-name">{t.username ?? t.user_id}</span>
|
||||
<span className="key-scopes">{t.scopes}</span>
|
||||
<button className="btn btn-mini" onClick={() => setTeamDraft({ user_id: t.user_id, scopes: t.scopes.split(',').filter(Boolean) })}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Discord-User-ID <span className="field-hint">Rechtsklick auf den User → „Benutzer-ID kopieren"</span></label>
|
||||
<div className="field-row">
|
||||
<input type="text" placeholder="z. B. 123456789012345678" value={teamDraft.user_id} onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })} />
|
||||
<button className="btn btn-save" onClick={saveTeam}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bereiche</label>
|
||||
<div className="scope-row" style={{ flexDirection: 'column', gap: '.5rem', alignItems: 'flex-start' }}>
|
||||
{Object.entries(TEAM_SCOPE_LABELS).map(([id, label]) => (
|
||||
<label className="scope-pick" key={id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={teamDraft.scopes.includes(id)}
|
||||
onChange={(e) =>
|
||||
setTeamDraft({
|
||||
...teamDraft,
|
||||
scopes: e.target.checked
|
||||
? [...teamDraft.scopes, id]
|
||||
: teamDraft.scopes.filter((s) => s !== id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{id} — {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = {
|
||||
status: tabStatus,
|
||||
brand: tabBrand,
|
||||
@@ -1225,8 +1423,10 @@ export default function Settings({ me }) {
|
||||
support: tabSupport,
|
||||
bewerbungen: tabBewerbungen,
|
||||
composer: tabComposer,
|
||||
server: tabServer,
|
||||
system: tabSystem,
|
||||
api: tabApi,
|
||||
team: tabTeam,
|
||||
}[tab];
|
||||
|
||||
// Tabs, deren Inhalte über den zentralen Speichern-Button laufen
|
||||
@@ -1238,7 +1438,7 @@ export default function Settings({ me }) {
|
||||
<section className="content content-wide">
|
||||
<div className="settings-layout">
|
||||
<nav className="settings-nav">
|
||||
{TABS.map((t) => (
|
||||
{visibleTabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`settings-nav-item ${tab === t.id ? 'active' : ''}`}
|
||||
|
||||
Reference in New Issue
Block a user