Team-Bereich aufgehuebscht: Avatare, Rechte-Chips, Protokoll mit relativer Zeit
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:
+146
-62
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user