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 = {
+117 -4
View File
@@ -1736,10 +1736,123 @@ label.toggle {
.srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; }
/* ── AUDIT-LOG ─────────────────────────────────────── */
.audit-list { max-height: 24rem; overflow-y: auto; }
.audit-ts { font-family: var(--mono); font-size: .65rem; color: var(--muted2); white-space: nowrap; }
.audit-action { font-family: var(--mono); font-size: .7rem; color: var(--neon); white-space: nowrap; }
.audit-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 18rem; }
/* ── TEAM & PROTOKOLL ──────────────────────────────── */
.tm-list {
display: flex; flex-direction: column;
border: 1px solid rgba(255, 255, 255, .07);
border-radius: 12px; overflow: hidden;
margin-bottom: 1.6rem;
}
.tm-row {
display: flex; align-items: center; gap: .9rem;
padding: .7rem .9rem;
border-bottom: 1px solid rgba(255, 255, 255, .05);
font-size: .9rem;
transition: background .15s ease;
}
.tm-row:last-child { border-bottom: none; }
.tm-row:hover { background: rgba(255, 255, 255, .02); }
/* Tönungen aus der Leitfarbe mischen — die ist auf der Bot-Seite orange,
im Hub gold, und beides soll stimmen. */
.tm-row.owner { background: color-mix(in srgb, var(--neon) 5%, transparent); }
.tm-row.aktiv {
background: color-mix(in srgb, var(--neon) 9%, transparent);
box-shadow: inset 3px 0 0 var(--neon);
}
.tm-row.leer {
justify-content: center; color: var(--muted2);
font-family: var(--mono); font-size: .72rem; letter-spacing: .1em;
padding: 1.4rem;
}
.tm-avatar {
width: 34px; height: 34px; border-radius: 50%;
flex: 0 0 auto; object-fit: cover;
border: 1px solid rgba(255, 255, 255, .12);
}
.tm-avatar.klein { width: 24px; height: 24px; }
.tm-avatar.ohne {
display: grid; place-items: center;
background: rgba(255, 255, 255, .05);
color: var(--muted); font-weight: 700; font-size: .85rem;
}
.tm-avatar.klein.ohne { font-size: .65rem; }
.tm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .3rem; }
.tm-name { font-weight: 600; }
.tm-scopes { display: flex; flex-wrap: wrap; gap: .3rem; }
.tm-chip {
font-family: var(--mono); font-size: .6rem; letter-spacing: .08em;
padding: .18rem .45rem; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, .12);
color: var(--muted); white-space: nowrap;
}
.tm-chip.alle {
border-color: color-mix(in srgb, var(--neon) 40%, transparent);
color: var(--neon);
}
.tm-role {
font-family: var(--mono); font-size: .62rem; letter-spacing: .14em;
color: var(--neon); text-transform: uppercase;
}
.tm-form-title {
font-family: var(--mono); font-size: .75rem; letter-spacing: .16em;
text-transform: uppercase; color: var(--muted);
margin: 0 0 1rem;
}
.tm-scopes-pick {
display: grid; gap: .5rem;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
.tm-scope {
display: flex; align-items: center; gap: .6rem; text-align: left;
padding: .6rem .7rem;
background: rgba(0, 0, 0, .25);
border: 1px solid rgba(255, 255, 255, .09);
border-radius: 10px; color: var(--muted);
cursor: pointer; transition: all .15s ease;
}
.tm-scope:hover { border-color: rgba(255, 255, 255, .22); color: var(--text); }
.tm-scope.on {
border-color: var(--neon);
background: color-mix(in srgb, var(--neon) 8%, transparent);
color: var(--text);
}
.tm-scope-mark {
width: 18px; height: 18px; flex: 0 0 auto;
display: grid; place-items: center;
border: 1px solid rgba(255, 255, 255, .2); border-radius: 5px;
font-size: .7rem; color: #0a0a0a;
}
.tm-scope.on .tm-scope-mark { background: var(--neon); border-color: var(--neon); }
.tm-scope-text { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.tm-scope-id {
font-family: var(--mono); font-size: .68rem; letter-spacing: .1em;
color: inherit;
}
.tm-scope-desc { font-size: .72rem; color: var(--muted2); }
.tm-row.audit { font-size: .82rem; padding: .5rem .9rem; }
.tm-audit-name { font-weight: 600; min-width: 7rem; }
.tm-audit-action {
font-family: var(--mono); font-size: .68rem; letter-spacing: .06em;
color: var(--neon); white-space: nowrap; min-width: 9rem;
}
.tm-audit-detail {
flex: 1; min-width: 0; color: var(--muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tm-audit-time {
font-family: var(--mono); font-size: .62rem;
color: var(--muted2); white-space: nowrap;
}
@media (max-width: 700px) {
.tm-audit-action { min-width: 0; }
.tm-audit-detail { display: none; }
}
/* ── STARTSEITE ────────────────────────────────────── */
.home-hero { min-height: 420px; }
+23 -2
View File
@@ -1688,7 +1688,21 @@ ${rssItems}
app.get('/api/webadmins', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { admins: listWebAdmins(), scopes: TEAM_SCOPES };
// Avatare aus dem Member-Cache — kostet keinen Discord-Aufruf,
// dieselbe Quelle wie bei der Bestenliste
const bild = (id) => {
for (const guild of client.guilds.cache.values()) {
const m = guild.members?.cache?.get(id);
if (m) return { avatar: m.displayAvatarURL?.({ size: 64 }) ?? null, name: m.displayName };
}
return { avatar: null, name: null };
};
const owner = bild(config.adminDiscordId);
return {
admins: listWebAdmins().map((a) => ({ ...a, ...bild(a.user_id) })),
scopes: TEAM_SCOPES,
owner: { user_id: config.adminDiscordId, ...owner },
};
});
app.put('/api/webadmins', async (request, reply) => {
@@ -1724,7 +1738,14 @@ ${rssItems}
// Audit-Log: die letzten Team-/Owner-Aktionen im Webinterface (nur Owner)
app.get('/api/auditlog', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { entries: listAudit(100) };
const bild = (id) => {
for (const guild of client.guilds.cache.values()) {
const m = guild.members?.cache?.get(id);
if (m) return m.displayAvatarURL?.({ size: 48 }) ?? null;
}
return null;
};
return { entries: listAudit(100).map((e) => ({ ...e, avatar: bild(e.user_id) })) };
});
// --- SSO-Apps (nur Owner): Fremd-Dienste, die den Discord-Login mitnutzen ---