feat: Discord-Anmeldung -- im Panel einrichtbar, nicht in der .env
Auf Wunsch ueber die Oberflaeche statt ueber Umgebungsvariablen. Der Gewinn ist
nicht nur Bequemlichkeit: das Panel legt die REDIRECT-URI zum Kopieren hin, und
genau daran scheitert ein OAuth-Anlauf fast immer.
settings.ts Schluessel/Wert in SQLite, mit Umgebung als Rueckfall. Wer
schon Stack-Variablen gesetzt hat, behaelt sie; das Panel
uebersteuert. In der Umgebung bleibt, was den Dienst ueberhaupt
erreichbar macht (PUBLIC_URL, PORT, DATA_DIR) -- wer das per
Formular verstellen koennte, sperrt sich mit einem Klick aus.
discord.ts Der Weg in drei Schritten, ohne Bibliothek und OHNE BOT-TOKEN:
die Rolle wird mit dem Merkmal des BENUTZERS geholt
(guilds.members.read). Kein Bot, keine Bot-Rechte, kein zweites
Geheimnis, das ablaufen kann.
DAS GEHEIMNIS GEHT NIE HERAUS -- auch nicht an eine angemeldete Sitzung. Das
Panel bekommt nur "hatGeheimnis: true". Ein Formular, das den Client-Schluessel
im Klartext zurueckschickt, streut ihn in jeden Browser-Cache. Ein leeres Feld
heisst "unveraendert", nicht "loeschen"; zum Entfernen gibt es "-".
JEDER ABBRUCH IST SICHTBAR: kein weisser Bildschirm, sondern zurueck aufs
Formular mit dem Grund im Adressfeld -- "Du bist nicht auf diesem Server",
"Dir fehlt die noetige Rolle", oder bei 4xx vom Tausch der Hinweis auf die
Redirect-URI. Die IDs werden schon beim Speichern geprueft (17-20 Ziffern),
damit ein halber Link nicht erst beim Anmelden auffaellt.
Das Passwort bleibt als Notausgang. Ein ueber Discord angelegtes Konto bekommt
KEIN brauchbares Passwort ("nur-ueber-discord"): verifyPassword scheitert an
allem, was nicht dem scrypt-Format entspricht -- nachgesehen, nicht vermutet.
Dazu der Verlauf, wie gewuenscht: Suche nach Pfad (mit derselben
LIKE-Maskierung wie in der Galerie -- Suche nach "_" findet 0 statt allem),
waehlbare Seitengroesse (25/50/100/200, Standard 50) und mehr Luft zwischen
"Groesse" und "Von wem".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Copy, ExternalLink, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
|
||||
import { api, type Einstellungen } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { Feld, Karte, Knopf, useKopieren } from '../components/Bausteine'
|
||||
|
||||
/** Die Discord-Anmeldung einrichten — im Panel statt in der .env.
|
||||
*
|
||||
* Der eigentliche Gewinn steht ganz oben in der Karte: die Redirect-URI zum
|
||||
* Kopieren. Sie muss ZEICHEN FÜR ZEICHEN mit dem übereinstimmen, was im
|
||||
* Discord-Portal steht, und daran scheitert der erste Anlauf fast immer. */
|
||||
export function DiscordKarte() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [daten, setDaten] = useState<Einstellungen | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const [speichert, setSpeichert] = useState(false)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [geheimnis, setGeheimnis] = useState('')
|
||||
const [guildId, setGuildId] = useState('')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
|
||||
async function laden() {
|
||||
setLaedt(true)
|
||||
try {
|
||||
const e = await api.einstellungen()
|
||||
setDaten(e)
|
||||
setClientId(e.discord.clientId)
|
||||
setGuildId(e.discord.guildId)
|
||||
setRoleId(e.discord.roleId)
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
async function speichern() {
|
||||
setSpeichert(true)
|
||||
const ergebnis = await run(
|
||||
() =>
|
||||
api.einstellungenSpeichern({
|
||||
clientId,
|
||||
// Leer heißt „nicht anfassen" — sonst würde jedes Speichern
|
||||
// das Geheimnis wegwerfen, weil es nie im Formular steht.
|
||||
...(geheimnis ? { clientSecret: geheimnis } : {}),
|
||||
guildId,
|
||||
roleId,
|
||||
}),
|
||||
(r) =>
|
||||
r.bereit
|
||||
? 'Gespeichert. Die Discord-Anmeldung ist eingerichtet.'
|
||||
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
|
||||
)
|
||||
setSpeichert(false)
|
||||
if (ergebnis) {
|
||||
setGeheimnis('')
|
||||
void laden()
|
||||
}
|
||||
}
|
||||
|
||||
if (laedt) {
|
||||
return (
|
||||
<Karte titel="Anmeldung über Discord">
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="animate-spin text-schrift-leise" />
|
||||
</div>
|
||||
</Karte>
|
||||
)
|
||||
}
|
||||
if (!daten) return null
|
||||
|
||||
const d = daten.discord
|
||||
|
||||
return (
|
||||
<Karte
|
||||
titel="Anmeldung über Discord"
|
||||
rechts={
|
||||
d.bereit ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-gut">
|
||||
<ShieldCheck size={14} /> eingerichtet
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||
<ShieldX size={14} /> nicht eingerichtet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-schrift-leise">
|
||||
Diese Adresse muss im Discord-Portal unter OAuth2 → Redirects stehen —
|
||||
Zeichen für Zeichen:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
|
||||
{d.redirectUri}
|
||||
</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!(await kopieren(d.redirectUri, 'uri')))
|
||||
note('fehler', 'Die Zwischenablage ist gesperrt.')
|
||||
}}
|
||||
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="Adresse kopieren"
|
||||
>
|
||||
{kopiert === 'uri' ? (
|
||||
<Check size={14} className="text-gut" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="Discord-Portal öffnen"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld
|
||||
label="Client-ID"
|
||||
value={clientId}
|
||||
placeholder="17 bis 20 Ziffern"
|
||||
hinweis={d.ausDerUmgebung.clientId ? 'kommt derzeit aus der Umgebung' : undefined}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Client-Geheimnis"
|
||||
type="password"
|
||||
value={geheimnis}
|
||||
placeholder={d.hatGeheimnis ? '•••••••• (hinterlegt)' : 'noch keines'}
|
||||
hinweis={
|
||||
d.ausDerUmgebung.clientSecret
|
||||
? 'kommt derzeit aus der Umgebung'
|
||||
: 'leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
|
||||
}
|
||||
onChange={(e) => setGeheimnis(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Server-ID"
|
||||
value={guildId}
|
||||
placeholder="Rechtsklick auf den Server → ID kopieren"
|
||||
hinweis={d.ausDerUmgebung.guildId ? 'kommt derzeit aus der Umgebung' : undefined}
|
||||
onChange={(e) => setGuildId(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Rollen-ID"
|
||||
value={roleId}
|
||||
placeholder="Wer diese Rolle hat, darf herein"
|
||||
hinweis={d.ausDerUmgebung.roleId ? 'kommt derzeit aus der Umgebung' : undefined}
|
||||
onChange={(e) => setRoleId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Die IDs bekommst du in Discord über Einstellungen → Erweitert →
|
||||
Entwicklermodus, danach Rechtsklick → ID kopieren. Es wird nur{' '}
|
||||
<code className="font-mono">identify</code> und{' '}
|
||||
<code className="font-mono">guilds.members.read</code> erfragt — kein Bot,
|
||||
keine Nachrichten, nichts weiter. Das Passwort bleibt als Notausgang
|
||||
bestehen.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user