diff --git a/server/src/db.ts b/server/src/db.ts index 8cdf117..25d674a 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -91,8 +91,33 @@ CREATE TABLE IF NOT EXISTS events ( actor TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS events_at ON events(at DESC); + +-- Einstellungen, die zur Laufzeit aenderbar sein sollen. +-- +-- Bewusst NICHT alles: was den Dienst ueberhaupt hochfahren laesst (PUBLIC_URL, +-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den +-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart +-- verstellbar sein soll -- derzeit die Discord-Anmeldung. +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); `) +// Nachtraegliche Spalten. SQLite kann kein "ADD COLUMN IF NOT EXISTS", also +// wird nachgesehen — das ist die ganze Wanderung, die dieses Projekt braucht. +{ + const spalten = db.prepare('PRAGMA table_info(users)').all() as { name: string }[] + if (!spalten.some((s) => s.name === 'discord_id')) { + db.exec('ALTER TABLE users ADD COLUMN discord_id TEXT') + // Kein UNIQUE in der Spaltendefinition: SQLite erlaubt das bei ADD + // COLUMN nicht. Der Index danach leistet dasselbe. + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)') + console.log('[db] Spalte users.discord_id angelegt') + } +} + // Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur // wenn er leer ist. // @@ -123,6 +148,9 @@ export type User = { username: string password_hash: string created_at: number + /** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto + * hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */ + discord_id: string | null } export type Token = { diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index c6282b5..29638e4 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -31,6 +31,8 @@ import { verifyPassword, } from '../auth.js' import { readFile, stat } from 'node:fs/promises' +import { DISCORD_KEYS, ausDatenbank, discordConfig, setSetting } from '../settings.js' +import { discordRoutes, redirectUri } from './discord.js' import { absolutePath, checkPath, @@ -212,6 +214,17 @@ dashRoutes.get('/auth/me', (c) => { return c.json({ user: { id: user.id, username: user.username } }) }) +/** Was die Anmeldeseite wissen muss, BEVOR jemand angemeldet ist. + * + * Nur ein Ja/Nein: ob der Discord-Knopf ueberhaupt sinnvoll ist. Weder + * Client-ID noch Server-ID gehen hier heraus — die braucht das Formular + * nicht, und was nicht herausgeht, kann auch nicht abgeschrieben werden. */ +dashRoutes.get('/auth/config', (c) => c.json({ discord: discordConfig().bereit })) + +// Der Discord-Weg selbst. MUSS vor der Wache stehen: wer sich anmelden will, +// ist noch nicht angemeldet. +dashRoutes.route('/auth', discordRoutes) + // Ab hier gilt: angemeldet oder nichts. dashRoutes.use('*', async (c, next) => { const user = userForSession(getCookie(c, SESSION_COOKIE)) @@ -523,10 +536,11 @@ dashRoutes.post('/upload', async (c) => { * Nur lesen, nie schreiben — geschrieben wird an den Stellen, an denen es * passiert. Ein Verlauf, den man von aussen fuellen kann, ist keiner. */ dashRoutes.get('/events', (c) => { - const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500) + const limit = Math.min(Number(c.req.query('limit') ?? 50) || 50, 500) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) const kind = c.req.query('kind')?.trim() ?? '' const actor = c.req.query('actor')?.trim() ?? '' + const query = c.req.query('query')?.trim() ?? '' const bedingungen: string[] = [] const params: unknown[] = [] @@ -538,6 +552,12 @@ dashRoutes.get('/events', (c) => { bedingungen.push('actor = ?') params.push(actor) } + if (query) { + // Dieselbe Maskierung wie bei der Mediensuche: ein Pfad mit _ darin + // wuerde sonst auch auf ein beliebiges Zeichen passen. + bedingungen.push(`path LIKE ? ESCAPE '\\'`) + params.push(`%${query.replace(/[\\%_]/g, (z) => `\\${z}`)}%`) + } const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' const total = db.prepare(`SELECT COUNT(*) AS n FROM events ${where}`).get(...params) as { @@ -563,6 +583,92 @@ dashRoutes.get('/events', (c) => { return c.json({ total: total.n, limit, offset, items, akteure, seit: seit.a }) }) +// ------------------------------------------------------------- Einstellungen + +/** Die Einstellungen zum Anzeigen. + * + * DAS GEHEIMNIS GEHT NIE HERAUS — auch nicht an eine angemeldete Sitzung. + * Zurueck kommt nur, OB eines hinterlegt ist. Ein Formular, das den + * Client-Schluessel im Klartext zurueckschickt, streut ihn in jeden + * Browser-Cache und jedes Bildschirmfoto. */ +dashRoutes.get('/settings', (c) => { + const d = discordConfig() + return c.json({ + discord: { + clientId: d.clientId, + guildId: d.guildId, + roleId: d.roleId, + hatGeheimnis: Boolean(d.clientSecret), + bereit: d.bereit, + // Die Stelle, an der der erste Anlauf fast immer scheitert. + redirectUri: redirectUri(), + ausDerUmgebung: { + clientId: Boolean(d.clientId) && !ausDatenbank(DISCORD_KEYS.clientId), + clientSecret: + Boolean(d.clientSecret) && !ausDatenbank(DISCORD_KEYS.clientSecret), + guildId: Boolean(d.guildId) && !ausDatenbank(DISCORD_KEYS.guildId), + roleId: Boolean(d.roleId) && !ausDatenbank(DISCORD_KEYS.roleId), + }, + }, + }) +}) + +dashRoutes.post('/settings', async (c) => { + const body = await jsonBody<{ + discord?: { + clientId?: string + clientSecret?: string + guildId?: string + roleId?: string + } + }>(c) + if (!body) return c.json(KEIN_JSON, 400) + + const d = body.discord + if (!d) return c.json({ error: 'nichts zu speichern' }, 400) + + // Die IDs von Discord sind Snowflakes: 17 bis 20 Ziffern. Wer eine + // Rollen-ID mit Leerzeichen oder einen halben Link einfuegt, soll es HIER + // erfahren und nicht beim ersten Anmeldeversuch. + const istId = (v: string) => /^\d{17,20}$/.test(v) + + if (d.clientId !== undefined) { + const v = d.clientId.trim() + if (v && !istId(v)) return c.json({ error: 'Client-ID: 17 bis 20 Ziffern' }, 400) + setSetting(DISCORD_KEYS.clientId, v) + } + if (d.guildId !== undefined) { + const v = d.guildId.trim() + if (v && !istId(v)) return c.json({ error: 'Server-ID: 17 bis 20 Ziffern' }, 400) + setSetting(DISCORD_KEYS.guildId, v) + } + if (d.roleId !== undefined) { + const v = d.roleId.trim() + if (v && !istId(v)) return c.json({ error: 'Rollen-ID: 17 bis 20 Ziffern' }, 400) + setSetting(DISCORD_KEYS.roleId, v) + } + // Ein leeres Feld heisst "unveraendert lassen", nicht "loeschen" — sonst + // wuerde jedes Speichern das Geheimnis wegwerfen, weil es nie im Formular + // steht. Zum Entfernen gibt es den eigenen Wert '-'. + if (d.clientSecret !== undefined) { + const v = d.clientSecret.trim() + if (v === '-') setSetting(DISCORD_KEYS.clientSecret, '') + else if (v) setSetting(DISCORD_KEYS.clientSecret, v) + } + + const jetzt = discordConfig() + return c.json({ + ok: true, + bereit: jetzt.bereit, + fehlt: [ + !jetzt.clientId && 'Client-ID', + !jetzt.clientSecret && 'Client-Geheimnis', + !jetzt.guildId && 'Server-ID', + !jetzt.roleId && 'Rollen-ID', + ].filter(Boolean), + }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') diff --git a/server/src/routes/discord.ts b/server/src/routes/discord.ts new file mode 100644 index 0000000..8840743 --- /dev/null +++ b/server/src/routes/discord.ts @@ -0,0 +1,196 @@ +// Anmeldung ueber Discord. +// +// DER ABLAUF, in drei Schritten und ohne Bibliothek: +// +// 1. /api/dash/auth/discord → wir schicken den Browser zu Discord +// 2. Discord fragt den Benutzer → und schickt ihn mit einem `code` zurueck +// 3. /…/discord/callback → wir tauschen den Code gegen ein Zugangs- +// merkmal, holen Benutzer UND Rolle, und +// legen bei Erfolg eine Sitzung an +// +// WARUM OHNE BOT-TOKEN. Die Rolle wird mit dem Merkmal des BENUTZERS geholt +// (`guilds.members.read` → /users/@me/guilds//member). Damit braucht +// dieser Dienst keinen Bot, keine Bot-Berechtigung und kein zweites Geheimnis, +// das ablaufen kann. +// +// WAS SCHIEFGEHEN KANN, geht sichtbar schief: jeder Abbruch landet wieder auf +// der Anmeldeseite, mit dem Grund im Adressfeld. Ein OAuth-Weg, der bei einem +// Fehler eine weisse Seite zeigt, ist der Grund, warum niemand ihn debuggen +// mag. + +import { Hono } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { randomBytes } from 'node:crypto' +import { config } from '../config.js' +import { db, now, type User } from '../db.js' +import { SESSION_COOKIE, createSession } from '../auth.js' +import { discordConfig } from '../settings.js' + +export const discordRoutes = new Hono() + +/** Muss ZEICHEN FUER ZEICHEN mit dem uebereinstimmen, was im Discord-Portal + * eingetragen ist — daran scheitert der erste Anlauf fast immer. Deshalb + * zeigt das Panel genau diese Zeichenkette an. */ +export const redirectUri = () => `${config.publicUrl}/api/dash/auth/discord/callback` + +const STATE_COOKIE = 'dstate' + +/** Zurueck zur Anmeldeseite, mit dem Grund im Adressfeld. */ +const zurueck = (c: any, grund: string) => + c.redirect(`/?discord=${encodeURIComponent(grund)}`, 302) + +// ------------------------------------------------------------------ Schritt 1 + +discordRoutes.get('/discord', (c) => { + const d = discordConfig() + if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.') + + // Der Zustandswert schuetzt gegen untergeschobene Rueckleitungen: nur wer + // diesen Weg begonnen hat, hat das Cookie dazu. + const state = randomBytes(24).toString('base64url') + setCookie(c, STATE_COOKIE, state, { + httpOnly: true, + secure: !config.dev, + sameSite: 'Lax', + path: '/', + maxAge: 600, + }) + + const url = new URL('https://discord.com/oauth2/authorize') + url.searchParams.set('client_id', d.clientId) + url.searchParams.set('response_type', 'code') + url.searchParams.set('redirect_uri', redirectUri()) + // identify: wer es ist. guilds.members.read: welche Rollen er auf DIESEM + // Server hat. Mehr wird nicht erfragt — was man nicht hat, kann man nicht + // verlieren. + url.searchParams.set('scope', 'identify guilds.members.read') + url.searchParams.set('state', state) + + return c.redirect(url.toString(), 302) +}) + +// ------------------------------------------------------------------ Schritt 3 + +discordRoutes.get('/discord/callback', async (c) => { + const d = discordConfig() + if (!d.bereit) return zurueck(c, 'Die Discord-Anmeldung ist nicht eingerichtet.') + + // Discord meldet Ablehnungen selbst — etwa wenn jemand auf "Abbrechen" + // klickt. Das ist kein Fehler, sondern eine Antwort. + const fehler = c.req.query('error_description') ?? c.req.query('error') + if (fehler) return zurueck(c, `Discord: ${fehler}`) + + const state = c.req.query('state') + const erwartet = getCookie(c, STATE_COOKIE) + deleteCookie(c, STATE_COOKIE, { path: '/' }) + if (!state || !erwartet || state !== erwartet) { + return zurueck(c, 'Der Anmeldeversuch passt nicht zu diesem Browser. Noch einmal.') + } + + const code = c.req.query('code') + if (!code) return zurueck(c, 'Discord hat keinen Code mitgeschickt.') + + try { + // -- Code gegen Zugangsmerkmal -------------------------------------- + const tokenAntwort = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: d.clientId, + client_secret: d.clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri(), + }), + }) + + if (!tokenAntwort.ok) { + const text = (await tokenAntwort.text()).slice(0, 200) + // Der haeufigste Fall steht hier zuerst, weil er sonst eine + // Viertelstunde kostet. + return zurueck( + c, + `Discord lehnt den Tausch ab (${tokenAntwort.status}). Stimmt die Redirect-URI im Portal genau? ${text}`, + ) + } + + const { access_token } = (await tokenAntwort.json()) as { access_token?: string } + if (!access_token) return zurueck(c, 'Discord hat kein Zugangsmerkmal geliefert.') + + const mitMerkmal = { headers: { authorization: `Bearer ${access_token}` } } + + // -- Wer ist das ---------------------------------------------------- + const meAntwort = await fetch('https://discord.com/api/users/@me', mitMerkmal) + if (!meAntwort.ok) return zurueck(c, 'Discord gibt den Benutzer nicht heraus.') + const me = (await meAntwort.json()) as { + id: string + username: string + global_name?: string | null + } + + // -- Und welche Rolle hat er hier ----------------------------------- + const memberAntwort = await fetch( + `https://discord.com/api/users/@me/guilds/${d.guildId}/member`, + mitMerkmal, + ) + if (memberAntwort.status === 404) { + return zurueck(c, 'Du bist nicht auf diesem Discord-Server.') + } + if (!memberAntwort.ok) { + return zurueck( + c, + `Die Mitgliedschaft liess sich nicht pruefen (${memberAntwort.status}). Stimmt die Server-ID?`, + ) + } + + const member = (await memberAntwort.json()) as { roles?: string[] } + if (!Array.isArray(member.roles) || !member.roles.includes(d.roleId)) { + // Bewusst ohne den Namen der Rolle: wer sie nicht hat, muss auch + // nicht erfahren, wie sie heisst. + return zurueck(c, 'Dir fehlt die noetige Rolle auf dem Server.') + } + + // -- Konto finden oder anlegen -------------------------------------- + let user = db.prepare('SELECT * FROM users WHERE discord_id = ?').get(me.id) as + | User + | undefined + + if (!user) { + const anzeige = (me.global_name || me.username || `discord-${me.id}`).slice(0, 40) + // Der Name muss eindeutig sein; zwei Leute koennen denselben + // Anzeigenamen haben. Bei Kollision haengt die Discord-ID dran. + const belegt = db.prepare('SELECT 1 FROM users WHERE username = ?').get(anzeige) + const username = belegt ? `${anzeige} (${me.id})` : anzeige + + // KEIN brauchbares Passwort: verifyPassword scheitert an einem + // String, der nicht dem scrypt-Format entspricht — dieses Konto + // kommt also ausschliesslich ueber Discord herein. + const info = db + .prepare( + `INSERT INTO users (username, password_hash, created_at, discord_id) + VALUES (?, 'nur-ueber-discord', ?, ?)`, + ) + .run(username, now(), me.id) + + user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as User + console.log(`[discord] Konto angelegt: ${username} (${me.id})`) + } + + const session = createSession(user.id) + setCookie(c, SESSION_COOKIE, session.id, { + httpOnly: true, + secure: !config.dev, + sameSite: 'Lax', + path: '/', + expires: new Date(session.expiresAt), + }) + + return c.redirect('/', 302) + } catch (err) { + console.error('[discord]', err) + return zurueck( + c, + `Der Weg ueber Discord brach ab: ${err instanceof Error ? err.message : 'unbekannt'}`, + ) + } +}) diff --git a/server/src/settings.ts b/server/src/settings.ts new file mode 100644 index 0000000..b8b2773 --- /dev/null +++ b/server/src/settings.ts @@ -0,0 +1,80 @@ +// Einstellungen, die zur Laufzeit verstellbar sind. +// +// WAS HIER LIEGT UND WAS NICHT. In der Umgebung bleibt, was den Dienst +// ueberhaupt erreichbar macht: PUBLIC_URL, PORT, DATA_DIR, ADMIN_PASSWORD. Wer +// die per Formular verstellen koennte, koennte sich mit einem Klick +// aussperren, und die Reparatur waere ein Eingriff in die Datenbank. +// +// Hier liegt der Rest — derzeit die Discord-Anmeldung. Der Gewinn ist nicht +// nur Bequemlichkeit: das Panel kann die Redirect-URI gleich zum Kopieren +// hinlegen, und die ist die Stelle, an der ein OAuth-Anlauf am haeufigsten +// scheitert. +// +// DAS GEHEIMNIS LIEGT DAMIT IN media.db, und die steckt in jeder Sicherung +// unter C:\backup. Die .env ist dort nicht drin. Beides liegt unverschluesselt +// auf derselben Platte, der Unterschied ist die Zahl der Kopien — wer den +// Client-Schluessel zurueckziehen will, tut das im Discord-Portal, nicht hier. + +import { db, now } from './db.js' + +const lesen = db.prepare('SELECT value FROM settings WHERE key = ?') +const schreiben = db.prepare( + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, +) + +/** Ein Wert aus der Datenbank — oder, wenn dort nichts steht, aus der + * Umgebung. Die Reihenfolge ist Absicht: wer schon Stack-Variablen gesetzt + * hat, behaelt sie, und wer das Panel benutzt, uebersteuert sie. */ +export function getSetting(key: string, envName?: string): string { + const zeile = lesen.get(key) as { value: string } | undefined + if (zeile && zeile.value !== '') return zeile.value + return (envName ? (process.env[envName] ?? '') : '').trim() +} + +export function setSetting(key: string, value: string): void { + schreiben.run(key, value, now()) +} + +/** Ob ein Wert aus der Datenbank kommt (true) oder aus der Umgebung (false). + * Die Oberflaeche sagt das dazu — sonst wundert sich jemand, warum ein + * geleertes Feld weiterhin wirkt. */ +export function ausDatenbank(key: string): boolean { + const zeile = lesen.get(key) as { value: string } | undefined + return Boolean(zeile && zeile.value !== '') +} + +// ------------------------------------------------------------------- Discord + +export const DISCORD_KEYS = { + clientId: 'discord.client_id', + clientSecret: 'discord.client_secret', + guildId: 'discord.guild_id', + roleId: 'discord.role_id', +} as const + +export type DiscordConfig = { + clientId: string + clientSecret: string + guildId: string + roleId: string + /** Erst wenn alle vier stehen, hat die Anmeldung ueberhaupt eine Chance. + * Ein halb eingerichteter Weg, der auf halber Strecke abbricht, ist + * schlimmer als ein Knopf, den es nicht gibt. */ + bereit: boolean +} + +export function discordConfig(): DiscordConfig { + const clientId = getSetting(DISCORD_KEYS.clientId, 'DISCORD_CLIENT_ID') + const clientSecret = getSetting(DISCORD_KEYS.clientSecret, 'DISCORD_CLIENT_SECRET') + const guildId = getSetting(DISCORD_KEYS.guildId, 'DISCORD_GUILD_ID') + const roleId = getSetting(DISCORD_KEYS.roleId, 'DISCORD_ROLE_ID') + + return { + clientId, + clientSecret, + guildId, + roleId, + bereit: Boolean(clientId && clientSecret && guildId && roleId), + } +} diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 0206991..66569c2 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -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( `/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('/api/dash/settings'), + einstellungenSpeichern: (discord: Record) => + post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), + stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), diff --git a/server/ui/src/components/DiscordKarte.tsx b/server/ui/src/components/DiscordKarte.tsx new file mode 100644 index 0000000..c2c5663 --- /dev/null +++ b/server/ui/src/components/DiscordKarte.tsx @@ -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(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 ( + +
+ +
+
+ ) + } + if (!daten) return null + + const d = daten.discord + + return ( + + eingerichtet + + ) : ( + + nicht eingerichtet + + ) + } + > +
+
+

+ Diese Adresse muss im Discord-Portal unter OAuth2 → Redirects stehen — + Zeichen für Zeichen: +

+
+ + {d.redirectUri} + + + + + +
+
+ +
+ setClientId(e.target.value)} + /> + setGeheimnis(e.target.value)} + /> + setGuildId(e.target.value)} + /> + setRoleId(e.target.value)} + /> +
+ +

+ Die IDs bekommst du in Discord über Einstellungen → Erweitert → + Entwicklermodus, danach Rechtsklick → ID kopieren. Es wird nur{' '} + identify und{' '} + guilds.members.read erfragt — kein Bot, + keine Nachrichten, nichts weiter. Das Passwort bleibt als Notausgang + bestehen. +

+ +
+ +
+
+
+ ) +} diff --git a/server/ui/src/pages/Anmelden.tsx b/server/ui/src/pages/Anmelden.tsx index 7a657bd..116ba47 100644 --- a/server/ui/src/pages/Anmelden.tsx +++ b/server/ui/src/pages/Anmelden.tsx @@ -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(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 && ( + <> +
+ + oder + +
+ + {/* Ein echtes und kein fetch: der Weg fuehrt zu + Discord und wieder zurueck, das ist eine + Seitennavigation und keine Anfrage im Hintergrund. */} + + + + + Mit Discord anmelden + + + )} ) diff --git a/server/ui/src/pages/Konto.tsx b/server/ui/src/pages/Konto.tsx index dffe2cf..d38bcf7 100644 --- a/server/ui/src/pages/Konto.tsx +++ b/server/ui/src/pages/Konto.tsx @@ -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 ( -
+
+ +
) } diff --git a/server/ui/src/pages/Verlauf.tsx b/server/ui/src/pages/Verlauf.tsx index bce7440..db15b94 100644 --- a/server/ui/src/pages/Verlauf.tsx +++ b/server/ui/src/pages/Verlauf.tsx @@ -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 = { upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload }, @@ -30,24 +32,37 @@ export function Verlauf() { const [seite, setSeite] = useState(0) const [art, setArt] = useState('') 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 (
@@ -90,6 +105,51 @@ export function Verlauf() {
+
+
+ + 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 && ( + + )} +
+ + +
+ {items.length === 0 && !laedt ? ( Wann Was Pfad - Größe - Von wem + Größe + Von wem @@ -125,10 +185,10 @@ export function Verlauf() { {e.path} - + {e.size === null ? '—' : bytes(e.size)} - +