Team-Bereich aufgehuebscht: Avatare, Rechte-Chips, Protokoll mit relativer Zeit
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Der Team-Reiter war funktional, aber roh: Rechte standen als "content,community"
da, es gab keine Gesichter, und das Protokoll war eine Wand aus Zeitstempeln.

- /api/webadmins und /api/auditlog liefern jetzt Avatar und Anzeigename aus dem
  Member-Cache — kostet keinen zusaetzlichen Discord-Aufruf
- Owner-Zeile mit eigenem Chip "Alle Bereiche", damit klar ist, wer alles darf
- Bereiche als lesbare Chips statt roher IDs; die Auswahl beim Anlegen sind
  klickbare Karten mit Beschreibung
- Protokoll zeigt relative Zeit ("vor 37 Min"), erst 12 Zeilen, Rest auf Klick
- Toenungen ueber color-mix aus --neon: stimmt im Hub (gold) wie auf der
  Bot-Seite (orange)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:25:51 +02:00
co-authored by Claude Fable 5
parent c026b2a603
commit 570e45fe60
3 changed files with 286 additions and 68 deletions
+146 -62
View File
@@ -96,6 +96,26 @@ const TAB_SCOPES = {
team: null,
};
/** „vor 3 Minuten" statt rohem Zeitstempel */
function vorWieLange(iso) {
// Die Datenbank speichert UTC ohne Kennzeichnung — sonst rechnet der
// Browser sie als Ortszeit und alles sieht Stunden alt aus.
const min = Math.floor((Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()) / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min`;
const std = Math.floor(min / 60);
if (std < 24) return `vor ${std} Std`;
const tage = Math.floor(std / 24);
return tage === 1 ? 'gestern' : `vor ${tage} Tagen`;
}
/** Avatar mit Anfangsbuchstaben als Rückfallebene.
* Bewusst außerhalb der Komponente: als Inline-Komponente wäre der Typ bei
* jedem Rendern neu und React würde die Bilder jedes Mal neu einhängen. */
const Avatar = ({ src, name, klein }) => (src
? <img className={`tm-avatar${klein ? ' klein' : ''}`} src={src} alt="" loading="lazy" />
: <span className={`tm-avatar${klein ? ' klein' : ''} ohne`}>{(name ?? '?').slice(0, 1).toUpperCase()}</span>);
const TEAM_SCOPE_LABELS = {
content: 'Composer, Tags, Triggers & geplante Posts',
community: 'Playtester & Alpha-Keys',
@@ -302,6 +322,8 @@ export default function Settings({ me }) {
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
const [owner, setOwner] = useState(null);
const [auditAlle, setAuditAlle] = useState(false);
// Module (An/Aus für alle Funktionen)
const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]);
@@ -420,7 +442,9 @@ export default function Settings({ me }) {
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
apiGet('/api/webadmins')
.then((d) => { setTeam(d.admins); setOwner(d.owner); })
.catch(() => {});
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
@@ -2791,77 +2815,137 @@ export default function Settings({ me }) {
</div>
);
const auditSichtbar = auditAlle ? audit : audit.slice(0, 12);
const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id);
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.
Team-Mitglieder loggen sich ganz normal mit Discord ein und sehen nur ihre
Bereiche. Brand, System, API-Keys und diese 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 className="tm-list">
{owner && (
<div className="tm-row owner">
<Avatar src={owner.avatar} name={owner.name ?? me.user?.username} />
<div className="tm-main">
<span className="tm-name">{owner.name ?? me.user?.username ?? 'Du'}</span>
<span className="tm-scopes"><span className="tm-chip alle">Alle Bereiche</span></span>
</div>
<span className="tm-role">Owner</span>
</div>
)}
{team.map((t) => (
<div className={`tm-row${t.user_id === teamDraft.user_id ? ' aktiv' : ''}`} key={t.user_id}>
<Avatar src={t.avatar} name={t.name ?? t.username} />
<div className="tm-main">
<span className="tm-name">{t.name ?? t.username ?? t.user_id}</span>
<span className="tm-scopes">
{t.scopes.split(',').filter(Boolean).map((sc) => (
<span className="tm-chip" key={sc}>{TEAM_SCOPE_LABELS[sc] ?? sc}</span>
))}
</span>
</div>
<button
className="btn btn-mini"
onClick={() => setTeamDraft({
user_id: t.user_id,
scopes: t.scopes.split(',').filter(Boolean),
})}
>
Rechte
</button>
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}></button>
</div>
))}
{team.length === 0 && (
<div className="tm-row leer">Außer dir hat noch niemand Zugriff.</div>
)}
</div>
<h3 className="tm-form-title">
{teamBearbeitet ? 'Rechte ändern' : 'Jemanden hinzufügen'}
</h3>
<div className="field">
<label>
Discord-User-ID
<span className="field-hint">Rechtsklick auf den User Benutzer-ID kopieren"</span>
</label>
<input
type="text" placeholder="z. B. 123456789012345678"
value={teamDraft.user_id}
onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })}
/>
</div>
<div className="field">
<label>Bereiche <span className="field-hint">mindestens einer</span></label>
<div className="tm-scopes-pick">
{Object.entries(TEAM_SCOPE_LABELS).map(([sid, label]) => {
const an = teamDraft.scopes.includes(sid);
return (
<button
type="button" key={sid}
className={`tm-scope${an ? ' on' : ''}`}
onClick={() => setTeamDraft({
...teamDraft,
scopes: an
? teamDraft.scopes.filter((x) => x !== sid)
: [...teamDraft.scopes, sid],
})}
>
<span className="tm-scope-mark">{an ? '✓' : ''}</span>
<span className="tm-scope-text">
<span className="tm-scope-id">{sid}</span>
<span className="tm-scope-desc">{label}</span>
</span>
</button>
);
})}
</div>
</div>
<div className="settings-actions">
<button className="btn btn-save" onClick={saveTeam}>
{teamBearbeitet ? 'Rechte speichern' : 'Hinzufügen'}
</button>
{(teamDraft.user_id || teamDraft.scopes.length > 0) && (
<button className="btn btn-ghost" onClick={() => setTeamDraft({ user_id: '', scopes: [] })}>
Abbrechen
</button>
)}
</div>
</div>
{audit.length > 0 && (
<div className="settings-section">
<h2 className="settings-title">// Protokoll</h2>
<p className="section-intro">
Wer hat was im Webinterface geändert.
</p>
<div className="tm-list">
{auditSichtbar.map((a) => (
<div className="tm-row audit" key={a.id}>
<Avatar src={a.avatar} name={a.username} klein />
<span className="tm-audit-name">{a.username}</span>
<span className="tm-audit-action">{a.action}</span>
<span className="tm-audit-detail" title={a.detail}>{a.detail}</span>
<span className="tm-audit-time" title={a.ts.replace('T', ' ')}>{vorWieLange(a.ts)}</span>
</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>
{audit.length > 0 && (
<>
<h2 className="settings-title" style={{ marginTop: '2rem' }}>// Audit-Log</h2>
<p className="field-hint" style={{ marginBottom: '.8rem' }}>
Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
</p>
<div className="key-list audit-list">
{audit.map((a) => (
<div className="key-row" key={a.id}>
<span className="audit-ts">{a.ts.slice(0, 16).replace('T', ' ')}</span>
<span className="key-name">{a.username}</span>
<span className="audit-action">{a.action}</span>
<span className="key-used audit-detail" title={a.detail}>{a.detail}</span>
</div>
))}
{audit.length > 12 && (
<div className="settings-actions" style={{ marginTop: '.8rem' }}>
<button className="btn btn-ghost" onClick={() => setAuditAlle(!auditAlle)}>
{auditAlle ? 'Weniger zeigen' : `Alle ${audit.length} zeigen`}
</button>
</div>
</>
)}
</div>
)}
</div>
)}
</>
);
const content = {