Files
qrcode-generator/src/app/(app)/verwaltung/page.tsx
T
Florian Wilde 46e7eb0b59 Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten
Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
2026-08-17 10:41:57 +02:00

147 lines
4.8 KiB
TypeScript

import type { Metadata } from "next";
import Link from "next/link";
import { Icon, type IconName } from "@/components/icons";
import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" };
export const dynamic = "force-dynamic";
function Tile({
href,
icon,
title,
description,
hint,
}: {
href: string;
icon: IconName;
title: string;
description: string;
hint: string;
}) {
return (
<Link
href={href}
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
<Icon name={icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="font-semibold text-slate-900">{title}</span>
<span className="mt-1 block text-sm text-slate-600">{description}</span>
<span className="mt-2 block text-xs text-slate-500 tabular-nums">
{hint}
</span>
</span>
</Link>
);
}
export default async function AdminPage() {
await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
prisma.domain.count(),
getBranding(),
getSettings(),
]);
const eigenes = [
branding.logoUploadId && "Logo",
branding.faviconUploadId && "Favicon",
].filter(Boolean);
const brandingHint =
eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName;
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Verwaltung
</h1>
<p className="mt-2 text-slate-600">
Benutzer, Gruppen und die verfügbaren QR-Typen.
</p>
</div>
{!isAuthEnabled() ? (
<div className="mb-6">
<Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>
in der <code className="font-mono text-xs">.env</code> setzen und
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d
</code>
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
an. Benutzer und Gruppen lassen sich schon jetzt vorbereiten.
</Notice>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Tile
href="/verwaltung/benutzer"
icon="contact"
title="Benutzer"
description="Konten anlegen, Passwörter zurücksetzen, deaktivieren."
hint={`${userCount} ${userCount === 1 ? "Benutzer" : "Benutzer"} angelegt`}
/>
<Tile
href="/verwaltung/gruppen"
icon="list"
title="Gruppen"
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
/>
<Tile
href="/verwaltung/domains"
icon="globe"
title="Domains"
description="Adressen, unter denen Kurz-URLs ausgeliefert werden."
hint={`${domainCount} ${domainCount === 1 ? "Domain" : "Domains"} registriert`}
/>
<Tile
href="/verwaltung/typen"
icon="qr"
title="QR-Typen"
description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt"
/>
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile
href="/verwaltung/erscheinungsbild"
icon="image"
title="Erscheinungsbild"
description="Name, Logo, Favicon und Farbe der Oberfläche."
hint={brandingHint}
/>
</div>
</div>
);
}