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
+33 -2
View File
@@ -147,6 +147,24 @@ export type Folders = {
wurzel: { files: number; bytes: number }
}
export type Einstellungen = {
discord: {
clientId: string
guildId: string
roleId: string
/** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */
hatGeheimnis: boolean
bereit: boolean
redirectUri: string
ausDerUmgebung: {
clientId: boolean
clientSecret: boolean
guildId: boolean
roleId: boolean
}
}
}
export type EventKind = 'upload' | 'replace' | 'delete'
export type MediaEvent = {
@@ -198,6 +216,8 @@ export const api = {
login: (username: string, password: string) =>
post<{ user: User }>('/api/dash/auth/login', { username, password }),
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
/** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */
authConfig: () => get<{ discord: boolean }>('/api/dash/auth/config'),
me: () => get<{ user: User }>('/api/dash/auth/me'),
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
@@ -212,12 +232,23 @@ export const api = {
deleteMediaBulk: (ids: number[]) =>
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
events: (kind: string, actor: string, limit: number, offset: number) =>
events: (
kind: string,
actor: string,
query: string,
limit: number,
offset: number,
) =>
get<EventsSeite>(
`/api/dash/events?kind=${encodeURIComponent(kind)}` +
`&actor=${encodeURIComponent(actor)}&limit=${limit}&offset=${offset}`,
`&actor=${encodeURIComponent(actor)}` +
`&query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
),
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
einstellungenSpeichern: (discord: Record<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
+183
View File
@@ -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>
)
}
+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'