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:
2026-08-11 18:44:45 +02:00
co-authored by Claude Opus 5
parent 0b295a2302
commit dffec7714c
9 changed files with 746 additions and 13 deletions
+47 -1
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import { HardDrive } from 'lucide-react'
import { api } from '../api'
import { useStore } from '../store'
import { Feld, Knopf } from '../components/Bausteine'
@@ -10,6 +11,26 @@ export function Anmelden() {
const [passwort, setPasswort] = useState('')
const [wartet, setWartet] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [discord, setDiscord] = useState(false)
// Ob der Discord-Knopf ueberhaupt Sinn hat, weiss nur der Dienst. Ein
// Knopf, der zu einer Fehlerseite fuehrt, weil nichts eingerichtet ist,
// ist genau die Sorte Knopf, die wir hier nicht bauen.
useEffect(() => {
api.authConfig()
.then((c) => setDiscord(c.discord))
.catch(() => setDiscord(false))
}, [])
// Der Weg ueber Discord endet bei einem Abbruch wieder hier, mit dem Grund
// im Adressfeld. Ohne diese Zeilen laege er dort und niemand saehe ihn.
useEffect(() => {
const grund = new URLSearchParams(window.location.search).get('discord')
if (!grund) return
setFehler(grund)
note('fehler', grund)
window.history.replaceState(null, '', window.location.pathname)
}, [note])
async function absenden(e: FormEvent) {
e.preventDefault()
@@ -71,6 +92,31 @@ export function Anmelden() {
className="mt-5 w-full"
kind="Anmelden"
/>
{discord && (
<>
<div className="my-4 flex items-center gap-3 text-xs text-schrift-leise">
<span className="h-px flex-1 bg-rand" />
oder
<span className="h-px flex-1 bg-rand" />
</div>
{/* Ein echtes <a> und kein fetch: der Weg fuehrt zu
Discord und wieder zurueck, das ist eine
Seitennavigation und keine Anfrage im Hintergrund. */}
<a
href="/api/dash/auth/discord"
className="flex w-full items-center justify-center gap-2 rounded-md
border border-[#5865F2] bg-[#5865F2] px-3 py-1.5 text-sm
font-medium text-white transition-colors hover:bg-[#4752c4]"
>
<svg viewBox="0 0 127 96" className="h-4 w-4" fill="currentColor" aria-hidden>
<path d="M107 8A105 105 0 0 0 81 0l-3 7a97 97 0 0 0-29 0l-4-7a105 105 0 0 0-26 8C2 33-1 57 1 81a106 106 0 0 0 32 16l7-11c-4-1-8-3-11-5l3-2a75 75 0 0 0 64 0l3 2c-4 2-8 4-12 5l7 11a106 106 0 0 0 32-16c3-28-2-52-19-73ZM43 66c-6 0-11-6-11-13s5-13 11-13 12 6 11 13c0 7-5 13-11 13Zm42 0c-6 0-11-6-11-13s5-13 11-13 11 6 11 13c0 7-5 13-11 13Z" />
</svg>
Mit Discord anmelden
</a>
</>
)}
</form>
</div>
)
+4 -1
View File
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
export function Konto() {
const user = useStore((s) => s.user)
@@ -37,7 +38,7 @@ export function Konto() {
}
return (
<div className="max-w-md">
<div className="flex max-w-2xl flex-col gap-4">
<Karte titel={`Angemeldet als ${user?.username ?? '—'}`}>
<form onSubmit={wechseln} className="flex flex-col gap-3">
<Feld
@@ -79,6 +80,8 @@ export function Konto() {
</div>
</form>
</Karte>
<DiscordKarte />
</div>
)
}
+68 -8
View File
@@ -6,16 +6,18 @@ import {
KeyRound,
Loader2,
RefreshCw,
Search,
Trash2,
Upload,
UserCog,
X,
} from 'lucide-react'
import { api, type EventKind, type EventsSeite } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Knopf, Leer } from '../components/Bausteine'
const PRO_SEITE = 100
const SEITENGROESSEN = [25, 50, 100, 200] as const
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
@@ -30,24 +32,37 @@ export function Verlauf() {
const [seite, setSeite] = useState(0)
const [art, setArt] = useState<EventKind | ''>('')
const [wer, setWer] = useState('')
const [proSeite, setProSeite] = useState(50)
const [suche, setSuche] = useState('')
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
// "vehicles" eine Anfrage je Buchstabe raus.
const [sucheAktiv, setSucheAktiv] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setSucheAktiv(suche)
setSeite(0)
}, 250)
return () => clearTimeout(t)
}, [suche])
const laden = useCallback(async () => {
setLaedt(true)
try {
setDaten(await api.events(art, wer, PRO_SEITE, seite * PRO_SEITE))
setDaten(await api.events(art, wer, sucheAktiv, proSeite, seite * proSeite))
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLaedt(false)
}
}, [art, wer, seite, note])
}, [art, wer, sucheAktiv, proSeite, seite, note])
useEffect(() => {
void laden()
}, [laden])
const items = daten?.items ?? []
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / PRO_SEITE))
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
return (
<div className="flex flex-col gap-4">
@@ -90,6 +105,51 @@ export function Verlauf() {
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
size={15}
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
/>
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Pfad durchsuchen, z. B. vehicles/adder"
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
focus:outline-none"
/>
{suche && (
<button
onClick={() => setSuche('')}
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
aria-label="Suche leeren"
>
<X size={14} />
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs text-schrift-leise">
pro Seite
<select
value={proSeite}
onChange={(e) => {
setProSeite(Number(e.target.value))
setSeite(0)
}}
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
{SEITENGROESSEN.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<Karte>
{items.length === 0 && !laedt ? (
<Leer
@@ -103,8 +163,8 @@ export function Verlauf() {
<th className="pb-2 font-medium">Wann</th>
<th className="pb-2 font-medium">Was</th>
<th className="pb-2 font-medium">Pfad</th>
<th className="pb-2 text-right font-medium">Größe</th>
<th className="pb-2 font-medium">Von wem</th>
<th className="pb-2 pr-8 text-right font-medium">Größe</th>
<th className="pb-2 pl-2 font-medium">Von wem</th>
</tr>
</thead>
<tbody>
@@ -125,10 +185,10 @@ export function Verlauf() {
<td className="max-w-0 truncate py-1.5 font-mono text-xs" title={e.path}>
{e.path}
</td>
<td className="py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
<td className="py-1.5 pr-8 text-right whitespace-nowrap text-schrift-leise tabular-nums">
{e.size === null ? '—' : bytes(e.size)}
</td>
<td className="py-1.5 whitespace-nowrap">
<td className="py-1.5 pl-2 whitespace-nowrap">
<span
className={
e.actor_kind === 'unbekannt'