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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user