Compare commits

..
10 Commits
Author SHA1 Message Date
florian.wilde 660490f1ac Merge feature/v1.9.5: interner Bereich für Mobilgeräte nutzbar 2026-07-20 00:55:13 +02:00
florian.wildeandClaude Sonnet 5 0c69a21a85 feat: interner Bereich für Mobilgeräte nutzbar
AppHeader.tsx: Hamburger-Menü unterhalb "sm" mit denselben Links
gestapelt (Vorschläge-Badge-Logik in gemeinsame renderLinks()-Funktion
ausgelagert), Panel schließt automatisch bei Navigation. Bisherige
einzeilige Navigation bleibt ab "sm" unverändert.

WeekEditor.tsx: Mobile Tag-für-Tag-Ansicht mit Tab-Leiste (Mo-Fr)
statt der 6-spaltigen Tabelle, die bei 375px unbenutzbar gequetscht
war. Zellinhalt (Eintragsliste + "hinzufügen"-Flow) in renderCell()
extrahiert und von Tabelle und Karten-Ansicht gemeinsam genutzt, damit
der Bearbeitungs-Zustand nicht dupliziert werden muss. Desktop-Tabelle
unverändert ab "sm".

DishManager/CodeListManager/UserManager/SectionManager/PasskeyManager:
Kopf- und Listenzeilen brechen jetzt bei Bedarf um (flex-wrap) statt
Buttons abzuschneiden, gleiches Muster wie bereits in
SuggestionReview.tsx vorhanden.

Version 1.9.5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:54:05 +02:00
florian.wilde db868d1174 Merge feature/v1.9: SSO-Login per OpenID Connect (Authentik u.a.) 2026-07-20 00:34:11 +02:00
florian.wildeandClaude Sonnet 5 32ef93ae8e feat: SSO-Login per OpenID Connect (Authentik u.a.)
Dritte Login-Methode neben Passwort und Passkey, konfiguriert über
OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (optional, Button bleibt
ohne diese Vars ausgeblendet). Authorization-Code-Flow mit PKCE,
ID-Token-Signaturprüfung gegen das JWKS des Providers via neuer
Abhängigkeit "jose" (RS256 fest gepinnt gegen Algorithm-Confusion),
State/Nonce kurzlebig und einmalig verwendbar in neuer Tabelle
oidc_states (Migration 0003, analog zu webauthn_challenges).

Funktioniert ausschließlich für bereits angelegte Benutzer (Abgleich
per E-Mail) – kein Auto-Provisioning, keine Rollenübernahme aus
Claims, beides bleibt exklusiv über die bestehende Benutzerverwaltung
steuerbar.

app/login/page.tsx zu Server-Component-Wrapper reduziert (prüft
isConfigured(), liest ?error= aus searchParams), bisheriger Inhalt
nach components/LoginForm.tsx verschoben.

Version 1.9.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:29:57 +02:00
florian.wilde 73cbda65a1 Merge feature/update-script: Update-Skript für Linux-Deployments (update.sh) 2026-07-20 00:08:18 +02:00
florian.wildeandClaude Sonnet 5 3b5fbd1bcd feat: Update-Skript fuer Linux-Deployments (update.sh)
Stoppt den Docker-Stack, sichert lokale Aenderungen an
docker-compose.yml per git stash (falls vorhanden), fuehrt git pull
aus, spielt die Aenderungen zurueck und startet den Stack neu im
Build-Modus. Bricht bei git-pull- oder Stash-Konflikten kontrolliert
ab statt mit einem halb aktualisierten Stand weiterzumachen.

Version 1.8.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:05:56 +02:00
florian.wilde f561ac29ba Merge feature/v1.8: Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe 2026-07-19 23:58:45 +02:00
florian.wildeandClaude Sonnet 5 de8914f9cc feat: Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe
Neue Migration 0002_labeling_notice.sql: settings.labeling_mode
("detailed"/"notice") + labeling_notice(_en/_pl). Auf der Seite
"Allergene & Zusatzstoffe" (LabelingNoticeEditor) kann zwischen der
detaillierten Auswahl je Gericht und einem editierbaren Hinweistext
umgeschaltet werden. Im Hinweismodus entfällt auf der öffentlichen
Speisekarte (Desktop + Mobile) die Legende und die Kürzel an den
Gerichten, in der Gerichte-Verwaltung wird die Chip-Auswahl beim
Bearbeiten ausgeblendet. Umschalten/Bearbeiten ist für staff und admin
freigegeben, Farben in /api/settings bleiben admin-only.

Version 1.8.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:57:43 +02:00
florian.wilde 96eaf3989e Merge feature/v1.7: CSV-Import/-Export, RSS-Fix für mehrere Essenszeiten 2026-07-19 23:14:02 +02:00
florian.wildeandClaude Sonnet 5 474467811c feat: CSV-Import/-Export für Gerichte/Allergene/Zusatzstoffe, RSS-Fix für mehrere Essenszeiten
CSV-Import/-Export (Gerichte, Allergene, Zusatzstoffe): neue Export-/
Import-Routen unter app/api/*/export und */import, hand-geschriebener
CSV-Parser/-Serializer (lib/csv.ts, keine neue Abhängigkeit), Upsert
über Kürzel (Allergene/Zusatzstoffe) bzw. exakten Namen (Gerichte,
mit Ambiguitäts-Check bei Namensdopplung). UI-Buttons in DishManager
und CodeListManager.

RSS-Feed: <content:encoded> zusätzlich zu <description> ausgeben,
da mehrere Feed-Reader description als kürzbare Vorschau behandeln
und dadurch bei mehreren Essenszeiten pro Tag nur die erste zeigten.

Version 1.7.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:10:36 +02:00
39 changed files with 1958 additions and 368 deletions
+12
View File
@@ -48,3 +48,15 @@ ADMIN_EMAIL=
# Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen" # Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen"
# inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen. # inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen.
DEEPL_API_KEY= DEEPL_API_KEY=
# Optional: SSO-Login per OpenID Connect (z. B. gegen ein selbst
# gehostetes Authentik). Ohne diese drei Werte bleibt der SSO-Button auf
# der Anmeldeseite ausgeblendet, Passwort/Passkey funktionieren
# unverändert. SSO-Login funktioniert NUR für bereits unter "Benutzer"
# angelegte E-Mail-Adressen – es werden keine neuen Konten angelegt und
# die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich dort
# steuerbar. Beim Identity Provider als Redirect-URI eintragen:
# <APP_URL>/api/auth/oidc/callback
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
+89
View File
@@ -4,6 +4,95 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.9.5] – 2026-07-20
### Hinzugefügt
- Interner Bereich (Wochenplan, Essenszeiten, Gerichte, Allergene &
Zusatzstoffe, Vorschläge, Benutzer, Design, Mein Konto) für
Mobilgeräte nutzbar gemacht. `AppHeader.tsx` bekommt unterhalb der
`sm`-Breakpoint-Grenze ein Hamburger-Menü mit denselben Links
gestapelt statt der bisherigen, bei schmalen Bildschirmen unübersichtlich
umbrechenden einzeiligen Navigation. Der Wochenplan (`WeekEditor.tsx`)
zeigt auf Mobilgeräten eine Tag-für-Tag-Ansicht mit Tab-Leiste
(Mo–Fr) statt der für 375px viel zu schmalen 6-spaltigen Tabelle –
Gericht hinzufügen/löschen funktioniert unverändert, da beide
Ansichten denselben Bearbeitungs-Zustand teilen. In DishManager,
CodeListManager, UserManager, SectionManager und PasskeyManager
brechen Kopf- und Listenzeilen jetzt bei Bedarf um, statt Buttons
abzuschneiden.
## [1.9.0] – 2026-07-20
### Hinzugefügt
- SSO-Login per OpenID Connect (z. B. gegen ein selbst gehostetes
Authentik), zusätzlich zu Passwort und Passkey. Konfiguration über
drei optionale Env-Vars (`OIDC_ISSUER`, `OIDC_CLIENT_ID`,
`OIDC_CLIENT_SECRET`) – ohne sie bleibt der SSO-Button auf der
Anmeldeseite ausgeblendet. Authorization-Code-Flow mit PKCE, ID-Token-
Signaturprüfung gegen das JWKS des Providers (neue Abhängigkeit
`jose`), State/Nonce serverseitig kurzlebig und einmalig verwendbar
(neue Tabelle `oidc_states`, Migration `0003_oidc_state.sql`).
SSO-Login funktioniert ausschließlich für bereits unter „Benutzer“
angelegte E-Mail-Adressen – es werden nie neue Konten angelegt, und
die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich über die
bestehende Benutzerverwaltung steuerbar, nie aus SSO-Claims
übernommen. In DEPLOYMENT.md unter „SSO/OpenID Connect“ dokumentiert.
## [1.8.1] – 2026-07-20
### Hinzugefügt
- `update.sh`: Update-Skript für Linux-Deployments – stoppt den Docker-
Stack, sichert lokale Änderungen an `docker-compose.yml` per
`git stash` (falls vorhanden), führt `git pull` aus, spielt die
Änderungen zurück und startet den Stack neu im Build-Modus. Bricht
bei einem `git pull`- oder Stash-Konflikt kontrolliert ab, statt mit
einem halb aktualisierten Stand weiterzumachen. In DEPLOYMENT.md unter
„Updates einspielen“ dokumentiert.
## [1.8.0] – 2026-07-19
### Hinzugefügt
- Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe: Auf der Seite
„Allergene & Zusatzstoffe“ kann zwischen der bisherigen detaillierten
Auswahl je Gericht und einem einfachen, in Deutsch/Englisch/Polnisch
editierbaren Hinweistext (z. B. „Bei Fragen zu Allergenen und
Zusatzstoffen wenden Sie sich bitte an das Kantinen-Team.“)
umgeschaltet werden. Im Hinweismodus entfällt auf der öffentlichen
Speisekarte die Legende und die Kürzel an den Gerichten, und in der
Gerichte-Verwaltung wird die Allergen-/Zusatzstoff-Auswahl beim
Bearbeiten ausgeblendet (bereits zugeordnete Daten bleiben in der
Datenbank erhalten). Umschalten und Hinweistext bearbeiten dürfen
sowohl Kantinenpersonal als auch Admins.
## [1.7.0] – 2026-07-19
### Hinzugefügt
- CSV-Import/-Export für Gerichte, Allergene und Zusatzstoffe (Buttons
„Exportieren“/„Importieren“ auf den jeweiligen Verwaltungsseiten).
Export lädt die aktuelle Liste als CSV herunter (Gerichte:
`name,name_en,name_pl,price,allergens,additives`, Allergene/
Zusatzstoffe: `code,name,name_en,name_pl`; Kürzel bei Gerichten
Semikolon-getrennt in einer Zelle, z. B. `A;G`). Import liest dieselbe
Spaltenstruktur (Reihenfolge egal, Groß-/Kleinschreibung der
Kopfzeile wird ignoriert) und aktualisiert bestehende Einträge
(Abgleich über Kürzel bei Allergenen/Zusatzstoffen, über den exakten
Namen bei Gerichten) bzw. legt neue an. Zeilen mit fehlenden
Pflichtfeldern, ungültigem Preis oder mehrdeutigem Gerichtsnamen
(mehrere Gerichte mit demselben Namen) werden übersprungen und mit
Grund in der Erfolgsmeldung aufgelistet, statt den ganzen Import
abzubrechen.
### Behoben
- RSS-Feed (`/feed.xml`): Mehrere Feed-Reader (u. a. Outlook,
Thunderbird) behandeln `<description>` als kurze Vorschau und kürzen
sie eigenmächtig – dadurch war bei Tagen mit mehreren Essenszeiten
oft nur die erste sichtbar, obwohl der Server bereits alle
Essenszeiten des Tages in der Beschreibung lieferte. Zusätzlich zu
`<description>` wird jetzt auch `<content:encoded>` (RSS-Content-
Modul) mit demselben vollständigen Inhalt ausgeliefert – von den
meisten Readern als vollständiger Artikeltext erkannt und ungekürzt
angezeigt.
## [1.6.3] – 2026-07-18 ## [1.6.3] – 2026-07-18
### Behoben ### Behoben
+35
View File
@@ -149,6 +149,32 @@ docker compose --env-file .env.local up -d
werden im Kantinen-Bereich gepflegt. werden im Kantinen-Bereich gepflegt.
- Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen. - Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen.
## SSO/OpenID Connect (optional)
Zusätzlich zu Passwort und Passkey kann ein SSO-Login gegen einen
beliebigen OpenID-Connect-Provider aktiviert werden (z. B. ein selbst
gehostetes [Authentik](https://goauthentik.io/)). Dazu in `.env.local`
setzen:
```bash
OIDC_ISSUER=https://authentik.example.de/application/o/canteen-menu/
OIDC_CLIENT_ID=<client-id-aus-authentik>
OIDC_CLIENT_SECRET=<client-secret-aus-authentik>
```
Beim Identity Provider als Redirect-URI hinterlegen:
`<APP_URL>/api/auth/oidc/callback` (also z. B.
`https://menu.example.de/api/auth/oidc/callback`).
**Wichtig:** SSO-Login funktioniert ausschließlich für E-Mail-Adressen,
die bereits unter „Benutzer“ angelegt sind – es werden nie neue Konten
angelegt, und die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich
dort steuerbar. Ein Mitarbeiter muss also zuerst ganz normal als
Benutzer angelegt werden, bevor er sich per SSO anmelden kann.
Ohne die drei Variablen bleibt der SSO-Button auf der Anmeldeseite
einfach ausgeblendet, Passwort/Passkey funktionieren unverändert.
## Fehlerbehebung ## Fehlerbehebung
### `password authentication failed for user "postgres"` in den App-Logs ### `password authentication failed for user "postgres"` in den App-Logs
@@ -225,6 +251,15 @@ Neue Datenbank-Migrationen (`db/migrations/*.sql`) werden beim Start
automatisch und idempotent angewendet, bestehende Daten bleiben automatisch und idempotent angewendet, bestehende Daten bleiben
erhalten. erhalten.
Alternativ übernimmt `./update.sh` den kompletten Ablauf: Stack stoppen,
lokale Änderungen an `docker-compose.yml` per `git stash` sichern (falls
vorhanden), `git pull`, Änderungen zurückspielen, Stack neu bauen und
starten:
```bash
./update.sh
```
## Backup ## Backup
Zwei Docker-Volumes enthalten alle persistenten Daten: Zwei Docker-Volumes enthalten alle persistenten Daten:
+24
View File
@@ -0,0 +1,24 @@
import { listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const items = await listCodes("additives");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="zusatzstoffe.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const codeIdx = header.indexOf("code");
const nameIdx = header.indexOf("name");
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const code = (c[codeIdx] ?? "").trim();
const name = (c[nameIdx] ?? "").trim();
if (!code || !name) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
rows.push({
code,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
});
});
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 };
return NextResponse.json({ ...result, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const items = await listCodes("allergens");
const rows = [
["code", "name", "name_en", "name_pl"],
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="allergene.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader } from "@/lib/csv";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const codeIdx = header.indexOf("code");
const nameIdx = header.indexOf("name");
if (codeIdx === -1 || nameIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
const rows: CodeImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const code = (c[codeIdx] ?? "").trim();
const name = (c[nameIdx] ?? "").trim();
if (!code || !name) {
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
return;
}
rows.push({
code,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
});
});
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
return NextResponse.json({ ...result, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
import { isConfigured } from "@/lib/auth/oidc-config";
import { consumeState } from "@/lib/auth/oidc-state";
import { discover, exchangeCode, resolveEmail, verifyIdToken } from "@/lib/auth/oidc-client";
import type { Role } from "@/lib/types";
function errorRedirect(request: Request, code: string): NextResponse {
return NextResponse.redirect(new URL(`/login?error=${code}`, request.url));
}
// GET statt POST: der Identity Provider leitet den Browser per
// Redirect hierher (Cross-Origin-Navigation) – die übliche
// isSameOrigin()-CSRF-Prüfung greift hier nicht und wird bewusst nicht
// verwendet. Die Absicherung ist der einmalig verwendbare state-
// Parameter (Standard-OIDC-Mechanismus, siehe lib/auth/oidc-state.ts).
export async function GET(request: Request) {
if (!isConfigured()) {
return errorRedirect(request, "oidc_failed");
}
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const idpError = url.searchParams.get("error");
if (idpError || !code || !state) {
return errorRedirect(request, "oidc_failed");
}
const stored = await consumeState(state);
if (!stored) {
return errorRedirect(request, "oidc_invalid_state");
}
try {
const discovery = await discover();
const { idToken, accessToken } = await exchangeCode(discovery, code, stored.codeVerifier);
const payload = await verifyIdToken(discovery, idToken, stored.nonce);
const email = await resolveEmail(discovery, payload, accessToken);
if (!email) {
return errorRedirect(request, "oidc_failed");
}
const user = await queryOne<{ id: string; role: Role }>(
"select id, role from users where email = $1",
[email.trim().toLowerCase()]
);
if (!user) {
return errorRedirect(request, "oidc_unknown_user");
}
await createSession(user.id);
return NextResponse.redirect(
new URL(user.role === "admin" ? "/admin" : "/intern", request.url)
);
} catch {
return errorRedirect(request, "oidc_failed");
}
}
+31
View File
@@ -0,0 +1,31 @@
import { createHash, randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { isConfigured } from "@/lib/auth/oidc-config";
import { storeState } from "@/lib/auth/oidc-state";
import { buildAuthorizeUrl, discover } from "@/lib/auth/oidc-client";
// Bewusst ein einfacher GET-Link statt eines CSRF-gesicherten POSTs:
// der Browser muss hier auf die fremde Login-Seite des Identity
// Providers navigieren (funktioniert nicht per fetch/XHR) – identisch
// zum Muster jeder "Login mit X"-Schaltfläche. Die Absicherung gegen
// Cross-Site-Missbrauch übernimmt der state-Parameter im Callback.
export async function GET(request: Request) {
if (!isConfigured()) {
return NextResponse.json({ error: "oidc_not_configured" }, { status: 404 });
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
try {
const discovery = await discover();
await storeState(state, nonce, codeVerifier);
return NextResponse.redirect(
buildAuthorizeUrl(discovery, { state, nonce, codeChallenge })
);
} catch {
return NextResponse.redirect(new URL("/login?error=oidc_failed", request.url));
}
}
+32
View File
@@ -0,0 +1,32 @@
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { toCsv } from "@/lib/csv";
import type { Dish } from "@/lib/types";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
const dishes = await query<Dish>("select * from dishes order by name");
const rows = [
["name", "name_en", "name_pl", "price", "allergens", "additives"],
...dishes.map((d) => [
d.name,
d.name_en ?? "",
d.name_pl ?? "",
String(d.price),
(d.allergens ?? []).join(";"),
(d.additives ?? []).join(";"),
]),
];
return new Response(toCsv(rows), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="gerichte.csv"',
},
});
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
interface ImportRow {
rowNum: number;
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const { header, rows: cells } = parseCsvWithHeader(await file.text());
const nameIdx = header.indexOf("name");
const priceIdx = header.indexOf("price");
if (nameIdx === -1 || priceIdx === -1) {
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
}
const nameEnIdx = header.indexOf("name_en");
const namePlIdx = header.indexOf("name_pl");
const allergensIdx = header.indexOf("allergens");
const additivesIdx = header.indexOf("additives");
const rows: ImportRow[] = [];
const skipped: { row: number; reason: string }[] = [];
cells.forEach((c, i) => {
const rowNum = i + 2; // 1-indexiert + Kopfzeile
const name = (c[nameIdx] ?? "").trim();
if (!name) {
skipped.push({ row: rowNum, reason: "Name fehlt" });
return;
}
const priceRaw = (c[priceIdx] ?? "").trim().replace(",", ".");
const price = Number.parseFloat(priceRaw);
if (Number.isNaN(price) || price < 0) {
skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
return;
}
rows.push({
rowNum,
name,
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
price,
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
});
});
let created = 0;
let updated = 0;
if (rows.length > 0) {
await withTransaction(async (client) => {
for (const row of rows) {
// Gerichtsnamen sind nicht eindeutig (keine DB-Constraint) – bei
// mehreren Treffern lieber überspringen als versehentlich alle
// gleichnamigen Gerichte mit denselben Werten zu überschreiben.
const existing = await client.query<{ id: string }>(
"select id from dishes where name = $1",
[row.name]
);
if (existing.rows.length > 1) {
skipped.push({
row: row.rowNum,
reason: `„${row.name}“ ist mehrdeutig (mehrere Gerichte mit diesem Namen) – übersprungen`,
});
continue;
}
if (existing.rows.length === 1) {
await client.query(
`update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5
where id = $6`,
[row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id]
);
updated++;
} else {
await client.query(
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6)`,
[row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives]
);
created++;
}
}
});
}
return NextResponse.json({ created, updated, skipped });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+48 -4
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { getSettings } from "@/lib/db/settings"; import { getSettings, type LabelingMode } from "@/lib/db/settings";
import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
@@ -8,14 +8,58 @@ export async function GET() {
return NextResponse.json(await getSettings()); return NextResponse.json(await getSettings());
} }
interface SettingsPatchBody {
colors?: Record<string, string>;
labeling_mode?: LabelingMode;
labeling_notice?: string;
labeling_notice_en?: string | null;
labeling_notice_pl?: string | null;
}
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
if (!isSameOrigin(request)) { if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
} }
try { try {
await requireRole(["admin"]); // Farben bleiben admin-only wie bisher; die Allergen-/Zusatzstoff-
const { colors } = (await request.json()) as { colors: Record<string, string> }; // Anzeige darf auch Kantinenpersonal umschalten/bearbeiten.
await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]); const session = await requireRole(["staff", "admin"]);
const body = (await request.json()) as SettingsPatchBody;
const sets: string[] = [];
const params: unknown[] = [];
if (body.colors !== undefined) {
if (session.user.role !== "admin") {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
params.push(JSON.stringify(body.colors));
sets.push(`colors = $${params.length}`);
}
if (body.labeling_mode !== undefined) {
if (body.labeling_mode !== "detailed" && body.labeling_mode !== "notice") {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
params.push(body.labeling_mode);
sets.push(`labeling_mode = $${params.length}`);
}
if (body.labeling_notice !== undefined) {
params.push(body.labeling_notice);
sets.push(`labeling_notice = $${params.length}`);
}
if (body.labeling_notice_en !== undefined) {
params.push(body.labeling_notice_en);
sets.push(`labeling_notice_en = $${params.length}`);
}
if (body.labeling_notice_pl !== undefined) {
params.push(body.labeling_notice_pl);
sets.push(`labeling_notice_pl = $${params.length}`);
}
if (sets.length === 0) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
await query(`update settings set ${sets.join(", ")} where id = 1`, params);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (err) { } catch (err) {
const res = authErrorResponse(err); const res = authErrorResponse(err);
+9 -1
View File
@@ -80,12 +80,20 @@ export async function GET() {
<guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid> <guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
<pubDate>${pubDate}</pubDate> <pubDate>${pubDate}</pubDate>
<description><![CDATA[${dayContent}]]></description> <description><![CDATA[${dayContent}]]></description>
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
</item>`; </item>`;
}) })
.join("\n"); .join("\n");
// "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook,
// etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig
// gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur
// die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als
// vollständiger Artikeltext und wird von den meisten Readern
// ungekürzt angezeigt, wenn vorhanden – redundant zu description für
// Reader ohne Unterstützung des Moduls.
const xml = `<?xml version="1.0" encoding="UTF-8"?> const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel> <channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title> <title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link> <link>${appUrl}</link>
+7 -3
View File
@@ -1,11 +1,15 @@
import CodeListManager from "@/components/CodeListManager"; import CodeListManager from "@/components/CodeListManager";
import LabelingNoticeEditor from "@/components/LabelingNoticeEditor";
export default function KennzeichnungPage() { export default function KennzeichnungPage() {
const translateEnabled = !!process.env.DEEPL_API_KEY; const translateEnabled = !!process.env.DEEPL_API_KEY;
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="flex flex-col gap-6">
<CodeListManager table="allergens" translateEnabled={translateEnabled} /> <LabelingNoticeEditor />
<CodeListManager table="additives" translateEnabled={translateEnabled} /> <div className="grid gap-6 lg:grid-cols-2">
<CodeListManager table="allergens" translateEnabled={translateEnabled} />
<CodeListManager table="additives" translateEnabled={translateEnabled} />
</div>
</div> </div>
); );
} }
+9 -143
View File
@@ -1,145 +1,11 @@
"use client"; import LoginForm from "@/components/LoginForm";
import { isConfigured } from "@/lib/auth/oidc-config";
import { useState } from "react"; export default async function LoginPage({
import { useRouter } from "next/navigation"; searchParams,
import Link from "next/link"; }: {
import { startAuthentication } from "@simplewebauthn/browser"; searchParams: Promise<{ error?: string }>;
import { formatMessage } from "@/lib/i18n/shared"; }) {
import { useDict } from "@/components/i18n/LocaleProvider"; const { error } = await searchParams;
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; return <LoginForm oidcEnabled={isConfigured()} initialError={error ?? null} />;
import type { Dict } from "@/lib/i18n/shared";
function authErrorMessage(dict: Dict, code: string): string {
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (/cancelled|aborted|NotAllowedError/i.test(code))
return dict.login.errorPasskeyCancelled;
return formatMessage(dict.login.errorGeneric, { message: code });
}
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
}
export default function LoginPage() {
const router = useRouter();
const dict = useDict();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function signInWithPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
}
async function signInWithPasskey() {
setError(null);
setBusy(true);
try {
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
const data = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
} catch (err) {
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false);
}
}
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label className="label" htmlFor="email">
{dict.common.email}
</label>
<input
id="email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username webauthn"
required
/>
</div>
<div>
<label className="label" htmlFor="password">
{dict.common.password}
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{dict.login.submit}
</button>
</form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
{dict.login.or}
<span className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={signInWithPasskey}
className="btn btn-neutral w-full"
disabled={busy}
>
{dict.login.passkeyButton}
</button>
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.login.backToMenu}
</Link>
</p>
</div>
</div>
);
} }
+60 -31
View File
@@ -42,6 +42,7 @@ export default async function Home({
]); ]);
const logo = logoUrl(settings?.logo_path); const logo = logoUrl(settings?.logo_path);
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
return ( return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full"> <div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
@@ -99,7 +100,12 @@ export default async function Home({
</header> </header>
{/* Mobil: Tag (Standard) oder Woche umschaltbar */} {/* Mobil: Tag (Standard) oder Woche umschaltbar */}
<PublicMobileMenu monday={monday} sections={sections} entries={entries} /> <PublicMobileMenu
monday={monday}
sections={sections}
entries={entries}
showLabeling={labelingDetailed}
/>
{/* Ab "sm": klassische Wochentabelle */} {/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block"> <div className="hidden sm:block">
@@ -148,7 +154,9 @@ export default async function Home({
<span className="font-bold"> <span className="font-bold">
{localized(e.dish, locale)} {localized(e.dish, locale)}
</span> </span>
{codes && <sup className="dish-codes">{codes}</sup>} {labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
)}
{" – "} {" – "}
{formatPrice(e.dish.price)} {formatPrice(e.dish.price)}
</div> </div>
@@ -163,35 +171,56 @@ export default async function Home({
</div> </div>
</div> </div>
{/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */} {/* Allergene & Zusatzstoffe: entweder Legende zu den Kürzeln bei den
<section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700"> Gerichten, oder – falls im Hinweismodus – nur ein Hinweistext. */}
<div> {labelingDetailed ? (
<h2 className="font-bold text-[var(--c-primary)] mb-2"> <section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700">
{dict.publicMenu.allergens} <div>
</h2> <h2 className="font-bold text-[var(--c-primary)] mb-2">
<ul className="space-y-0.5"> {dict.publicMenu.allergens}
{allergens.map((a) => ( </h2>
<li key={a.code}> <ul className="space-y-0.5">
<span className="font-semibold">{a.code}</span> –{" "} {allergens.map((a) => (
{localized(a, locale)} <li key={a.code}>
</li> <span className="font-semibold">{a.code}</span> –{" "}
))} {localized(a, locale)}
</ul> </li>
</div> ))}
<div> </ul>
<h2 className="font-bold text-[var(--c-primary)] mb-2"> </div>
{dict.publicMenu.additives} <div>
</h2> <h2 className="font-bold text-[var(--c-primary)] mb-2">
<ul className="space-y-0.5"> {dict.publicMenu.additives}
{additives.map((a) => ( </h2>
<li key={a.code}> <ul className="space-y-0.5">
<span className="font-semibold">{a.code}</span> –{" "} {additives.map((a) => (
{localized(a, locale)} <li key={a.code}>
</li> <span className="font-semibold">{a.code}</span> –{" "}
))} {localized(a, locale)}
</ul> </li>
</div> ))}
</section> </ul>
</div>
</section>
) : (
settings && (
<section className="mt-8 text-sm text-gray-700">
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{dict.publicMenu.labelingNoticeTitle}
</h2>
<p>
{localized(
{
name: settings.labeling_notice,
name_en: settings.labeling_notice_en,
name_pl: settings.labeling_notice_pl,
},
locale
)}
</p>
</section>
)
)}
</div> </div>
); );
} }
+114 -24
View File
@@ -3,7 +3,8 @@
// Gemeinsamer Kopfbereich für alle internen Seiten. Statt zweier // Gemeinsamer Kopfbereich für alle internen Seiten. Statt zweier
// getrennter Bereiche (Kantine/Verwaltung) gibt es eine Navigation: // getrennter Bereiche (Kantine/Verwaltung) gibt es eine Navigation:
// Admins sehen zusätzlich die Verwaltungs-Links, der aktive Punkt // Admins sehen zusätzlich die Verwaltungs-Links, der aktive Punkt
// wird hervorgehoben. // wird hervorgehoben. Ab "sm" die klassische einzeilige Navigation,
// darunter ein Hamburger-Menü mit denselben Links gestapelt.
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -13,6 +14,11 @@ import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider"; import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
interface NavLink {
href: string;
label: string;
}
export default function AppHeader({ export default function AppHeader({
role, role,
displayName, displayName,
@@ -23,6 +29,7 @@ export default function AppHeader({
const pathname = usePathname(); const pathname = usePathname();
const dict = useDict(); const dict = useDict();
const [suggestionCount, setSuggestionCount] = useState(0); const [suggestionCount, setSuggestionCount] = useState(0);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetch("/api/dish-suggestions") fetch("/api/dish-suggestions")
@@ -31,14 +38,19 @@ export default function AppHeader({
.catch(() => {}); .catch(() => {});
}, [pathname]); }, [pathname]);
const menuLinks = [ // Hamburger-Panel bei jeder Navigation automatisch schließen.
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
const menuLinks: NavLink[] = [
{ href: "/intern", label: dict.header.weekPlan }, { href: "/intern", label: dict.header.weekPlan },
{ href: "/intern/essenszeiten", label: dict.header.sections }, { href: "/intern/essenszeiten", label: dict.header.sections },
{ href: "/intern/gerichte", label: dict.header.dishes }, { href: "/intern/gerichte", label: dict.header.dishes },
{ href: "/intern/kennzeichnung", label: dict.header.labels }, { href: "/intern/kennzeichnung", label: dict.header.labels },
{ href: "/intern/vorschlaege", label: dict.header.suggestions }, { href: "/intern/vorschlaege", label: dict.header.suggestions },
]; ];
const adminLinks = [ const adminLinks: NavLink[] = [
{ href: "/admin", label: dict.header.users }, { href: "/admin", label: dict.header.users },
{ href: "/admin/design", label: dict.header.design }, { href: "/admin/design", label: dict.header.design },
]; ];
@@ -48,9 +60,105 @@ export default function AppHeader({
pathname === href ? "bg-white/20 font-semibold" : "text-white/90" pathname === href ? "bg-white/20 font-semibold" : "text-white/90"
}`; }`;
// Von Desktop- und Mobile-Navigation gemeinsam genutzt, damit die
// Vorschläge-Badge-Logik nicht doppelt gepflegt werden muss.
function renderLinks(links: NavLink[]) {
return links.map((l) => (
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
{l.label}
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
aria-label={formatMessage(dict.header.openSuggestions, {
count: suggestionCount,
})}
>
{suggestionCount}
</span>
)}
</Link>
));
}
return ( return (
<header className="bg-[var(--c-primary-dark)] text-white"> <header className="bg-[var(--c-primary-dark)] text-white">
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm"> {/* Mobil (< "sm"): kompakte Kopfzeile mit Hamburger-Toggle */}
<div className="sm:hidden flex items-center justify-between px-4 py-3">
<span className="font-bold text-base">{dict.header.canteen}</span>
<button
type="button"
className="p-1 -m-1"
onClick={() => setMobileOpen((v) => !v)}
aria-label={dict.header.menuToggle}
aria-expanded={mobileOpen}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
{mobileOpen ? (
<path d="M6 6l12 12M18 6L6 18" />
) : (
<path d="M4 7h16M4 12h16M4 17h16" />
)}
</svg>
</button>
</div>
{mobileOpen && (
<div className="sm:hidden px-4 pb-4 flex flex-col gap-4 text-sm border-t border-white/10">
<nav className="flex flex-col gap-1" aria-label={dict.header.menuGroup}>
<span className="mt-3 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{renderLinks(menuLinks)}
</nav>
{role === "admin" && (
<nav className="flex flex-col gap-1" aria-label={dict.header.adminGroup}>
<span className="text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup}
</span>
{renderLinks(adminLinks)}
</nav>
)}
<Link
href="/"
className="w-fit rounded px-2 py-0.5 text-white/70 hover:bg-white/10"
>
{dict.header.viewMenu}
</Link>
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
<LanguageSwitcher />
<Link
href="/konto"
className={`rounded px-2 py-0.5 hover:bg-white/10 ${
pathname === "/konto" ? "bg-white/20 font-semibold" : "text-white/80"
}`}
>
{displayName}
</Link>
</div>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del w-full">
{dict.common.logout}
</button>
</form>
</div>
)}
{/* Ab "sm": bisherige einzeilige Navigation */}
<div className="hidden sm:flex max-w-[1600px] mx-auto w-full px-4 py-3 flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<span className="font-bold text-base">{dict.header.canteen}</span> <span className="font-bold text-base">{dict.header.canteen}</span>
<nav <nav
@@ -60,21 +168,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50"> <span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup} {dict.header.menuGroup}
</span> </span>
{menuLinks.map((l) => ( {renderLinks(menuLinks)}
<Link key={l.href} href={l.href} className={`relative ${linkClass(l.href)}`}>
{l.label}
{l.href === "/intern/vorschlaege" && suggestionCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--c-del)] px-1 text-[10px] font-bold leading-none text-white"
aria-label={formatMessage(dict.header.openSuggestions, {
count: suggestionCount,
})}
>
{suggestionCount}
</span>
)}
</Link>
))}
</nav> </nav>
{role === "admin" && ( {role === "admin" && (
@@ -85,11 +179,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50"> <span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup} {dict.header.adminGroup}
</span> </span>
{adminLinks.map((l) => ( {renderLinks(adminLinks)}
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
</nav> </nav>
)} )}
+70 -2
View File
@@ -34,6 +34,8 @@ export default function CodeListManager({
namePl: string; namePl: string;
} | null>(null); } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">( const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle" "idle"
); );
@@ -125,12 +127,77 @@ export default function CodeListManager({
} }
} }
async function exportItems() {
setError(null);
setNotice(null);
const res = await fetch(`/api/${table}/export`);
if (!res.ok) {
setError(formatMessage(dict.codeList.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = table === "allergens" ? "allergene.csv" : "zusatzstoffe.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importItems(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/${table}/import`, { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.codeList.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.codeList.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.codeList.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.codeList.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
return ( return (
<section className="card"> <section className="card">
<div className="flex items-center gap-3 mb-3"> <div className="flex flex-wrap items-center gap-3 mb-3">
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto"> <h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
{title} {title}
</h2> </h2>
<button type="button" className="btn btn-neutral" onClick={exportItems}>
{dict.codeList.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.codeList.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importItems}
disabled={ioBusy}
/>
</label>
<button <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
@@ -148,6 +215,7 @@ export default function CodeListManager({
{error} {error}
</p> </p>
)} )}
{notice && <p className="mb-2 text-sm text-green-700">{notice}</p>}
{editing && ( {editing && (
<form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3"> <form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3">
@@ -223,7 +291,7 @@ export default function CodeListManager({
<ul className="divide-y divide-gray-100"> <ul className="divide-y divide-gray-100">
{items.map((item) => ( {items.map((item) => (
<li key={item.code} className="flex items-center gap-2 py-1.5 text-sm"> <li key={item.code} className="flex flex-wrap items-center gap-2 py-1.5 text-sm">
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold">{item.code}</span> –{" "} <span className="font-semibold">{item.code}</span> –{" "}
{localized(item, locale)} {localized(item, locale)}
+96 -16
View File
@@ -40,10 +40,13 @@ export default function DishManager({
const [additives, setAdditives] = useState<CodeItem[]>([]); const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null); const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [labelingDetailed, setLabelingDetailed] = useState(true);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">( const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle" "idle"
); );
@@ -55,10 +58,11 @@ export default function DishManager({
}, [dishes, search]); }, [dishes, search]);
const load = useCallback(async () => { const load = useCallback(async () => {
const [dishesRes, allergensRes, additivesRes] = await Promise.all([ const [dishesRes, allergensRes, additivesRes, settingsRes] = await Promise.all([
fetch("/api/dishes"), fetch("/api/dishes"),
fetch("/api/allergens"), fetch("/api/allergens"),
fetch("/api/additives"), fetch("/api/additives"),
fetch("/api/settings"),
]); ]);
if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) { if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) {
setError(formatMessage(dict.dishManager.loadError, { message: "load failed" })); setError(formatMessage(dict.dishManager.loadError, { message: "load failed" }));
@@ -67,6 +71,10 @@ export default function DishManager({
setAllergens(await allergensRes.json()); setAllergens(await allergensRes.json());
setAdditives(await additivesRes.json()); setAdditives(await additivesRes.json());
} }
if (settingsRes.ok) {
const settings = await settingsRes.json();
setLabelingDetailed((settings?.labeling_mode ?? "detailed") !== "notice");
}
setLoading(false); setLoading(false);
}, [dict]); }, [dict]);
@@ -219,15 +227,80 @@ export default function DishManager({
} }
} }
async function exportDishes() {
setError(null);
setNotice(null);
const res = await fetch("/api/dishes/export");
if (!res.ok) {
setError(formatMessage(dict.dishManager.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "gerichte.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importDishes(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/dishes/import", { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.dishManager.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.dishManager.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.dishManager.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.dishManager.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
const allSelected = const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id)); visible.length > 0 && visible.every((d) => selected.has(d.id));
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.dishManager.title} {dict.dishManager.title}
</h1> </h1>
<button type="button" className="btn btn-neutral" onClick={exportDishes}>
{dict.dishManager.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.dishManager.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importDishes}
disabled={ioBusy}
/>
</label>
<button <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
@@ -245,6 +318,7 @@ export default function DishManager({
{error} {error}
</p> </p>
)} )}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && ( {form && (
<form onSubmit={save} className="card mb-4 space-y-4"> <form onSubmit={save} className="card mb-4 space-y-4">
@@ -322,18 +396,24 @@ export default function DishManager({
</div> </div>
)} )}
<ChipPicker {labelingDetailed ? (
label={dict.codeList.allergensTitle} <>
items={allergens} <ChipPicker
selected={form.allergens} label={dict.codeList.allergensTitle}
onToggle={toggleAllergen} items={allergens}
/> selected={form.allergens}
<ChipPicker onToggle={toggleAllergen}
label={dict.codeList.additivesTitle} />
items={additives} <ChipPicker
selected={form.additives} label={dict.codeList.additivesTitle}
onToggle={toggleAdditive} items={additives}
/> selected={form.additives}
onToggle={toggleAdditive}
/>
</>
) : (
<p className="text-sm text-gray-500">{dict.dishManager.labelingDisabledHint}</p>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className="btn btn-add"> <button type="submit" className="btn btn-add">
{dict.common.save} {dict.common.save}
@@ -364,7 +444,7 @@ export default function DishManager({
/> />
)} )}
{dishes.length > 0 && ( {dishes.length > 0 && (
<div className="flex items-center gap-3 mb-2 text-sm"> <div className="flex flex-wrap items-center gap-3 mb-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -394,7 +474,7 @@ export default function DishManager({
{visible.map((dish) => { {visible.map((dish) => {
const codes = dishCodes(dish); const codes = dishCodes(dish);
return ( return (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2"> <li key={dish.id} className="flex flex-wrap items-center gap-3 px-4 py-2">
<input <input
type="checkbox" type="checkbox"
checked={selected.has(dish.id)} checked={selected.has(dish.id)}
+142
View File
@@ -0,0 +1,142 @@
"use client";
// Umschaltung zwischen der detaillierten Allergen-/Zusatzstoff-Auswahl
// je Gericht und einem einfachen, editierbaren Hinweistext.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import type { LabelingMode } from "@/lib/db/settings";
export default function LabelingNoticeEditor() {
const dict = useDict();
const [mode, setMode] = useState<LabelingMode>("detailed");
const [noticeDe, setNoticeDe] = useState("");
const [noticeEn, setNoticeEn] = useState("");
const [noticePl, setNoticePl] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const load = useCallback(async () => {
const res = await fetch("/api/settings");
if (!res.ok) {
setError(formatMessage(dict.labelingNotice.loadError, { message: String(res.status) }));
setLoading(false);
return;
}
const data = await res.json();
setMode(data.labeling_mode ?? "detailed");
setNoticeDe(data.labeling_notice ?? "");
setNoticeEn(data.labeling_notice_en ?? "");
setNoticePl(data.labeling_notice_pl ?? "");
setLoading(false);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function save() {
setBusy(true);
setError(null);
setInfo(null);
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
labeling_mode: mode,
labeling_notice: noticeDe.trim(),
labeling_notice_en: noticeEn.trim() || null,
labeling_notice_pl: noticePl.trim() || null,
}),
});
setBusy(false);
if (!res.ok) {
setError(formatMessage(dict.labelingNotice.saveError, { message: String(res.status) }));
return;
}
setInfo(dict.labelingNotice.saved);
}
if (loading) return null;
return (
<section className="card">
<h2 className="font-semibold mb-2">{dict.labelingNotice.title}</h2>
<p className="text-sm text-gray-600 mb-3">{dict.labelingNotice.hint}</p>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{info && <p className="mb-3 text-sm text-green-700">{info}</p>}
<div className="flex flex-col gap-2 mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="labeling-mode"
checked={mode === "detailed"}
onChange={() => setMode("detailed")}
/>
{dict.labelingNotice.modeDetailed}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="labeling-mode"
checked={mode === "notice"}
onChange={() => setMode("notice")}
/>
{dict.labelingNotice.modeNotice}
</label>
</div>
<div className="space-y-3 mb-4">
<div>
<label className="label" htmlFor="labeling-notice-de">
{dict.labelingNotice.noticeDeLabel}
</label>
<textarea
id="labeling-notice-de"
className="input"
rows={2}
value={noticeDe}
onChange={(e) => setNoticeDe(e.target.value)}
/>
</div>
<div>
<label className="label" htmlFor="labeling-notice-en">
{dict.labelingNotice.noticeEnLabel}
</label>
<textarea
id="labeling-notice-en"
className="input"
rows={2}
value={noticeEn}
onChange={(e) => setNoticeEn(e.target.value)}
/>
</div>
<div>
<label className="label" htmlFor="labeling-notice-pl">
{dict.labelingNotice.noticePlLabel}
</label>
<textarea
id="labeling-notice-pl"
className="input"
rows={2}
value={noticePl}
onChange={(e) => setNoticePl(e.target.value)}
/>
</div>
</div>
<button type="button" className="btn btn-add" onClick={save} disabled={busy}>
{dict.labelingNotice.save}
</button>
</section>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { startAuthentication } from "@simplewebauthn/browser";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
import type { Dict } from "@/lib/i18n/shared";
function authErrorMessage(dict: Dict, code: string): string {
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (code === "oidc_invalid_state") return dict.login.errorOidcInvalidState;
if (code === "oidc_unknown_user") return dict.login.errorOidcUnknownUser;
if (code === "oidc_failed") return dict.login.errorOidcFailed;
if (/cancelled|aborted|NotAllowedError/i.test(code))
return dict.login.errorPasskeyCancelled;
return formatMessage(dict.login.errorGeneric, { message: code });
}
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
}
export default function LoginForm({
oidcEnabled,
initialError,
}: {
oidcEnabled: boolean;
initialError: string | null;
}) {
const router = useRouter();
const dict = useDict();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(() =>
initialError ? authErrorMessage(dict, initialError) : null
);
const [busy, setBusy] = useState(false);
async function signInWithPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
}
async function signInWithPasskey() {
setError(null);
setBusy(true);
try {
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
const data = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
router.replace(redirectPath(data.role));
router.refresh();
} catch (err) {
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false);
}
}
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label className="label" htmlFor="email">
{dict.common.email}
</label>
<input
id="email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username webauthn"
required
/>
</div>
<div>
<label className="label" htmlFor="password">
{dict.common.password}
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{dict.login.submit}
</button>
</form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
{dict.login.or}
<span className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={signInWithPasskey}
className="btn btn-neutral w-full"
disabled={busy}
>
{dict.login.passkeyButton}
</button>
{oidcEnabled && (
<a href="/api/auth/oidc/login" className="btn btn-neutral w-full mt-2">
{dict.login.ssoButton}
</a>
)}
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.login.backToMenu}
</Link>
</p>
</div>
</div>
);
}
+1 -1
View File
@@ -96,7 +96,7 @@ export default function PasskeyManager() {
<ul className="divide-y divide-gray-100"> <ul className="divide-y divide-gray-100">
{passkeys.map((pk) => ( {passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm"> <li key={pk.id} className="flex flex-wrap items-center gap-3 py-1.5 text-sm">
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold">{pk.name || "Passkey"}</span>{" "} <span className="font-semibold">{pk.name || "Passkey"}</span>{" "}
<span className="text-gray-500"> <span className="text-gray-500">
+5 -1
View File
@@ -27,10 +27,12 @@ export default function PublicMobileMenu({
monday, monday,
sections, sections,
entries, entries,
showLabeling,
}: { }: {
monday: string; monday: string;
sections: Section[]; sections: Section[];
entries: MenuEntry[]; entries: MenuEntry[];
showLabeling: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const dict = useDict(); const dict = useDict();
@@ -86,7 +88,9 @@ export default function PublicMobileMenu({
<span className="font-semibold"> <span className="font-semibold">
{localized(e.dish, locale)} {localized(e.dish, locale)}
</span> </span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "} {showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)} – {formatPrice(e.dish.price)}
</p> </p>
); );
+2 -2
View File
@@ -124,7 +124,7 @@ export default function SectionManager() {
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.sectionManager.title} {dict.sectionManager.title}
</h1> </h1>
@@ -232,7 +232,7 @@ export default function SectionManager() {
) : ( ) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white"> <ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{sections.map((section, idx) => ( {sections.map((section, idx) => (
<li key={section.key} className="flex items-center gap-3 px-4 py-2"> <li key={section.key} className="flex flex-wrap items-center gap-3 px-4 py-2">
<div className="flex flex-col"> <div className="flex flex-col">
<button <button
type="button" type="button"
+2 -2
View File
@@ -106,7 +106,7 @@ export default function UserManager({ meId }: { meId: string }) {
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.userManager.title} {dict.userManager.title}
</h1> </h1>
@@ -221,7 +221,7 @@ export default function UserManager({ meId }: { meId: string }) {
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white"> <ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{profiles.map((p) => ( {profiles.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-4 py-2 text-sm"> <li key={p.id} className="flex flex-wrap items-center gap-3 px-4 py-2 text-sm">
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold">{p.full_name || p.email}</span>{" "} <span className="font-semibold">{p.full_name || p.email}</span>{" "}
<span className="text-gray-500"> <span className="text-gray-500">
+186 -133
View File
@@ -10,6 +10,7 @@ import {
formatPrice, formatPrice,
mondayOf, mondayOf,
todayISO, todayISO,
weekdayIndex,
} from "@/lib/menu"; } from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
@@ -22,8 +23,11 @@ export default function WeekEditor({ monday }: { monday: string }) {
const friday = addDays(monday, 4); const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({ const days = dict.weekdays.long.map((weekday, i) => ({
weekday, weekday,
short: dict.weekdays.short[i],
iso: addDays(monday, i), iso: addDays(monday, i),
})); }));
const isCurrentWeek = monday === mondayOf(todayISO());
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
const [sections, setSections] = useState<Section[]>([]); const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]); const [dishes, setDishes] = useState<Dish[]>([]);
@@ -36,6 +40,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
); );
const [selectedDish, setSelectedDish] = useState(""); const [selectedDish, setSelectedDish] = useState("");
const [dishSearch, setDishSearch] = useState(""); const [dishSearch, setDishSearch] = useState("");
// Mobil: ein Tag auf einmal statt aller fünf gleichzeitig
const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
function startAdding(iso: string, section: string) { function startAdding(iso: string, section: string) {
setAdding({ iso, section }); setAdding({ iso, section });
@@ -108,6 +114,117 @@ export default function WeekEditor({ monday }: { monday: string }) {
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query)) ? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes; : dishes;
// Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow.
// Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der
// Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert
// werden muss – nur eine Zelle kann gleichzeitig bearbeitet werden,
// unabhängig davon, welche Ansicht gerade rendert.
function renderCell(iso: string, sectionKey: string) {
const isAdding = adding?.iso === iso && adding?.section === sectionKey;
return (
<>
{entriesFor(entries, iso, sectionKey).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">{localized(e.dish, locale)}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
<button
type="button"
className="btn btn-del"
onClick={() => removeEntry(e.id)}
>
{dict.common.delete}
</button>
</div>
</div>
);
})}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
{selectedDishObj ? (
<div className="flex items-center gap-1 text-sm">
<span className="mr-auto">
{localized(selectedDishObj, locale)} (
{formatPrice(selectedDishObj.price)})
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)]"
onClick={() => {
setSelectedDish("");
setDishSearch("");
}}
aria-label={dict.weekEditor.changeDish}
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
autoFocus
placeholder={dict.dishManager.searchPlaceholder}
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
/>
<ul className="max-h-32 overflow-y-auto border border-gray-200 rounded-md bg-white divide-y divide-gray-100">
{filteredDishes.map((dish) => (
<li key={dish.id}>
<button
type="button"
className="w-full text-left px-2 py-1 text-sm hover:bg-[var(--c-row-bg-alt)]"
onClick={() => setSelectedDish(dish.id)}
>
{localized(dish, locale)} ({formatPrice(dish.price)})
</button>
</li>
))}
{filteredDishes.length === 0 && (
<li className="px-2 py-1 text-sm text-gray-500">
{formatMessage(dict.dishManager.noResults, {
query: dishSearch,
})}
</li>
)}
</ul>
</>
)}
<div className="flex gap-1 justify-center">
<button
type="button"
className="btn btn-add"
onClick={addEntry}
disabled={!selectedDish}
>
{dict.common.add}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelAdding}>
{dict.common.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(iso, sectionKey)}
>
{dict.weekEditor.addMeal}
</button>
)}
</>
);
}
const selectedDay = days[selectedDayIdx];
return ( return (
<div> <div>
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
@@ -136,140 +253,76 @@ export default function WeekEditor({ monday }: { monday: string }) {
{loading ? ( {loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p> <p className="text-gray-500">{dict.weekEditor.loading}</p>
) : ( ) : (
<table className="menu-table"> <>
<thead> {/* Mobil (< "sm"): ein Tag auf einmal, per Tabs gewählt */}
<tr> <div className="sm:hidden">
<th className="section-head w-52" aria-hidden /> <div className="flex gap-1 mb-3">
{days.map((d) => ( {days.map((d, i) => (
<th key={d.iso} className="day-head"> <button
<span className="font-bold">{d.weekday}</span> key={d.iso}
<br /> type="button"
{formatDateLong(d.iso, dict.dateLocale)} onClick={() => setSelectedDayIdx(i)}
</th> className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
i === selectedDayIdx
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
: "bg-white text-gray-600 border-gray-200"
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
>
{d.short}
</button>
))} ))}
</tr> </div>
</thead> <h2 className="font-bold text-[var(--c-primary)] mb-2">
<tbody> {selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
{sections.map((s) => ( </h2>
<tr key={s.key}> <div className="space-y-3">
<th className="section-head"> {sections.map((s) => (
<div>{localized(s, locale)}</div> <div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="time">{s.time_range}</div> <div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
</th> <span className="font-semibold">{localized(s, locale)}</span>{" "}
{days.map((d) => { <span className="opacity-80">· {s.time_range}</span>
const isAdding = </div>
adding?.iso === d.iso && adding?.section === s.key; <div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
return ( {renderCell(selectedDay.iso, s.key)}
<td key={d.iso} className="cell"> </div>
{entriesFor(entries, d.iso, s.key).map((e) => { </div>
const codes = dishCodes(e.dish); ))}
return ( </div>
<div key={e.id} className="dish"> </div>
<span className="font-bold">
{localized(e.dish, locale)} {/* Ab "sm": klassische Wochentabelle */}
</span> <div className="hidden sm:block">
{codes && <sup className="dish-codes">{codes}</sup>} <table className="menu-table">
{" – "} <thead>
{formatPrice(e.dish.price)} <tr>
<div className="mt-1"> <th className="section-head w-52" aria-hidden />
<button {days.map((d) => (
type="button" <th key={d.iso} className="day-head">
className="btn btn-del" <span className="font-bold">{d.weekday}</span>
onClick={() => removeEntry(e.id)} <br />
> {formatDateLong(d.iso, dict.dateLocale)}
{dict.common.delete} </th>
</button> ))}
</div> </tr>
</div> </thead>
); <tbody>
})} {sections.map((s) => (
{isAdding ? ( <tr key={s.key}>
<div className="mt-2 space-y-1 text-left"> <th className="section-head">
{selectedDishObj ? ( <div>{localized(s, locale)}</div>
<div className="flex items-center gap-1 text-sm"> <div className="time">{s.time_range}</div>
<span className="mr-auto"> </th>
{localized(selectedDishObj, locale)} ( {days.map((d) => (
{formatPrice(selectedDishObj.price)}) <td key={d.iso} className="cell">
</span> {renderCell(d.iso, s.key)}
<button </td>
type="button" ))}
className="text-gray-400 hover:text-[var(--c-primary)]" </tr>
onClick={() => { ))}
setSelectedDish(""); </tbody>
setDishSearch(""); </table>
}} </div>
aria-label={dict.weekEditor.changeDish} </>
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
autoFocus
placeholder={dict.dishManager.searchPlaceholder}
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
/>
<ul className="max-h-32 overflow-y-auto border border-gray-200 rounded-md bg-white divide-y divide-gray-100">
{filteredDishes.map((dish) => (
<li key={dish.id}>
<button
type="button"
className="w-full text-left px-2 py-1 text-sm hover:bg-[var(--c-row-bg-alt)]"
onClick={() => setSelectedDish(dish.id)}
>
{localized(dish, locale)} ({formatPrice(dish.price)})
</button>
</li>
))}
{filteredDishes.length === 0 && (
<li className="px-2 py-1 text-sm text-gray-500">
{formatMessage(dict.dishManager.noResults, {
query: dishSearch,
})}
</li>
)}
</ul>
</>
)}
<div className="flex gap-1 justify-center">
<button
type="button"
className="btn btn-add"
onClick={addEntry}
disabled={!selectedDish}
>
{dict.common.add}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={cancelAdding}
>
{dict.common.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(d.iso, s.key)}
>
{dict.weekEditor.addMeal}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)} )}
<p className="mt-4 text-sm text-gray-500"> <p className="mt-4 text-sm text-gray-500">
{hintBefore} {hintBefore}
+11
View File
@@ -0,0 +1,11 @@
-- Umschaltbare Anzeige der Allergene/Zusatzstoffe: entweder die
-- detaillierte Auswahl je Gericht (bisheriges Verhalten) oder
-- stattdessen nur ein von Personal/Admin editierbarer Hinweistext
-- (z. B. "Bei Fragen bitte das Kantinen-Team fragen").
alter table settings
add column labeling_mode text not null default 'detailed'
check (labeling_mode in ('detailed', 'notice')),
add column labeling_notice text not null default
'Bei Fragen zu Allergenen und Zusatzstoffen wenden Sie sich bitte an das Kantinen-Team.',
add column labeling_notice_en text,
add column labeling_notice_pl text;
+12
View File
@@ -0,0 +1,12 @@
-- Kurzlebiger, einmalig verwendbarer State-Store für den OpenID-Connect-
-- Login (SSO, z. B. gegen Authentik). Analog zu webauthn_challenges:
-- der "state"-Wert selbst ist die CSRF-Absicherung für den Callback
-- (der Callback ist ein Cross-Origin-GET vom Identity Provider, der
-- normale isSameOrigin()-Check greift dort nicht).
create table oidc_states (
state text primary key,
nonce text not null,
code_verifier text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
+10
View File
@@ -34,6 +34,13 @@ services:
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key # (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer. # bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
DEEPL_API_KEY: ${DEEPL_API_KEY:-} DEEPL_API_KEY: ${DEEPL_API_KEY:-}
# Optional: SSO-Login per OpenID Connect (z. B. Authentik). Ohne
# diese drei Werte bleibt der SSO-Button ausgeblendet. Funktioniert
# nur für bereits angelegte Benutzer (Abgleich per E-Mail), legt nie
# neue Konten an.
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
# Muss auf das "volumes:"-Ziel unten UND das im Dockerfile per # Muss auf das "volumes:"-Ziel unten UND das im Dockerfile per
# "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese # "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese
# Variable würde lib/branding.ts auf ${PWD}/data/branding # Variable würde lib/branding.ts auf ${PWD}/data/branding
@@ -77,6 +84,9 @@ services:
SITE_TITLE: ${SITE_TITLE:-Canteen Menu} SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DEEPL_API_KEY: ${DEEPL_API_KEY:-} DEEPL_API_KEY: ${DEEPL_API_KEY:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
ports: ports:
- "0.0.0.0:3000:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
+99
View File
@@ -0,0 +1,99 @@
import "server-only";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { clientId, clientSecret, issuer, redirectUri } from "./oidc-config";
export interface OidcDiscovery {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
}
// Kein Caching des Discovery-Dokuments: Login ist bei ~250 Mitarbeitern
// kein Hot Path, ein zusätzlicher HTTPS-Request pro Anmeldeversuch
// fällt nicht ins Gewicht – vermeidet Cache-Invalidierungslogik ohne
// echten Bedarf.
export async function discover(): Promise<OidcDiscovery> {
const res = await fetch(`${issuer().replace(/\/$/, "")}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`oidc_discovery_failed_${res.status}`);
return (await res.json()) as OidcDiscovery;
}
export function buildAuthorizeUrl(
discovery: OidcDiscovery,
params: { state: string; nonce: string; codeChallenge: string }
): string {
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", clientId());
url.searchParams.set("redirect_uri", redirectUri());
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", params.state);
url.searchParams.set("nonce", params.nonce);
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
export async function exchangeCode(
discovery: OidcDiscovery,
code: string,
codeVerifier: string
): Promise<{ idToken: string; accessToken: string | null }> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId(),
client_secret: clientSecret(),
redirect_uri: redirectUri(),
code,
code_verifier: codeVerifier,
});
const res = await fetch(discovery.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) throw new Error(`oidc_token_exchange_failed_${res.status}`);
const data = (await res.json()) as { id_token?: string; access_token?: string };
if (!data.id_token) throw new Error("oidc_missing_id_token");
return { idToken: data.id_token, accessToken: data.access_token ?? null };
}
// Algorithmus bewusst auf RS256 festgenagelt (Authentik-Standard) statt
// dem Token zu vertrauen, welchen Algorithmus es selbst behauptet zu
// nutzen ("algorithm confusion"-Angriffsklasse bei JWTs).
export async function verifyIdToken(
discovery: OidcDiscovery,
idToken: string,
expectedNonce: string
): Promise<JWTPayload> {
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
const { payload } = await jwtVerify(idToken, jwks, {
issuer: issuer(),
audience: clientId(),
algorithms: ["RS256"],
});
if (payload.nonce !== expectedNonce) {
throw new Error("oidc_nonce_mismatch");
}
if (payload.email_verified === false) {
throw new Error("oidc_email_not_verified");
}
return payload;
}
/** E-Mail bevorzugt aus dem ID-Token, sonst Fallback auf den Userinfo-Endpoint. */
export async function resolveEmail(
discovery: OidcDiscovery,
payload: JWTPayload,
accessToken: string | null
): Promise<string | null> {
if (typeof payload.email === "string" && payload.email) return payload.email;
if (!accessToken || !discovery.userinfo_endpoint) return null;
const res = await fetch(discovery.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
const info = (await res.json()) as { email?: string };
return typeof info.email === "string" && info.email ? info.email : null;
}
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
// SSO-Konfiguration wird direkt aus Env-Vars abgeleitet – kein
// Cloud-Konfigurationsschritt, analog zu lib/auth/webauthn-config.ts.
// "Aktiviert" ergibt sich implizit aus dem Vorhandensein der drei
// Pflicht-Vars (wie bei DEEPL_API_KEY), kein separates Enable-Flag.
export function isConfigured(): boolean {
return !!(
process.env.OIDC_ISSUER &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
);
}
export function issuer(): string {
return process.env.OIDC_ISSUER!;
}
export function clientId(): string {
return process.env.OIDC_CLIENT_ID!;
}
export function clientSecret(): string {
return process.env.OIDC_CLIENT_SECRET!;
}
// Bewusst aus APP_URL abgeleitet statt aus Request-Headern (wie z. B.
// proxy.ts es für den Login-Redirect tut): der Wert muss exakt mit der
// beim Identity Provider hinterlegten Redirect-URI übereinstimmen,
// darf sich also nicht je nach eingehendem Host-Header ändern.
export function redirectUri(): string {
const appUrl = new URL(process.env.APP_URL ?? "http://localhost:3000");
return `${appUrl.origin}/api/auth/oidc/callback`;
}
+31
View File
@@ -0,0 +1,31 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
const STATE_TTL_MS = 10 * 60 * 1000;
export async function storeState(
state: string,
nonce: string,
codeVerifier: string
): Promise<void> {
// Kein Cron nötig: abgelaufene Zeilen bei jedem neuen State gleich
// mit aufräumen (wie bei webauthn_challenges).
await query("delete from oidc_states where expires_at < now()");
await query(
"insert into oidc_states (state, nonce, code_verifier, expires_at) values ($1, $2, $3, $4)",
[state, nonce, codeVerifier, new Date(Date.now() + STATE_TTL_MS)]
);
}
/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */
export async function consumeState(
state: string
): Promise<{ nonce: string; codeVerifier: string } | null> {
const row = await queryOne<{ nonce: string; code_verifier: string }>(
`delete from oidc_states
where state = $1 and expires_at > now()
returning nonce, code_verifier`,
[state]
);
return row ? { nonce: row.nonce, codeVerifier: row.code_verifier } : null;
}
+98
View File
@@ -0,0 +1,98 @@
import "server-only";
// Minimaler RFC-4180-artiger CSV-Parser/-Serializer für Import/Export der
// Gerichte-/Allergen-/Zusatzstofflisten. Bewusst ohne npm-Abhängigkeit
// (Projekt-Stil: kein ORM, keine Validierungs-Library, minimale Deps) –
// deckt quoted Felder mit eingebetteten Kommas/Anführungszeichen/
// Zeilenumbrüchen ab, da Gerichtsnamen Kommas enthalten können.
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
let i = 0;
const len = text.length;
function pushField() {
row.push(field);
field = "";
}
function pushRow() {
pushField();
rows.push(row);
row = [];
}
while (i < len) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
field += c;
i++;
continue;
}
if (c === '"') {
inQuotes = true;
i++;
continue;
}
if (c === ",") {
pushField();
i++;
continue;
}
if (c === "\r") {
i++;
continue;
}
if (c === "\n") {
pushRow();
i++;
continue;
}
field += c;
i++;
}
// Letzte Zeile ohne abschließenden Zeilenumbruch noch anhängen.
if (field !== "" || row.length > 0) pushRow();
// Leere Zeilen überspringen (z. B. durch einen abschließenden Zeilenumbruch).
return rows.filter((r) => !(r.length === 1 && r[0].trim() === ""));
}
function csvField(value: string): string {
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(csvField).join(",")).join("\r\n") + "\r\n";
}
/** Kopfzeile in Kleinbuchstaben, damit der Import tolerant gegenüber Groß-/
* Kleinschreibung und Spaltenreihenfolge ist. */
export function parseCsvWithHeader(text: string): { header: string[]; rows: string[][] } {
const table = parseCsv(text);
if (table.length === 0) return { header: [], rows: [] };
const [header, ...rows] = table;
return { header: header.map((h) => h.trim().toLowerCase()), rows };
}
/** Codes einer Zelle wie "A;G;2" in einzelne, getrimmte Kürzel zerlegen. */
export function splitCodes(cell: string | undefined): string[] {
return (cell ?? "")
.split(";")
.map((c) => c.trim())
.filter(Boolean);
}
+45 -1
View File
@@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { query, queryOne } from "@/lib/db"; import { query, queryOne, withTransaction } from "@/lib/db";
import type { CodeItem } from "@/lib/types"; import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel + // "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
@@ -39,3 +39,47 @@ export async function updateCode(
export async function deleteCode(table: CodeTable, code: string): Promise<void> { export async function deleteCode(table: CodeTable, code: string): Promise<void> {
await query(`delete from ${table} where code = $1`, [code]); await query(`delete from ${table} where code = $1`, [code]);
} }
export interface CodeImportRow {
code: string;
name: string;
name_en: string | null;
name_pl: string | null;
}
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
* aktualisiert – ein doppelter Code innerhalb derselben Datei
* aktualisiert also einfach zweimal statt einen Fehler zu werfen. */
export async function importCodes(
table: CodeTable,
rows: CodeImportRow[]
): Promise<{ created: number; updated: number }> {
return withTransaction(async (client) => {
const existing = await client.query<{ code: string }>(`select code from ${table}`);
const known = new Set(existing.rows.map((r) => r.code));
const maxRow = await client.query<{ sort: number }>(
`select coalesce(max(sort), 0) as sort from ${table}`
);
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
let created = 0;
let updated = 0;
for (const row of rows) {
if (known.has(row.code)) {
await client.query(
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`,
[row.name, row.name_en, row.name_pl, row.code]
);
updated++;
} else {
await client.query(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
);
known.add(row.code);
created++;
}
}
return { created, updated };
});
}
+9 -1
View File
@@ -1,14 +1,22 @@
import "server-only"; import "server-only";
import { queryOne } from "@/lib/db"; import { queryOne } from "@/lib/db";
export type LabelingMode = "detailed" | "notice";
export interface Settings { export interface Settings {
colors: Record<string, string>; colors: Record<string, string>;
logo_path: string | null; logo_path: string | null;
favicon_path: string | null; favicon_path: string | null;
labeling_mode: LabelingMode;
labeling_notice: string;
labeling_notice_en: string | null;
labeling_notice_pl: string | null;
} }
export async function getSettings(): Promise<Settings | null> { export async function getSettings(): Promise<Settings | null> {
return queryOne<Settings>( return queryOne<Settings>(
"select colors, logo_path, favicon_path from settings where id = 1" `select colors, logo_path, favicon_path, labeling_mode,
labeling_notice, labeling_notice_en, labeling_notice_pl
from settings where id = 1`
); );
} }
+113
View File
@@ -40,18 +40,25 @@ const de = {
openSuggestions: "{count} offene Rezeptvorschläge", openSuggestions: "{count} offene Rezeptvorschläge",
viewMenu: "Speisekarte ansehen", viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto", myAccount: "Mein Konto",
menuToggle: "Menü öffnen/schließen",
}, },
login: { login: {
title: "Anmeldung", title: "Anmeldung",
submit: "Anmelden", submit: "Anmelden",
or: "oder", or: "oder",
passkeyButton: "Mit Passkey anmelden", passkeyButton: "Mit Passkey anmelden",
ssoButton: "Mit Single Sign-On anmelden",
backToMenu: "← Zur Speisekarte", backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.", errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.", errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}", errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled: errorPasswordDisabled:
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.", "Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.",
errorOidcInvalidState:
"Die SSO-Anmeldung ist abgelaufen oder ungültig. Bitte erneut versuchen.",
errorOidcUnknownUser:
"Für diese E-Mail-Adresse existiert kein Konto. Bitte an die Verwaltung wenden.",
errorOidcFailed: "SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.",
}, },
publicMenu: { publicMenu: {
prevWeek: "← Vorherige Woche", prevWeek: "← Vorherige Woche",
@@ -63,6 +70,7 @@ const de = {
weekTab: "Woche", weekTab: "Woche",
allergens: "Allergene", allergens: "Allergene",
additives: "Zusatzstoffe", additives: "Zusatzstoffe",
labelingNoticeTitle: "Allergene & Zusatzstoffe",
suggestDish: "Rezept vorschlagen", suggestDish: "Rezept vorschlagen",
noEntry: "–", noEntry: "–",
rssFeed: "RSS-Feed abonnieren", rssFeed: "RSS-Feed abonnieren",
@@ -106,6 +114,15 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}", loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}", saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}", deleteError: "Löschen fehlgeschlagen: {message}",
exportButton: "Exportieren",
importButton: "Importieren",
exportError: "Export fehlgeschlagen: {message}",
importError: "Import fehlgeschlagen: {message}",
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
importSkippedRow: "Zeile {row}: {reason}",
labelingDisabledHint:
"Allergene/Zusatzstoffe sind aktuell deaktiviert (Hinweismodus aktiv, siehe „Allergene & Zusatzstoffe“).",
}, },
codeList: { codeList: {
allergensTitle: "Allergene", allergensTitle: "Allergene",
@@ -119,6 +136,13 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}", loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}", saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}", deleteError: "Löschen fehlgeschlagen: {message}",
exportButton: "Exportieren",
importButton: "Importieren",
exportError: "Export fehlgeschlagen: {message}",
importError: "Import fehlgeschlagen: {message}",
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
importSkippedRow: "Zeile {row}: {reason}",
}, },
sectionManager: { sectionManager: {
title: "Essenszeiten", title: "Essenszeiten",
@@ -185,6 +209,20 @@ const de = {
confirmRemoveLogo: "Logo wirklich entfernen?", confirmRemoveLogo: "Logo wirklich entfernen?",
confirmRemoveFavicon: "Favicon wirklich entfernen?", confirmRemoveFavicon: "Favicon wirklich entfernen?",
}, },
labelingNotice: {
title: "Anzeige von Allergenen & Zusatzstoffen",
hint:
"Standardmäßig sieht jeder Gast eine detaillierte Auswahl der Allergene/Zusatzstoffe je Gericht. Alternativ kann stattdessen nur ein Hinweistext angezeigt werden – die Auswahl je Gericht wird dann auch in der Gerichte-Verwaltung ausgeblendet.",
modeDetailed: "Detaillierte Auswahl je Gericht anzeigen",
modeNotice: "Nur Hinweistext anzeigen",
noticeDeLabel: "Hinweistext (Deutsch)",
noticeEnLabel: "Hinweistext (Englisch)",
noticePlLabel: "Hinweistext (Polnisch)",
save: "Speichern",
saved: "Gespeichert.",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
},
passkeys: { passkeys: {
title: "Passkeys", title: "Passkeys",
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – nach einem Domainwechsel müssen sie neu registriert werden.", hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – nach einem Domainwechsel müssen sie neu registriert werden.",
@@ -296,18 +334,24 @@ const en: Dict = {
openSuggestions: "{count} open dish suggestions", openSuggestions: "{count} open dish suggestions",
viewMenu: "View menu", viewMenu: "View menu",
myAccount: "My account", myAccount: "My account",
menuToggle: "Open/close menu",
}, },
login: { login: {
title: "Sign in", title: "Sign in",
submit: "Log in", submit: "Log in",
or: "or", or: "or",
passkeyButton: "Sign in with passkey", passkeyButton: "Sign in with passkey",
ssoButton: "Sign in with Single Sign-On",
backToMenu: "← Back to menu", backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.", errorInvalidCredentials: "Email address or password is incorrect.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.", errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}", errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled: errorPasswordDisabled:
"Password sign-in is disabled for this account. Please sign in with a passkey.", "Password sign-in is disabled for this account. Please sign in with a passkey.",
errorOidcInvalidState: "The SSO sign-in expired or was invalid. Please try again.",
errorOidcUnknownUser:
"No account exists for this email address. Please contact your administrator.",
errorOidcFailed: "SSO sign-in failed. Please try again.",
}, },
publicMenu: { publicMenu: {
prevWeek: "← Previous week", prevWeek: "← Previous week",
@@ -319,6 +363,7 @@ const en: Dict = {
weekTab: "Week", weekTab: "Week",
allergens: "Allergens", allergens: "Allergens",
additives: "Additives", additives: "Additives",
labelingNoticeTitle: "Allergens & additives",
suggestDish: "Suggest a dish", suggestDish: "Suggest a dish",
noEntry: "–", noEntry: "–",
rssFeed: "Subscribe to RSS feed", rssFeed: "Subscribe to RSS feed",
@@ -362,6 +407,15 @@ const en: Dict = {
loadError: "Loading failed: {message}", loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}", saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}", deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
labelingDisabledHint:
"Allergens/additives are currently disabled (notice mode active, see \"Allergens & additives\").",
}, },
codeList: { codeList: {
allergensTitle: "Allergens", allergensTitle: "Allergens",
@@ -375,6 +429,13 @@ const en: Dict = {
loadError: "Loading failed: {message}", loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}", saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}", deleteError: "Deleting failed: {message}",
exportButton: "Export",
importButton: "Import",
exportError: "Export failed: {message}",
importError: "Import failed: {message}",
importSuccess: "{created} created, {updated} updated.",
importSkipped: "{count} row(s) skipped: {reasons}",
importSkippedRow: "Row {row}: {reason}",
}, },
sectionManager: { sectionManager: {
title: "Meal times", title: "Meal times",
@@ -441,6 +502,20 @@ const en: Dict = {
confirmRemoveLogo: "Really remove the logo?", confirmRemoveLogo: "Really remove the logo?",
confirmRemoveFavicon: "Really remove the favicon?", confirmRemoveFavicon: "Really remove the favicon?",
}, },
labelingNotice: {
title: "Allergen & additive display",
hint:
"By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.",
modeDetailed: "Show detailed selection per dish",
modeNotice: "Show only notice text",
noticeDeLabel: "Notice text (German)",
noticeEnLabel: "Notice text (English)",
noticePlLabel: "Notice text (Polish)",
save: "Save",
saved: "Saved.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
},
passkeys: { passkeys: {
title: "Passkeys", title: "Passkeys",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.", hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.",
@@ -550,18 +625,25 @@ const pl: Dict = {
openSuggestions: "{count} otwartych propozycji dań", openSuggestions: "{count} otwartych propozycji dań",
viewMenu: "Zobacz jadłospis", viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto", myAccount: "Moje konto",
menuToggle: "Otwórz/zamknij menu",
}, },
login: { login: {
title: "Logowanie", title: "Logowanie",
submit: "Zaloguj się", submit: "Zaloguj się",
or: "lub", or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu", passkeyButton: "Zaloguj się kluczem dostępu",
ssoButton: "Zaloguj się przez Single Sign-On",
backToMenu: "← Powrót do jadłospisu", backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.", errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.", errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}", errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled: errorPasswordDisabled:
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.", "Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.",
errorOidcInvalidState:
"Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.",
errorOidcUnknownUser:
"Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.",
errorOidcFailed: "Logowanie SSO nie powiodło się. Spróbuj ponownie.",
}, },
publicMenu: { publicMenu: {
prevWeek: "← Poprzedni tydzień", prevWeek: "← Poprzedni tydzień",
@@ -573,6 +655,7 @@ const pl: Dict = {
weekTab: "Tydzień", weekTab: "Tydzień",
allergens: "Alergeny", allergens: "Alergeny",
additives: "Dodatki", additives: "Dodatki",
labelingNoticeTitle: "Alergeny i dodatki",
suggestDish: "Zaproponuj danie", suggestDish: "Zaproponuj danie",
noEntry: "–", noEntry: "–",
rssFeed: "Subskrybuj kanał RSS", rssFeed: "Subskrybuj kanał RSS",
@@ -616,6 +699,15 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}", loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}", saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}", deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
labelingDisabledHint:
"Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).",
}, },
codeList: { codeList: {
allergensTitle: "Alergeny", allergensTitle: "Alergeny",
@@ -629,6 +721,13 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}", loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}", saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}", deleteError: "Usuwanie nie powiodło się: {message}",
exportButton: "Eksportuj",
importButton: "Importuj",
exportError: "Eksport nie powiódł się: {message}",
importError: "Import nie powiódł się: {message}",
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
importSkipped: "Pominięto wierszy: {count}: {reasons}",
importSkippedRow: "Wiersz {row}: {reason}",
}, },
sectionManager: { sectionManager: {
title: "Pory posiłków", title: "Pory posiłków",
@@ -695,6 +794,20 @@ const pl: Dict = {
confirmRemoveLogo: "Czy na pewno usunąć logo?", confirmRemoveLogo: "Czy na pewno usunąć logo?",
confirmRemoveFavicon: "Czy na pewno usunąć favicon?", confirmRemoveFavicon: "Czy na pewno usunąć favicon?",
}, },
labelingNotice: {
title: "Wyświetlanie alergenów i dodatków",
hint:
"Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.",
modeDetailed: "Pokaż szczegółowy wybór dla każdego dania",
modeNotice: "Pokaż tylko komunikat",
noticeDeLabel: "Tekst komunikatu (niemiecki)",
noticeEnLabel: "Tekst komunikatu (angielski)",
noticePlLabel: "Tekst komunikatu (polski)",
save: "Zapisz",
saved: "Zapisano.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
},
passkeys: { passkeys: {
title: "Klucze dostępu", title: "Klucze dostępu",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.", hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.",
+12 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.6.3", "version": "1.9.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.6.3", "version": "1.9.5",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10", "next": "16.2.10",
"pg": "^8.22.0", "pg": "^8.22.0",
"react": "19.2.4", "react": "19.2.4",
@@ -4930,6 +4931,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.6.3", "version": "1.9.5",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs", "predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10", "next": "16.2.10",
"pg": "^8.22.0", "pg": "^8.22.0",
"react": "19.2.4", "react": "19.2.4",
Executable
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Aktualisiert ein per Docker Compose betriebenes Deployment:
# Stack stoppen -> lokale docker-compose.yml-Änderungen sichern (git stash)
# -> git pull -> Änderungen zurückspielen -> Stack neu bauen und starten.
#
# Aufruf im Projektverzeichnis (dort, wo docker-compose.yml liegt):
# ./update.sh
set -euo pipefail
cd "$(dirname "$0")"
ENV_FILE=".env.local"
if [ ! -f "$ENV_FILE" ]; then
echo "Fehler: $ENV_FILE nicht gefunden. Bitte im Projektverzeichnis ausführen." >&2
exit 1
fi
echo "==> Stoppe laufenden Stack..."
docker compose --env-file "$ENV_FILE" down
STASHED=0
if ! git diff --quiet -- docker-compose.yml || ! git diff --cached --quiet -- docker-compose.yml; then
echo "==> Sichere lokale Änderungen an docker-compose.yml (git stash)..."
git stash push --message "update.sh: docker-compose.yml vor Pull gesichert" -- docker-compose.yml
STASHED=1
fi
echo "==> Hole neueste Version (git pull)..."
git pull
if [ "$STASHED" -eq 1 ]; then
echo "==> Stelle lokale Änderungen an docker-compose.yml wieder her..."
if ! git stash pop; then
echo "Fehler: git stash pop hatte einen Konflikt in docker-compose.yml." >&2
echo "Bitte den Konflikt manuell auflösen (git status), danach Stack manuell starten:" >&2
echo " docker compose --env-file $ENV_FILE up -d --build" >&2
exit 1
fi
fi
echo "==> Baue und starte Stack neu..."
docker compose --env-file "$ENV_FILE" up -d --build
echo "==> Fertig. Status:"
docker compose ps