diff --git a/README.md b/README.md index 098ccee..56de034 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. - **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt. - **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter. - **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst. +- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im + Admin-Bereich einstellbar. - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start von selbst; alles Nötige steht in einer `.env`. diff --git a/docs/funktionen.md b/docs/funktionen.md index 9b92f7e..f0f9af9 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -19,6 +19,7 @@ so entschieden ist. Wie man es bedient, steht in der - [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen) - [Single Sign-on](#single-sign-on) - [Mehrere Domains](#mehrere-domains) +- [Erscheinungsbild](#erscheinungsbild) - [Verwaltung](#verwaltung) - [Betrieb](#betrieb) - [Schnittstelle](#schnittstelle) @@ -344,10 +345,46 @@ Anwendung leiten. --- +## Erscheinungsbild + +Unter Verwaltung → Erscheinungsbild lassen sich einstellen: + +| | | +| --- | --- | +| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile | +| **Zusatz in der Fußzeile** | steht hinter dem Namen | +| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert | +| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name | +| **Favicon** | PNG, ICO oder WebP bis 512 KB | + +**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu +`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese +Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der +gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste. + +Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die +Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf +der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde +ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton +benutzbar. + +**Auf den Landingpages** gelten Farbe und Favicon, aber weder Logo noch +Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht +die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht +als eigener Punkt auf der [Roadmap](roadmap.md). + +**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank +und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen +auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt +auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads. + +--- + ## Verwaltung | Bereich | Was dort geht | | --- | --- | +| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe | | **QR-Typen** | einzelne Typen ausblenden | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | @@ -398,7 +435,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP: | `POST /api/feedback` | Rückmeldung abgeben | | `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern | | `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter | -| `/api/admin/types` · `users` · `groups` · `domains` | Verwaltung | +| `/api/admin/types` · `users` · `groups` · `domains` · `branding` | Verwaltung | | `GET /r/{slug}` | Weiterleitung eines dynamischen Codes | | `GET /s/{slug}` | öffentliche Landingpage | diff --git a/docs/roadmap.md b/docs/roadmap.md index 176981d..b7515ce 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,6 +62,12 @@ Damit sind **alle 15 vorgesehenen Typen** gebaut. Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token. +### Erscheinungsbild (16.08.2026) + +Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe +wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne +Umbau der rund 60 eingefärbten Stellen. + ### Dokumentation (15.08.2026) Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. @@ -190,10 +196,13 @@ sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und die Auslieferungsroute — aber auch die Sicherungsstrategie. -**Eigene Gestaltung der Landingpages** · groß -Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma -aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es -braucht Grenzen, damit nichts Unlesbares entsteht. +**Erscheinungsbild je Gruppe** · groß +Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze +Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben +und die Landingpages mit dem Logo der jeweiligen Gruppe zu versehen — damit +eine gescannte Seite nach der Firma aussieht, der sie gehört. Technisch +überschaubar, gestalterisch eine eigene Aufgabe; es braucht Grenzen, damit +nichts Unlesbares entsteht. **Mehrsprachige Oberfläche** · groß Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den diff --git a/prisma/migrations/20260816090000_erscheinungsbild/migration.sql b/prisma/migrations/20260816090000_erscheinungsbild/migration.sql new file mode 100644 index 0000000..a82c83d --- /dev/null +++ b/prisma/migrations/20260816090000_erscheinungsbild/migration.sql @@ -0,0 +1,31 @@ +-- Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe der Installation. + +-- Wozu eine hochgeladene Datei gehört. Bestehende Dateien hängen alle an +-- einem Code, deshalb ist die Voreinstellung richtig. +ALTER TABLE "Upload" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'code'; +CREATE INDEX "Upload_purpose_idx" ON "Upload"("purpose"); + +CREATE TABLE "Branding" ( + "id" TEXT NOT NULL DEFAULT 'branding', + "appName" TEXT NOT NULL DEFAULT 'QR-Code-Generator', + "footerText" TEXT NOT NULL DEFAULT 'statische und dynamische QR-Codes', + "accentColor" TEXT NOT NULL DEFAULT '#4F46E5', + "logoUploadId" TEXT, + "faviconUploadId" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Branding_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Branding_logoUploadId_key" ON "Branding"("logoUploadId"); +CREATE UNIQUE INDEX "Branding_faviconUploadId_key" ON "Branding"("faviconUploadId"); + +ALTER TABLE "Branding" ADD CONSTRAINT "Branding_logoUploadId_fkey" + FOREIGN KEY ("logoUploadId") REFERENCES "Upload"("id") + ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Branding" ADD CONSTRAINT "Branding_faviconUploadId_fkey" + FOREIGN KEY ("faviconUploadId") REFERENCES "Upload"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur noch lesen muss. +INSERT INTO "Branding" ("id", "updatedAt") VALUES ('branding', CURRENT_TIMESTAMP); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9fb8253..58ae0f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -219,14 +219,45 @@ model Upload { data Bytes createdAt DateTime @default(now()) + /// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die + /// erste Art wird als herrenlos weggeräumt. + purpose String @default("code") + /// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen, /// der Code aber noch nicht gespeichert wurde; solche Reste räumt /// `src/lib/uploads.ts` nach einer Weile weg. qrCodeId String? qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) + brandingLogo Branding? @relation("BrandingLogo") + brandingFavicon Branding? @relation("BrandingFavicon") + @@index([qrCodeId]) @@index([createdAt]) + @@index([purpose]) +} + +/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe. +/// +/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner +/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die +/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind. +model Branding { + id String @id @default("branding") + appName String @default("QR-Code-Generator") + /// Zeile in der Fußzeile, hinter dem Namen. + footerText String @default("statische und dynamische QR-Codes") + /// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung; + /// die Helligkeitsstufen stammen aus der Vorlage — siehe + /// src/lib/branding-colors.ts. + accentColor String @default("#4F46E5") + + logoUploadId String? @unique + logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull) + faviconUploadId String? @unique + faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull) + + updatedAt DateTime @updatedAt } /// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 6f7c800..876b91a 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -4,6 +4,7 @@ import { LogoutButton } from "@/components/auth-forms"; import { Icon } from "@/components/icons"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { can } from "@/lib/auth/policy"; +import { getBranding } from "@/lib/branding"; // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // werden. @@ -21,6 +22,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) { const actor = await getActor(); const signedIn = actor.kind !== "anonymous"; const showAdmin = can(actor, "instance:manage"); + const branding = await getBranding(); return (
@@ -30,12 +32,25 @@ export default async function AppLayout({ children }: LayoutProps<"/">) { href="/" className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900" > - - - + {branding.logoUploadId ? ( + /* eslint-disable-next-line @next/next/no-img-element -- aus der + Datenbank ausgeliefert, nicht aus dem Dateisystem */ + {branding.appName} + ) : ( + + + + )} {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die - Navigation nicht mehr in eine Zeile. */} - QR-Code-Generator + Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt + dieses schon den Namen. */} + {branding.logoUploadId ? null : ( + {branding.appName} + )}
diff --git a/src/app/(app)/verwaltung/erscheinungsbild/page.tsx b/src/app/(app)/verwaltung/erscheinungsbild/page.tsx new file mode 100644 index 0000000..a5f0754 --- /dev/null +++ b/src/app/(app)/verwaltung/erscheinungsbild/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { BrandingAdmin } from "@/components/branding-admin"; +import { Icon } from "@/components/icons"; +import { requirePagePermission } from "@/lib/auth/guard"; +import { getBranding } from "@/lib/branding"; + +export const metadata: Metadata = { title: "Erscheinungsbild" }; + +export const dynamic = "force-dynamic"; + +export default async function BrandingPage() { + await requirePagePermission("instance:manage"); + const branding = await getBranding(); + + return ( +
+ + + Zur Verwaltung + + +
+

+ Erscheinungsbild +

+

+ Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen + gelten für die ganze Installation. +

+

+ Die öffentlichen Landingpages übernehmen die Farbe und das Favicon, + aber weder Logo noch Navigation — wer einen Code scannt, soll die + Seite des Inhalts sehen und nicht die einer Verwaltungssoftware. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/verwaltung/page.tsx b/src/app/(app)/verwaltung/page.tsx index fff7635..f4ca672 100644 --- a/src/app/(app)/verwaltung/page.tsx +++ b/src/app/(app)/verwaltung/page.tsx @@ -5,6 +5,7 @@ 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 { prisma } from "@/lib/db"; export const metadata: Metadata = { title: "Verwaltung" }; @@ -45,12 +46,21 @@ function Tile({ export default async function AdminPage() { await requirePagePermission("instance:manage"); - const [userCount, groupCount, codeCount, domainCount] = await Promise.all([ - prisma.user.count(), - prisma.group.count(), - prisma.qrCode.count(), - prisma.domain.count(), - ]); + const [userCount, groupCount, codeCount, domainCount, branding] = + await Promise.all([ + prisma.user.count(), + prisma.group.count(), + prisma.qrCode.count(), + prisma.domain.count(), + getBranding(), + ]); + + const eigenes = [ + branding.logoUploadId && "Logo", + branding.faviconUploadId && "Favicon", + ].filter(Boolean); + const brandingHint = + eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName; return (
@@ -110,6 +120,13 @@ export default async function AdminPage() { description="Festlegen, welche Typen auf der Startseite erscheinen." hint="15 Typen insgesamt" /> +
); diff --git a/src/app/api/admin/branding/route.ts b/src/app/api/admin/branding/route.ts new file mode 100644 index 0000000..3d67a89 --- /dev/null +++ b/src/app/api/admin/branding/route.ts @@ -0,0 +1,96 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +import { getActor } from "@/lib/auth/actor"; +import { requirePermission } from "@/lib/auth/policy"; +import { isHexColor } from "@/lib/branding-colors"; +import { + getBranding, + MAX_APP_NAME, + MAX_FOOTER_TEXT, + saveBranding, +} from "@/lib/branding"; +import { prisma } from "@/lib/db"; +import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { QrRequestError } from "@/lib/qr/type-module"; + +export const dynamic = "force-dynamic"; + +/** `null` löscht das Bild, ein fehlendes Feld lässt es unverändert. */ +const bildSchema = z.string().trim().min(1).max(64).nullable().optional(); + +const updateSchema = z.object({ + appName: z + .string() + .trim() + .min(1, { error: "Bitte gib einen Namen an." }) + .max(MAX_APP_NAME, { + error: `Der Name darf höchstens ${MAX_APP_NAME} Zeichen lang sein.`, + }) + .optional(), + footerText: z + .string() + .trim() + .max(MAX_FOOTER_TEXT, { + error: `Der Text darf höchstens ${MAX_FOOTER_TEXT} Zeichen lang sein.`, + }) + .optional(), + accentColor: z + .string() + .trim() + .refine(isHexColor, { + error: "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.", + }) + .optional(), + logoUploadId: bildSchema, + faviconUploadId: bildSchema, +}); + +export async function GET(): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + return Response.json({ branding: await getBranding() }); + } catch (error) { + return errorResponse(error); + } +} + +/** + * Prüft, dass eine angegebene Datei existiert und wirklich zum + * Erscheinungsbild gehört — sonst ließe sich hier die Datei eines fremden + * QR-Codes einhängen und damit dessen Anzeige zerstören. + */ +async function assertBrandingUpload( + id: string | null | undefined, + feld: string, +): Promise { + if (!id) return; + const upload = await prisma.upload.findUnique({ + where: { id }, + select: { purpose: true }, + }); + if (!upload || upload.purpose !== "branding") { + throw new QrRequestError( + `${feld} wurde nicht gefunden. Bitte lade die Datei erneut hoch.`, + 404, + ); + } +} + +export async function PATCH(request: NextRequest): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + + const parsed = updateSchema.safeParse(await request.json()); + if (!parsed.success) { + return jsonError(firstZodMessage(parsed.error)); + } + + await assertBrandingUpload(parsed.data.logoUploadId, "Das Logo"); + await assertBrandingUpload(parsed.data.faviconUploadId, "Das Favicon"); + + return Response.json({ branding: await saveBranding(parsed.data) }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index e9fa175..ebd524c 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -9,6 +9,7 @@ import { isUploadKind, maxUploadBytes, storeUpload, + uploadPurposeOf, } from "@/lib/uploads"; export const dynamic = "force-dynamic"; @@ -28,10 +29,6 @@ export async function POST(request: NextRequest): Promise { const actor = await getActor(); - // Wer keinen Code anlegen darf, soll auch nichts ablegen können. - const groupId = await resolveTargetGroup(actor, undefined); - requirePermission(actor, "codes:create", { groupId }); - let form: FormData; try { form = await request.formData(); @@ -44,6 +41,15 @@ export async function POST(request: NextRequest): Promise { return jsonError("Unbekannte Dateiart."); } + // Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon + // gehören der Installation, alles Übrige einem Code in einer Gruppe. + if (uploadPurposeOf(kind) === "branding") { + requirePermission(actor, "instance:manage"); + } else { + const groupId = await resolveTargetGroup(actor, undefined); + requirePermission(actor, "codes:create", { groupId }); + } + const file = form.get("file"); if (!(file instanceof File)) { return jsonError("Es wurde keine Datei mitgeschickt."); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eecbed9..3621742 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,28 +1,66 @@ import type { Metadata } from "next"; +import { accentCss } from "@/lib/branding-colors"; +import { getBranding } from "@/lib/branding"; + import "./globals.css"; -export const metadata: Metadata = { - title: { - default: "QR-Code-Generator", - template: "%s · QR-Code-Generator", - }, - description: - "QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.", -}; +// Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf +// vorgerendert werden. +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const branding = await getBranding(); + + return { + title: { + default: branding.appName, + template: `%s · ${branding.appName}`, + }, + description: + "QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.", + // Ohne eigenes Favicon bleibt es bei src/app/favicon.ico. + ...(branding.faviconUploadId + ? { + icons: { + icon: { + url: `/api/uploads/${branding.faviconUploadId}`, + type: branding.faviconMimeType ?? undefined, + }, + }, + } + : {}), + }; +} /** - * Wurzel-Layout — bewusst nur `html` und `body`. + * Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe. * * Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in * `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]` * bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und * „Meine Codes“ nichts zu suchen. + * + * Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen. */ -export default function RootLayout({ children }: LayoutProps<"/">) { +export default async function RootLayout({ children }: LayoutProps<"/">) { + const branding = await getBranding(); + return ( - {children} + + {/* + Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit + wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine + einzige Klasse angefasst werden müsste. Der Inhalt entsteht + ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf + die Voreinstellung zurück. + */} +