diff --git a/README.md b/README.md index e5c28d7..45e74ad 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite | | 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | | 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | -| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence | +| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Copy-Paste-Adresse); Typen FiveM + HTTP-Check; Spielerzahl in der Presence | +| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only | | 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf | | 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite | @@ -69,7 +70,8 @@ Share Tech Mono, selbst gehostet). ### Setup-Seite (`/settings`) Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Brand · Feeds · Community · -Rollen · Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in +Rollen · Support · Bewerbungen · Composer · Server · System · API · Team) — +Team-Mitglieder sehen nur die Tabs ihrer Bereiche. Alles zur Laufzeit änderbar — gespeichert in SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig: - **Allgemein:** Öffentliche URL, Gitea-URL diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3206094..d1d625f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { Events Changelog {me.admin && Commits} - {me.admin && Setup} + {(me.admin || me.scopes?.length > 0) && Setup}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index af480ec..7536a6c 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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}

Lade …

; - 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 }) { setBotName(e.target.value)} />
+
+ +