feat: Token-Grenzen, Speicher nach Art, Aufbewahrungsbericht, Discord-Meldungen
KEINE TOKEN-ARTEN, SONDERN TOKEN-GRENZEN. Fivemanage hat Token-Typen; wir haben schon ein Rechtesystem, es steht nur auf einer anderen Achse (Praefix und Loeschrecht). Was wirklich fehlte, sind die drei Schrauben, die die Frage beantworten "was passiert, wenn DIESER Token abhandenkommt": Groessengrenze ein Token fuer Fahrzeugbilder braucht keine 64 MB erlaubte Arten nur Bilder, kein Video Ablaufdatum fuer eine Anbindung, die man mal ausprobiert Alle drei am laufenden Dienst durchgemessen: 600 KB gegen 512 KB -> 413 mit beiden Zahlen; ein mp4 gegen einen Bild-Token -> 403 mit Art UND MIME im Text; ausserhalb des Praefix -> 403; abgelaufen -> 401 mit DEM DATUM. "Abgelaufen" und "unbekannt" auseinanderzuhalten spart eine halbe Stunde Suche nach einem Tippfehler, den es nicht gibt. Mehr als die Dienstgrenze zu erlauben wird abgelehnt statt still gekappt -- es waere eine Behauptung, denn durchgelassen wird ohnehin die schaerfere. SPEICHER NACH ART: ein Balken fuer die Verhaeltnisse, eine Tabelle fuer die Zahlen, dazu die groessten Dateien. AUFBEWAHRUNG VORBEREITET, NICHT GEBAUT. Automatisches Loeschen zu bauen, bevor man weiss, was da liegt, ist der Weg, wie man Daten verliert. Stattdessen der Bericht davor: wie viel ist aelter als 7/30/90/365 Tage, was liegt doppelt (gleicher SHA-256 unter zwei Pfaden) und wie viel Platz das kostet. "Alles aelter als 90 Tage loeschen" ist eine Behauptung, solange niemand weiss, wie viel das waere. Diese Seite loescht nichts. DISCORD-MELDUNGEN -- und der Grund ist nicht Discord, sondern eine Luecke, die wir heute selbst gebaut haben: die Sicherung laeuft nachts um halb fuenf, und wenn sie aufhoert zu laufen, merkt es niemand. Der Dienst sieht jetzt STUENDLICH nach und meldet, wenn der letzte Lauf gescheitert oder aelter als 26 Stunden ist (24 waeren zu knapp, 48 zu spaet). Hoechstens EINMAL je Zustand -- eine Meldung, die stuendlich wiederkommt, wird weggeklickt, und dann auch die, die zaehlt. Ein Webhook und keine Bot-Anbindung: eine Adresse, kein Token, keine Berechtigungen. Die Adresse wird geprueft (nur echte discord.com-Webhooks), und der Testknopf steht gleich daneben -- ein Webhook, den man eintraegt und erst in drei Wochen im Fehlerfall ausprobiert, ist einer, der dann nicht geht. Aus deren Doku mitgenommen und NICHT gebaut: presigned URLs (kurzlebige signierte Adressen fuer Client-Uploads). Waere der richtige Weg fuer Spieler-Screenshots -- solange keine Spieler hochladen, ist es Vorrat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+31
-2
@@ -127,6 +127,15 @@ export type Stats = {
|
||||
publicUrl: string
|
||||
}
|
||||
|
||||
export type Speicher = {
|
||||
nachArt: { art: string; files: number; bytes: number }[]
|
||||
groesste: MediaItem[]
|
||||
aelteste: MediaItem[]
|
||||
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
|
||||
verschwendet: number
|
||||
stufen: { tage: number; files: number; bytes: number }[]
|
||||
}
|
||||
|
||||
export type TokenRow = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -134,6 +143,12 @@ export type TokenRow = {
|
||||
can_delete: number
|
||||
created_at: number
|
||||
last_used_at: number | null
|
||||
/** Groesste erlaubte Datei. Null = die Grenze des Dienstes. */
|
||||
max_bytes: number | null
|
||||
/** Erlaubte Arten, mit Komma. Leer = alle. */
|
||||
arten: string
|
||||
expires_at: number | null
|
||||
abgelaufen: boolean
|
||||
}
|
||||
|
||||
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
|
||||
@@ -207,6 +222,7 @@ export type LetzteSicherung = {
|
||||
|
||||
export type Einstellungen = {
|
||||
vorlagen: Vorlage[]
|
||||
meldungen: { hatWebhook: boolean; anlaesse: string[] }
|
||||
sicherung: {
|
||||
basis: string
|
||||
benutzer: string
|
||||
@@ -350,6 +366,12 @@ export const api = {
|
||||
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
|
||||
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
|
||||
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
|
||||
meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) =>
|
||||
post<{ ok: true; meldungen: { hatWebhook: boolean; anlaesse: string[] } }>(
|
||||
'/api/dash/settings',
|
||||
{ meldungen },
|
||||
),
|
||||
meldenTesten: () => post<{ ok: true }>('/api/dash/maintenance/melden'),
|
||||
vorlagenSpeichern: (liste: Vorlage[]) =>
|
||||
post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }),
|
||||
einstellungenSpeichern: (discord: Record<string, string>) =>
|
||||
@@ -357,6 +379,7 @@ export const api = {
|
||||
|
||||
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
|
||||
stand: () => get<Stand>('/api/dash/stand'),
|
||||
speicher: () => get<Speicher>('/api/dash/speicher'),
|
||||
stats: () => get<Stats>('/api/dash/stats'),
|
||||
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
||||
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
|
||||
@@ -367,7 +390,13 @@ export const api = {
|
||||
),
|
||||
|
||||
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
||||
createToken: (name: string, prefix: string, canDelete: boolean) =>
|
||||
post<NewToken>('/api/dash/tokens', { name, prefix, canDelete }),
|
||||
createToken: (daten: {
|
||||
name: string
|
||||
prefix: string
|
||||
canDelete: boolean
|
||||
maxMb?: string
|
||||
arten?: string[]
|
||||
gueltigTage?: string
|
||||
}) => post<NewToken>('/api/dash/tokens', daten),
|
||||
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Bell, BellOff, Send } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { Feld, Karte, Knopf } from './Bausteine'
|
||||
|
||||
const ANLAESSE = [
|
||||
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
|
||||
['verwaiste', 'Verwaiste Dateien gefunden'],
|
||||
] as const
|
||||
|
||||
/** Meldungen nach Discord.
|
||||
*
|
||||
* Der Grund ist nicht Discord, sondern eine Lücke: die Sicherung läuft nachts
|
||||
* um halb fünf, und wenn sie aufhört zu laufen, merkt es niemand — das Panel
|
||||
* zeigt es nur dem, der hinschaut. Ein Webhook schließt genau das.
|
||||
*
|
||||
* Deshalb steht der Testknopf gleich daneben. Ein Webhook, den man einträgt
|
||||
* und erst in drei Wochen im Fehlerfall ausprobiert, ist ein Webhook, der dann
|
||||
* nicht funktioniert. */
|
||||
export function MeldungenKarte() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [hatWebhook, setHatWebhook] = useState(false)
|
||||
const [anlaesse, setAnlaesse] = useState<string[]>([])
|
||||
const [webhook, setWebhook] = useState('')
|
||||
const [speichert, setSpeichert] = useState(false)
|
||||
const [testet, setTestet] = useState(false)
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const e = await api.einstellungen()
|
||||
setHatWebhook(e.meldungen.hatWebhook)
|
||||
setAnlaesse(e.meldungen.anlaesse)
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Karte
|
||||
titel="Meldungen nach Discord"
|
||||
rechts={
|
||||
hatWebhook ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-gut">
|
||||
<Bell size={14} /> eingerichtet
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
|
||||
<BellOff size={14} /> aus
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Nicht für Jubelmeldungen, sondern für die zwei Fälle, in denen jemand etwas
|
||||
tun muss. Der wichtigste: <strong>die Sicherung läuft nachts und niemand
|
||||
merkt, wenn sie aufhört.</strong> Der Dienst sieht stündlich nach und meldet
|
||||
höchstens einmal je Anlass.
|
||||
</p>
|
||||
|
||||
<Feld
|
||||
label="Webhook-Adresse"
|
||||
type="password"
|
||||
value={webhook}
|
||||
placeholder={
|
||||
hatWebhook ? '•••••••• (hinterlegt)' : 'https://discord.com/api/webhooks/…'
|
||||
}
|
||||
hinweis={
|
||||
'In Discord: Serverkanal → Bearbeiten → Integrationen → Webhooks. ' +
|
||||
'Leer lassen heißt unverändert, ein „-" entfernt ihn.'
|
||||
}
|
||||
onChange={(e) => setWebhook(e.target.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||
Wann gemeldet wird
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ANLAESSE.map(([wert, text]) => (
|
||||
<label key={wert} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={anlaesse.includes(wert)}
|
||||
onChange={(e) =>
|
||||
setAnlaesse((a) =>
|
||||
e.target.checked
|
||||
? [...a, wert]
|
||||
: a.filter((x) => x !== wert),
|
||||
)
|
||||
}
|
||||
className="accent-akzent"
|
||||
/>
|
||||
{text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Knopf
|
||||
art="haupt"
|
||||
wartet={speichert}
|
||||
onClick={async () => {
|
||||
setSpeichert(true)
|
||||
const r = await run(
|
||||
() =>
|
||||
api.meldungenSpeichern({
|
||||
...(webhook ? { webhook } : {}),
|
||||
anlaesse,
|
||||
}),
|
||||
(x) =>
|
||||
x.meldungen.hatWebhook
|
||||
? 'Gespeichert. Jetzt den Testknopf drücken.'
|
||||
: 'Gespeichert — ohne Webhook wird nichts gemeldet.',
|
||||
)
|
||||
setSpeichert(false)
|
||||
if (r) {
|
||||
setWebhook('')
|
||||
void laden()
|
||||
}
|
||||
}}
|
||||
kind="Speichern"
|
||||
/>
|
||||
<Knopf
|
||||
wartet={testet}
|
||||
disabled={!hatWebhook}
|
||||
onClick={async () => {
|
||||
setTestet(true)
|
||||
await run(
|
||||
() => api.meldenTesten(),
|
||||
'Testnachricht geschickt — steht sie in Discord?',
|
||||
)
|
||||
setTestet(false)
|
||||
}}
|
||||
kind={
|
||||
<>
|
||||
<Send size={15} />
|
||||
Testnachricht
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FileQuestion, FileText, Film, Image, Loader2, Music } from 'lucide-react'
|
||||
import { api, type Speicher } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { bytes, zeit } from '../format'
|
||||
import { Karte, Leer } from './Bausteine'
|
||||
|
||||
const ARTEN: Record<string, { text: string; kind: typeof Image; farbe: string }> = {
|
||||
bild: { text: 'Bilder', kind: Image, farbe: 'bg-akzent' },
|
||||
video: { text: 'Video', kind: Film, farbe: 'bg-warn' },
|
||||
ton: { text: 'Ton', kind: Music, farbe: 'bg-gut' },
|
||||
dokument: { text: 'Dokumente', kind: FileText, farbe: 'bg-schrift-leise' },
|
||||
andere: { text: 'Anderes', kind: FileQuestion, farbe: 'bg-schlecht' },
|
||||
}
|
||||
|
||||
/** Was liegt da eigentlich — nach Art, nach Größe, nach Alter.
|
||||
*
|
||||
* ERST SEHEN, DANN ENTSCHEIDEN. Eine Aufbewahrungsregel („alles älter als 90
|
||||
* Tage weg") ist eine Behauptung, solange niemand weiß, wie viel das wäre.
|
||||
* Diese Karten löschen nichts; sie liefern die Zahlen, mit denen man
|
||||
* entscheiden kann. */
|
||||
export function SpeicherKarten() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [daten, setDaten] = useState<Speicher | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.speicher()
|
||||
.then(setDaten)
|
||||
.catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt'))
|
||||
}, [note])
|
||||
|
||||
if (!daten) {
|
||||
return (
|
||||
<Karte titel="Nach Medienart">
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="animate-spin text-schrift-leise" />
|
||||
</div>
|
||||
</Karte>
|
||||
)
|
||||
}
|
||||
|
||||
const gesamt = daten.nachArt.reduce((s, a) => s + a.bytes, 0) || 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<Karte titel="Nach Medienart">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Ein Balken für alles, damit die Verhältnisse auf einen
|
||||
Blick stimmen — Zahlen untereinander liest man, Anteile
|
||||
sieht man. */}
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-grund">
|
||||
{daten.nachArt
|
||||
.filter((a) => a.bytes > 0)
|
||||
.map((a) => (
|
||||
<div
|
||||
key={a.art}
|
||||
className={ARTEN[a.art]?.farbe ?? 'bg-rand'}
|
||||
style={{ width: `${(a.bytes / gesamt) * 100}%` }}
|
||||
title={`${ARTEN[a.art]?.text}: ${bytes(a.bytes)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{daten.nachArt.map((a) => {
|
||||
const g = ARTEN[a.art]
|
||||
const Kind = g?.kind ?? FileQuestion
|
||||
return (
|
||||
<tr
|
||||
key={a.art}
|
||||
className={`border-t border-rand first:border-0 ${
|
||||
a.files === 0 ? 'text-schrift-leise/50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${g?.farbe ?? 'bg-rand'}`}
|
||||
/>
|
||||
<Kind size={14} />
|
||||
{g?.text ?? a.art}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{a.files}
|
||||
</td>
|
||||
<td className="w-24 py-1.5 text-right tabular-nums">
|
||||
{bytes(a.bytes)}
|
||||
</td>
|
||||
<td className="w-16 py-1.5 text-right text-xs text-schrift-leise tabular-nums">
|
||||
{gesamt > 1 ? `${Math.round((a.bytes / gesamt) * 100)} %` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Karte titel="Die größten Dateien">
|
||||
{daten.groesste.length === 0 ? (
|
||||
<Leer text="Noch nichts da." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{daten.groesste.map((m) => (
|
||||
<li key={m.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-xs"
|
||||
title={m.path}
|
||||
>
|
||||
{m.path}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums">
|
||||
{bytes(m.size)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Doppelt abgelegt">
|
||||
{daten.doppelte.length === 0 ? (
|
||||
<p className="py-2 text-sm text-schrift-leise">
|
||||
Nichts doppelt — jeder Inhalt liegt genau einmal.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Derselbe Inhalt unter mehreren Pfaden. Kein Fehler — aber{' '}
|
||||
<strong className="text-warn">{bytes(daten.verschwendet)}</strong>{' '}
|
||||
Platz, den niemand gewählt hat.
|
||||
</p>
|
||||
<ul className="flex max-h-48 flex-col gap-1 overflow-auto text-xs">
|
||||
{daten.doppelte.map((d) => (
|
||||
<li key={d.sha256} className="rounded border border-rand p-1.5">
|
||||
<span className="text-schrift-leise">
|
||||
{d.n}× · {bytes(d.bytes / d.n)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate font-mono">
|
||||
{d.pfade.replaceAll('|', ' · ')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Karte>
|
||||
</div>
|
||||
|
||||
<Karte titel="Wie alt der Bestand ist">
|
||||
<p className="mb-3 text-xs text-schrift-leise">
|
||||
Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage
|
||||
löschen" ist eine Behauptung, solange niemand weiß, wie viel das wäre —
|
||||
hier steht es. <strong>Gelöscht wird hier nichts.</strong>
|
||||
</p>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{daten.stufen.map((st) => (
|
||||
<tr key={st.tage} className="border-t border-rand first:border-0">
|
||||
<td className="py-1.5">älter als {st.tage} Tage</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{st.files} Dateien
|
||||
</td>
|
||||
<td className="w-24 py-1.5 text-right tabular-nums">
|
||||
{bytes(st.bytes)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{daten.aelteste[0] && (
|
||||
<p className="mt-3 text-xs text-schrift-leise">
|
||||
Das älteste ist{' '}
|
||||
<code className="font-mono">{daten.aelteste[0].path}</code> vom{' '}
|
||||
{zeit(daten.aelteste[0].created_at)}.
|
||||
</p>
|
||||
)}
|
||||
</Karte>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { bytes, zeit } from '../format'
|
||||
import { Feld, Karte, Knopf } from '../components/Bausteine'
|
||||
import { DiscordKarte } from '../components/DiscordKarte'
|
||||
import { VorlagenKarte } from '../components/VorlagenKarte'
|
||||
import { MeldungenKarte } from '../components/MeldungenKarte'
|
||||
|
||||
/** Alles, was sich einstellen lässt, an einer Stelle.
|
||||
*
|
||||
@@ -28,6 +29,7 @@ export function Einstellungen() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
|
||||
<SicherungKarte />
|
||||
<MeldungenKarte />
|
||||
<DiscordKarte />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { run, useStore } from '../store'
|
||||
import { bytes } from '../format'
|
||||
import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
import { type Verwaiste } from '../api'
|
||||
import { SpeicherKarten } from '../components/SpeicherKarten'
|
||||
|
||||
export function Speicher() {
|
||||
const note = useStore((s) => s.note)
|
||||
@@ -59,6 +60,8 @@ export function Speicher() {
|
||||
<Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} />
|
||||
</div>
|
||||
|
||||
<SpeicherKarten />
|
||||
|
||||
<Karte titel="Öffentliche Adresse">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { api, type NewToken, type TokenRow } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { zeit } from '../format'
|
||||
import { bytes, zeit } from '../format'
|
||||
import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
|
||||
export function Tokens() {
|
||||
@@ -13,6 +13,9 @@ export function Tokens() {
|
||||
const [name, setName] = useState('')
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [darfLoeschen, setDarfLoeschen] = useState(false)
|
||||
const [maxMb, setMaxMb] = useState('')
|
||||
const [gueltigTage, setGueltigTage] = useState('')
|
||||
const [arten, setArten] = useState<string[]>([])
|
||||
const [legtAn, setLegtAn] = useState(false)
|
||||
|
||||
const [frisch, setFrisch] = useState<NewToken | null>(null)
|
||||
@@ -41,7 +44,15 @@ export function Tokens() {
|
||||
e.preventDefault()
|
||||
setLegtAn(true)
|
||||
const neu = await run(
|
||||
() => api.createToken(name.trim(), prefix.trim(), darfLoeschen),
|
||||
() =>
|
||||
api.createToken({
|
||||
name: name.trim(),
|
||||
prefix: prefix.trim(),
|
||||
canDelete: darfLoeschen,
|
||||
maxMb: maxMb.trim(),
|
||||
arten,
|
||||
gueltigTage: gueltigTage.trim(),
|
||||
}),
|
||||
(t) => `Token "${t.name}" angelegt.`,
|
||||
)
|
||||
setLegtAn(false)
|
||||
@@ -50,6 +61,9 @@ export function Tokens() {
|
||||
setName('')
|
||||
setPrefix('')
|
||||
setDarfLoeschen(false)
|
||||
setMaxMb('')
|
||||
setGueltigTage('')
|
||||
setArten([])
|
||||
void laden()
|
||||
}
|
||||
}
|
||||
@@ -84,6 +98,69 @@ export function Tokens() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld
|
||||
label="Größte Datei (MB, optional)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={maxMb}
|
||||
placeholder="leer = Grenze des Dienstes"
|
||||
hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB."
|
||||
onChange={(e) => setMaxMb(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Gültig für (Tage, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={gueltigTage}
|
||||
placeholder="leer = unbefristet"
|
||||
hinweis="Danach antwortet der Dienst mit einer Meldung, die das sagt."
|
||||
onChange={(e) => setGueltigTage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
||||
Erlaubte Arten (optional)
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
['bild', 'Bilder'],
|
||||
['video', 'Video'],
|
||||
['ton', 'Ton'],
|
||||
['dokument', 'Dokumente'],
|
||||
['andere', 'Anderes'],
|
||||
] as const
|
||||
).map(([wert, text]) => {
|
||||
const an = arten.includes(wert)
|
||||
return (
|
||||
<button
|
||||
key={wert}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setArten((a) =>
|
||||
an ? a.filter((x) => x !== wert) : [...a, wert],
|
||||
)
|
||||
}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
an
|
||||
? 'border-akzent bg-akzent/15 text-schrift'
|
||||
: 'border-rand text-schrift-leise hover:text-schrift'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="mt-1 block text-xs text-schrift-leise">
|
||||
Nichts gewählt heißt: alles erlaubt. Ein Token, der nur Bilder darf,
|
||||
kann kein Video hochladen — auch wenn er abhandenkommt.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -134,6 +211,7 @@ export function Tokens() {
|
||||
<th className="pb-2 font-medium">Name</th>
|
||||
<th className="pb-2 font-medium">Präfix</th>
|
||||
<th className="pb-2 font-medium">Rechte</th>
|
||||
<th className="pb-2 font-medium">Grenzen</th>
|
||||
<th className="pb-2 font-medium">Angelegt</th>
|
||||
<th className="pb-2 font-medium">Zuletzt benutzt</th>
|
||||
<th className="pb-2" />
|
||||
@@ -141,8 +219,18 @@ export function Tokens() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.id} className="border-t border-rand">
|
||||
<td className="py-2 font-medium">{t.name}</td>
|
||||
<tr
|
||||
key={t.id}
|
||||
className={`border-t border-rand ${t.abgelaufen ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<td className="py-2 font-medium">
|
||||
{t.name}
|
||||
{t.abgelaufen && (
|
||||
<span className="ml-2 rounded bg-schlecht/15 px-1.5 py-0.5 text-[11px] text-schlecht">
|
||||
abgelaufen
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-schrift-leise">
|
||||
{t.prefix ? `${t.prefix}/` : '— (überall)'}
|
||||
</td>
|
||||
@@ -157,6 +245,17 @@ export function Tokens() {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-schrift-leise">
|
||||
{[
|
||||
t.max_bytes ? `max ${bytes(t.max_bytes)}` : null,
|
||||
t.arten ? t.arten.replaceAll(',', ', ') : null,
|
||||
t.expires_at
|
||||
? `bis ${zeit(t.expires_at).slice(0, 10)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '—'}
|
||||
</td>
|
||||
<td className="py-2 text-schrift-leise">
|
||||
{zeit(t.created_at)}
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user