feat: Sitzungen beenden, Verwaltungs-Verlauf -- und ein Kaestchen, das log

DER FEHLER ZUERST, denn er ist meiner von gestern: die Liste der
Meldungsanlaesse stand ZWEIMAL da -- als Standard in meldung.ts und noch einmal
als Weissliste beim Speichern in dash.ts. Beim Einbau des Plattenwaechters habe
ich nur die erste angefasst.

Ergebnis: das Kaestchen "Der Platz auf der Platte wird knapp" liess sich
ankreuzen, der Server warf den Wert beim Speichern weg, und beim naechsten
Laden war es wieder aus. Ohne ein Wort dazu. Nachgemessen am laufenden Dienst:

    geschickt    ["sicherung","verwaiste","platte"]
    gespeichert  ["sicherung","verwaiste"]

Die Liste steht jetzt an EINER Stelle (ANLAESSE in meldung.ts) und wird von
beiden benutzt. Gegengeprueft: alle drei kommen an.

SITZUNGEN BEENDEN. Wer sich an einem fremden Rechner angemeldet hat und es
spaeter merkt, hatte keine Moeglichkeit das zurueckzunehmen -- ein geaendertes
Passwort half nicht, die Sitzungen haengen an einer eigenen Tabelle und
ueberleben es. Der Knopf steht im Kontomenue und nur dann, wenn es ueberhaupt
eine zweite Sitzung gibt. Gemessen: 87 offen, 86 beendet, meine lebt.

Dabei in die eigene Falle getreten: der Weg lag zuerst bei den anderen
/auth-Wegen -- und die stehen mit Absicht VOR der Wache, weil man sich anmelden
koennen muss, ohne angemeldet zu sein. Dort ist c.get('user') leer, und der Weg
antwortete mit "Cannot read properties of undefined". Hono setzt Middleware und
Handler in der Reihenfolge ihrer Anmeldung zusammen; der Pfad sagt darueber
nichts.

VERWALTUNGS-VERLAUF. Der Datei-Verlauf beantwortet "was ist mit den Dateien
passiert". Wer den Discord-Webhook geaendert, einen Token angelegt oder ein
Konto entfernt hat, stand nirgends. Bei zwei Konten verschmerzbar -- sobald ein
drittes ueber die Discord-Rolle von selbst entsteht, ist es die erste Frage.

Eigene Tabelle und nicht events: dort traegt jede Zeile path und size, und eine
Einstellungsaenderung muesste path mit etwas fuellen, das kein Pfad ist.

WERTE STEHEN DORT NIE, nur Schluesselnamen. Unter den Einstellungen liegen das
Discord-Geheimnis und das Nextcloud-Passwort; ein Verlauf, der sie mitschreibt,
macht aus einer Tabelle mit einem Passwort eine Tabelle mit allen, die es je
gab.

Beim Einbau derselbe Fehlertyp wie gestern beim SVG: im SQL-Kommentar standen
Backticks, und das Schema liegt in einem Template-Literal -- ein Backtick
beendet es. Der Uebersetzer hat es gefangen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 07:43:57 +02:00
co-authored by Claude Opus 5
parent a91c4261de
commit 29e516559a
11 changed files with 445 additions and 16 deletions
+22 -1
View File
@@ -340,6 +340,20 @@ export type Einstellungen = {
}
}
/** Eine Zeile aus dem Verwaltungs-Verlauf.
*
* `was` traegt NIE einen Wert, nur Schluesselnamen — unter den Einstellungen
* liegen das Discord-Geheimnis und das Nextcloud-Passwort. */
export type VerwaltungRow = {
id: number
at: number
wer: string
bereich: 'einstellung' | 'token' | 'benutzer' | 'freigabe' | 'ordner'
tat: 'angelegt' | 'geaendert' | 'entfernt'
ziel: string
was: string
}
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
export type MediaEvent = {
@@ -406,7 +420,7 @@ export const api = {
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
/** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */
authConfig: () => get<{ discord: boolean; marke: string }>('/api/dash/auth/config'),
me: () => get<{ user: User }>('/api/dash/auth/me'),
me: () => get<{ user: User; sitzungen?: number }>('/api/dash/auth/me'),
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
@@ -494,6 +508,13 @@ export const api = {
{ aktion },
),
verwaltung: (limit = 100, offset = 0) =>
get<{ items: VerwaltungRow[]; total: number; limit: number; offset: number }>(
`/api/dash/verwaltung?limit=${limit}&offset=${offset}`,
),
sitzungenBeenden: () =>
post<{ beendet: number; offen: number }>('/api/dash/auth/sitzungen/beenden'),
benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'),
benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`),
+31 -1
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
import { ChevronDown, LogOut, UserCog , ShieldOff } from 'lucide-react'
import { run, useStore } from '../store'
import { gehZu } from '../router'
import { Avatar } from './Avatar'
import { api } from '../api'
/** Das Konto hinter dem eigenen Namen.
*
@@ -16,6 +17,8 @@ import { Avatar } from './Avatar'
export function Kontomenue() {
const user = useStore((s) => s.user)
const logout = useStore((s) => s.logout)
const sitzungen = useStore((s) => s.sitzungen)
const checkSession = useStore((s) => s.checkSession)
const [offen, setOffen] = useState(false)
const huelle = useRef<HTMLDivElement>(null)
@@ -85,6 +88,33 @@ export function Kontomenue() {
Konto und Passwort
</button>
{/* Nur wenn es ueberhaupt eine zweite gibt. Ein Knopf, der
"0 andere Sitzungen beendet" meldet, ist ein Knopf, der
nichts tut -- und beim naechsten Mal drueckt man ihn
wieder, weil man es vergessen hat. */}
{sitzungen > 1 && (
<button
className={`${eintrag} border-t border-rand`}
onClick={async () => {
setOffen(false)
await run(
() => api.sitzungenBeenden(),
(r) =>
`${r.beendet} andere Sitzung(en) beendet. Diese hier bleibt.`,
)
void checkSession()
}}
>
<ShieldOff size={15} />
<span className="flex-1 text-left">
Andere Sitzungen beenden
<span className="block text-[11px] text-schrift-leise">
{sitzungen} offen an fremden Rechnern auch
</span>
</span>
</button>
)}
<button
className={`${eintrag} border-t border-rand hover:text-schlecht`}
onClick={() => {
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { KeyRound, Link2, Loader2, Settings, UserRound } from 'lucide-react'
import { api, type VerwaltungRow } from '../api'
import { useStore } from '../store'
import { zeit } from '../format'
import { Leer } from './Bausteine'
/** Wer hat wann was EINGESTELLT.
*
* Der Datei-Verlauf daneben beantwortet „was ist mit den Dateien passiert".
* Diese Liste beantwortet die andere Frage, die man stellt, wenn etwas anders
* ist als gestern: **wer hat daran gedreht?**
*
* Bei zwei Konten ist das verschmerzbar. Sobald ein drittes dazukommt — und
* über die Discord-Rolle entsteht es von selbst — ist es die erste Frage.
*
* WERTE STEHEN HIER NIE, nur Namen von Schlüsseln und Zielen. Unter den
* Einstellungen liegen das Discord-Geheimnis und das Nextcloud-Passwort; ein
* Verlauf, der sie mitschreibt, macht aus einer Tabelle mit einem Passwort
* eine Tabelle mit allen, die es je gab. */
const BEREICHE = {
einstellung: { text: 'Einstellung', kind: Settings, farbe: 'text-akzent' },
token: { text: 'Token', kind: KeyRound, farbe: 'text-warn' },
benutzer: { text: 'Konto', kind: UserRound, farbe: 'text-akzent' },
freigabe: { text: 'Freigabe', kind: Link2, farbe: 'text-gut' },
ordner: { text: 'Ordner', kind: Settings, farbe: 'text-schrift-leise' },
} as const
const TATEN = {
angelegt: { text: 'angelegt', farbe: 'text-gut' },
geaendert: { text: 'geändert', farbe: 'text-warn' },
entfernt: { text: 'entfernt', farbe: 'text-schlecht' },
} as const
export function VerwaltungListe() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<VerwaltungRow[] | null>(null)
useEffect(() => {
let lebt = true
api.verwaltung(200)
.then((r) => lebt && setDaten(r.items))
.catch((err) => lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
return () => {
lebt = false
}
}, [note])
if (!daten) {
return (
<div className="flex justify-center py-12">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (daten.length === 0) {
return (
<Leer
kind={<Settings size={26} className="text-schrift-leise/50" />}
text="Noch nichts verwaltet — oder alles, bevor es diese Liste gab."
/>
)
}
return (
<table className="w-full text-sm">
<colgroup>
<col className="w-36" />
<col className="w-28" />
<col className="w-24" />
<col />
<col className="w-32" />
</colgroup>
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="px-3 pb-2 font-medium">Wann</th>
<th className="px-3 pb-2 font-medium">Bereich</th>
<th className="px-3 pb-2 font-medium">Was</th>
<th className="px-3 pb-2 font-medium">Ziel</th>
<th className="px-3 pb-2 font-medium">Wer</th>
</tr>
</thead>
<tbody>
{daten.map((z) => {
const b = BEREICHE[z.bereich] ?? BEREICHE.ordner
const t = TATEN[z.tat] ?? TATEN.geaendert
const Zeichen = b.kind
return (
<tr key={z.id} className="border-t border-rand">
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
{zeit(z.at)}
</td>
<td className="px-3 py-1.5">
<span className={`flex items-center gap-1.5 text-xs ${b.farbe}`}>
<Zeichen size={13} />
{b.text}
</span>
</td>
<td className={`px-3 py-1.5 text-xs ${t.farbe}`}>{t.text}</td>
<td className="px-3 py-1.5">
<span className="font-mono text-xs">{z.ziel || '—'}</span>
{z.was && (
<span className="block text-[11px] text-schrift-leise">
{z.was}
</span>
)}
</td>
<td className="truncate px-3 py-1.5 text-schrift-leise">{z.wer}</td>
</tr>
)
})}
</tbody>
</table>
)
}
+49
View File
@@ -19,6 +19,7 @@ import { Karte, Knopf, Leer } from '../components/Bausteine'
import { useAuffrischen } from '../auffrischen'
import { useMerker } from '../merker'
import { Avatar } from '../components/Avatar'
import { VerwaltungListe } from '../components/VerwaltungListe'
const SEITENGROESSEN = [25, 50, 100, 200] as const
@@ -42,6 +43,10 @@ export function Verlauf() {
const [wer, setWer] = useState('')
const [proSeite, setProSeite] = useMerker('verlauf.proSeite', 50)
const [suche, setSuche] = useState('')
// Zwei Verläufe, eine Seite. Der eine beantwortet „was ist mit den Dateien
// passiert", der andere „wer hat daran gedreht" — dieselbe Frage aus zwei
// Richtungen, und beide gehören dorthin, wo man Verlauf sucht.
const [reiter, setReiter] = useState<'dateien' | 'verwaltung'>('dateien')
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
// "vehicles" eine Anfrage je Buchstabe raus.
@@ -81,8 +86,20 @@ export function Verlauf() {
const bilder = new Map((daten?.akteure ?? []).map((a) => [a.actor, a.avatar]))
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
if (reiter === 'verwaltung') {
return (
<div className="flex flex-col gap-4">
<Reiter reiter={reiter} setReiter={setReiter} />
<Karte>
<VerwaltungListe />
</Karte>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<Reiter reiter={reiter} setReiter={setReiter} />
<div className="flex flex-wrap items-center gap-2">
<Filter an={art === ''} onClick={() => { setArt(''); setSeite(0) }} text="Alles" />
{(Object.keys(arten) as EventKind[]).map((k) => (
@@ -342,3 +359,35 @@ function Filter({
</button>
)
}
/** Die zwei Verläufe nebeneinander.
*
* Als Reiter und nicht als zweite Seite im Menü: es ist beides ein Verlauf,
* und wer einen sucht, sucht nicht zwei Menüeinträge ab. */
function Reiter({
reiter,
setReiter,
}: {
reiter: 'dateien' | 'verwaltung'
setReiter: (r: 'dateien' | 'verwaltung') => void
}) {
const stil = (an: boolean) =>
`rounded-md px-3 py-1.5 text-sm ${
an
? 'bg-flaeche-hoch text-schrift'
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
}`
return (
<div className="flex gap-1 self-start rounded-lg border border-rand bg-flaeche p-1">
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
Dateien
</button>
<button
className={stil(reiter === 'verwaltung')}
onClick={() => setReiter('verwaltung')}
>
Verwaltung
</button>
</div>
)
}
+5 -2
View File
@@ -19,6 +19,8 @@ type State = {
checking: boolean
notes: Note[]
/** Wie viele Sitzungen dieses Konto offen hat. 0 = noch nicht geladen. */
sitzungen: number
checkSession: () => Promise<void>
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
@@ -30,12 +32,13 @@ type State = {
export const useStore = create<State>((set, get) => ({
user: null,
checking: true,
sitzungen: 0,
notes: [],
async checkSession() {
try {
const { user } = await api.me()
set({ user, checking: false })
const { user, sitzungen } = await api.me()
set({ user, sitzungen: sitzungen ?? 0, checking: false })
} catch (err) {
// 401 ist hier kein Fehler, sondern die Antwort "noch nicht
// angemeldet". Alles andere ist einer und wird gesagt.