feat: mehrere Rollen duerfen herein, nicht nur eine

Wer EINE der eingetragenen Rollen hat, kommt ins Dashboard. Mehrere sind der
Normalfall, sobald mehr als eine Person Zugang haben soll -- Admin und Fotograf
sind selten dieselbe Rolle.

Die bisherige einzelne Rolle geht NICHT verloren: der alte Schluessel
(discord.role_id) wird weitergelesen, bis die Liste einmal gepflegt wird; dann
raeumt das Speichern ihn weg. Zwei Quellen fuer dieselbe Frage laufen sonst
auseinander. Nachgemessen: die vorhandene Rolle stand nach dem Umbau
unveraendert drin, bereit blieb true.

Getrennt wird an Komma, Semikolon, Leerzeichen und Zeilenumbruch -- wer drei
IDs untereinander aus Discord kopiert, soll sie nicht erst zu einer Zeile
zusammenschieben. Doppelte fallen zusammen.

UND DIE SCHLECHTE WIRD BEIM NAMEN GENANNT: "123 ist keine Rollen-ID (17 bis 20
Ziffern)" statt "Rollen-ID ungueltig". Bei fuenf Eintraegen ist die zweite
Meldung eine Suchaufgabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:53:28 +02:00
co-authored by Claude Opus 5
parent 1c5bf20eb0
commit 23405d68d3
5 changed files with 93 additions and 30 deletions
+32 -8
View File
@@ -597,7 +597,7 @@ dashRoutes.get('/settings', (c) => {
discord: { discord: {
clientId: d.clientId, clientId: d.clientId,
guildId: d.guildId, guildId: d.guildId,
roleId: d.roleId, roleIds: d.roleIds,
hatGeheimnis: Boolean(d.clientSecret), hatGeheimnis: Boolean(d.clientSecret),
bereit: d.bereit, bereit: d.bereit,
// Die Stelle, an der der erste Anlauf fast immer scheitert. // Die Stelle, an der der erste Anlauf fast immer scheitert.
@@ -607,7 +607,10 @@ dashRoutes.get('/settings', (c) => {
clientSecret: clientSecret:
Boolean(d.clientSecret) && !ausDatenbank(DISCORD_KEYS.clientSecret), Boolean(d.clientSecret) && !ausDatenbank(DISCORD_KEYS.clientSecret),
guildId: Boolean(d.guildId) && !ausDatenbank(DISCORD_KEYS.guildId), guildId: Boolean(d.guildId) && !ausDatenbank(DISCORD_KEYS.guildId),
roleId: Boolean(d.roleId) && !ausDatenbank(DISCORD_KEYS.roleId), roleIds:
d.roleIds.length > 0 &&
!ausDatenbank(DISCORD_KEYS.roleIds) &&
!ausDatenbank(DISCORD_KEYS.roleIdAlt),
}, },
}, },
}) })
@@ -619,7 +622,7 @@ dashRoutes.post('/settings', async (c) => {
clientId?: string clientId?: string
clientSecret?: string clientSecret?: string
guildId?: string guildId?: string
roleId?: string roleIds?: string | string[]
} }
}>(c) }>(c)
if (!body) return c.json(KEIN_JSON, 400) if (!body) return c.json(KEIN_JSON, 400)
@@ -642,10 +645,31 @@ dashRoutes.post('/settings', async (c) => {
if (v && !istId(v)) return c.json({ error: 'Server-ID: 17 bis 20 Ziffern' }, 400) if (v && !istId(v)) return c.json({ error: 'Server-ID: 17 bis 20 Ziffern' }, 400)
setSetting(DISCORD_KEYS.guildId, v) setSetting(DISCORD_KEYS.guildId, v)
} }
if (d.roleId !== undefined) { if (d.roleIds !== undefined) {
const v = d.roleId.trim() // Komma, Semikolon, Leerzeichen, Zeilenumbruch — alles trennt. Wer
if (v && !istId(v)) return c.json({ error: 'Rollen-ID: 17 bis 20 Ziffern' }, 400) // drei IDs untereinander einfuegt, soll sie nicht erst zu einer Zeile
setSetting(DISCORD_KEYS.roleId, v) // zusammenschieben muessen.
const liste = (Array.isArray(d.roleIds) ? d.roleIds.join(',') : d.roleIds)
.split(/[\s,;]+/)
.map((t) => t.trim())
.filter(Boolean)
// JEDE einzeln pruefen und die schlechte BEIM NAMEN nennen. "Rollen-ID
// ungueltig" bei fuenf Eintraegen ist eine Suchaufgabe, keine Meldung.
for (const eintrag of liste) {
if (!istId(eintrag)) {
return c.json(
{ error: `"${eintrag}" ist keine Rollen-ID (17 bis 20 Ziffern)` },
400,
)
}
}
const eindeutig = [...new Set(liste)]
setSetting(DISCORD_KEYS.roleIds, eindeutig.join(','))
// Den alten Einzelwert raeumen wir weg, sobald die Liste gepflegt
// wird: zwei Quellen fuer dieselbe Frage laufen sonst auseinander.
setSetting(DISCORD_KEYS.roleIdAlt, '')
} }
// Ein leeres Feld heisst "unveraendert lassen", nicht "loeschen" — sonst // Ein leeres Feld heisst "unveraendert lassen", nicht "loeschen" — sonst
// wuerde jedes Speichern das Geheimnis wegwerfen, weil es nie im Formular // wuerde jedes Speichern das Geheimnis wegwerfen, weil es nie im Formular
@@ -664,7 +688,7 @@ dashRoutes.post('/settings', async (c) => {
!jetzt.clientId && 'Client-ID', !jetzt.clientId && 'Client-ID',
!jetzt.clientSecret && 'Client-Geheimnis', !jetzt.clientSecret && 'Client-Geheimnis',
!jetzt.guildId && 'Server-ID', !jetzt.guildId && 'Server-ID',
!jetzt.roleId && 'Rollen-ID', jetzt.roleIds.length === 0 && 'mindestens eine Rollen-ID',
].filter(Boolean), ].filter(Boolean),
}) })
}) })
+9 -4
View File
@@ -144,10 +144,15 @@ discordRoutes.get('/discord/callback', async (c) => {
} }
const member = (await memberAntwort.json()) as { roles?: string[] } const member = (await memberAntwort.json()) as { roles?: string[] }
if (!Array.isArray(member.roles) || !member.roles.includes(d.roleId)) { // EINE der eingetragenen Rollen reicht. Mehrere Rollen sind der
// Bewusst ohne den Namen der Rolle: wer sie nicht hat, muss auch // Normalfall, sobald mehr als eine Person Zugang haben soll — Admin
// nicht erfahren, wie sie heisst. // und Fotograf sind selten dieselbe Rolle.
return zurueck(c, 'Dir fehlt die noetige Rolle auf dem Server.') const hatRolle =
Array.isArray(member.roles) && member.roles.some((r) => d.roleIds.includes(r))
if (!hatRolle) {
// Bewusst ohne die Namen der Rollen: wer keine hat, muss auch nicht
// erfahren, welche es gaebe.
return zurueck(c, 'Dir fehlt eine der noetigen Rollen auf dem Server.')
} }
// -- Konto finden oder anlegen -------------------------------------- // -- Konto finden oder anlegen --------------------------------------
+24 -5
View File
@@ -50,31 +50,50 @@ export const DISCORD_KEYS = {
clientId: 'discord.client_id', clientId: 'discord.client_id',
clientSecret: 'discord.client_secret', clientSecret: 'discord.client_secret',
guildId: 'discord.guild_id', guildId: 'discord.guild_id',
roleId: 'discord.role_id', /** MEHRERE Rollen, mit Komma getrennt. Der alte Schluessel mit genau einer
* Rolle wird weiter gelesen (siehe rollen()) — wer schon eine eingetragen
* hatte, soll sie nicht verlieren, nur weil hier eine Liste daraus wurde. */
roleIds: 'discord.role_ids',
roleIdAlt: 'discord.role_id',
} as const } as const
export type DiscordConfig = { export type DiscordConfig = {
clientId: string clientId: string
clientSecret: string clientSecret: string
guildId: string guildId: string
roleId: string /** Wer EINE davon hat, darf herein. Leere Liste heisst: nicht eingerichtet. */
roleIds: string[]
/** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance. /** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance.
* Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist * Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist
* schlimmer als ein Knopf, den es nicht gibt. */ * schlimmer als ein Knopf, den es nicht gibt. */
bereit: boolean bereit: boolean
} }
/** Die Rollen, aus welcher Quelle sie auch kommen.
*
* Reihenfolge: die neue Liste, dann der alte Einzelwert, dann die Umgebung.
* Getrennt wird an Komma, Semikolon, Leerzeichen und Zeilenumbruch — wer eine
* Liste aus Discord kopiert, soll sie nicht erst von Hand aufraeumen. */
export function rollen(): string[] {
const roh =
getSetting(DISCORD_KEYS.roleIds) ||
getSetting(DISCORD_KEYS.roleIdAlt) ||
(process.env.DISCORD_ROLE_IDS ?? process.env.DISCORD_ROLE_ID ?? '')
return [...new Set(roh.split(/[\s,;]+/).map((t) => t.trim()).filter(Boolean))]
}
export function discordConfig(): DiscordConfig { export function discordConfig(): DiscordConfig {
const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID') const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID')
const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET') const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET')
const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID') const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID')
const roleId = getSetting(DISCORD_KEYS.roleId, 'DISCORD_ROLE_ID') const roleIds = rollen()
return { return {
clientId, clientId,
clientSecret, clientSecret,
guildId, guildId,
roleId, roleIds,
bereit: Boolean(clientId && clientSecret && guildId && roleId), bereit: Boolean(clientId && clientSecret && guildId && roleIds.length > 0),
} }
} }
+3 -2
View File
@@ -154,7 +154,8 @@ export type Einstellungen = {
discord: { discord: {
clientId: string clientId: string
guildId: string guildId: string
roleId: string /** Wer EINE davon hat, darf herein. */
roleIds: string[]
/** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */ /** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */
hatGeheimnis: boolean hatGeheimnis: boolean
bereit: boolean bereit: boolean
@@ -163,7 +164,7 @@ export type Einstellungen = {
clientId: boolean clientId: boolean
clientSecret: boolean clientSecret: boolean
guildId: boolean guildId: boolean
roleId: boolean roleIds: boolean
} }
} }
} }
+24 -10
View File
@@ -19,7 +19,7 @@ export function DiscordKarte() {
const [clientId, setClientId] = useState('') const [clientId, setClientId] = useState('')
const [geheimnis, setGeheimnis] = useState('') const [geheimnis, setGeheimnis] = useState('')
const [guildId, setGuildId] = useState('') const [guildId, setGuildId] = useState('')
const [roleId, setRoleId] = useState('') const [rollen, setRollen] = useState('')
async function laden() { async function laden() {
setLaedt(true) setLaedt(true)
@@ -28,7 +28,7 @@ export function DiscordKarte() {
setDaten(e) setDaten(e)
setClientId(e.discord.clientId) setClientId(e.discord.clientId)
setGuildId(e.discord.guildId) setGuildId(e.discord.guildId)
setRoleId(e.discord.roleId) setRollen(e.discord.roleIds.join('\n'))
} catch (err) { } catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt') note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally { } finally {
@@ -51,7 +51,7 @@ export function DiscordKarte() {
// das Geheimnis wegwerfen, weil es nie im Formular steht. // das Geheimnis wegwerfen, weil es nie im Formular steht.
...(geheimnis ? { clientSecret: geheimnis } : {}), ...(geheimnis ? { clientSecret: geheimnis } : {}),
guildId, guildId,
roleId, roleIds: rollen,
}), }),
(r) => (r) =>
r.bereit r.bereit
@@ -84,7 +84,8 @@ export function DiscordKarte() {
rechts={ rechts={
d.bereit ? ( d.bereit ? (
<span className="flex items-center gap-1.5 text-xs text-gut"> <span className="flex items-center gap-1.5 text-xs text-gut">
<ShieldCheck size={14} /> eingerichtet <ShieldCheck size={14} /> eingerichtet ·{' '}
{d.roleIds.length === 1 ? '1 Rolle' : `${d.roleIds.length} Rollen`}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise"> <span className="flex items-center gap-1.5 text-xs text-schrift-leise">
@@ -156,13 +157,26 @@ export function DiscordKarte() {
hinweis={d.ausDerUmgebung.guildId ? 'kommt derzeit aus der Umgebung' : undefined} hinweis={d.ausDerUmgebung.guildId ? 'kommt derzeit aus der Umgebung' : undefined}
onChange={(e) => setGuildId(e.target.value)} onChange={(e) => setGuildId(e.target.value)}
/> />
<Feld <label className="block sm:col-span-2">
label="Rollen-ID" <span className="mb-1 block text-xs font-medium text-schrift-leise">
value={roleId} Rollen-IDs eine je Zeile
placeholder="Wer diese Rolle hat, darf herein" </span>
hinweis={d.ausDerUmgebung.roleId ? 'kommt derzeit aus der Umgebung' : undefined} <textarea
onChange={(e) => setRoleId(e.target.value)} value={rollen}
onChange={(e) => setRollen(e.target.value)}
rows={3}
spellCheck={false}
placeholder={'143902xxxxxxxxxxxxx\n143903xxxxxxxxxxxxx'}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 font-mono
text-sm text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/> />
<span className="mt-1 block text-xs text-schrift-leise">
{d.ausDerUmgebung.roleIds
? 'kommt derzeit aus der Umgebung'
: 'Wer EINE davon hat, darf herein. Komma, Semikolon oder Zeilenumbruch trennen — kopier sie einfach hinein.'}
</span>
</label>
</div> </div>
<p className="text-xs text-schrift-leise"> <p className="text-xs text-schrift-leise">