Compare commits

..
12 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
florian.wilde 5874eb31b6 Merge fix/branding-dir-env: BRANDING_DIR im app-Dienst setzen (Upload-500-Fix) 2026-07-18 15:11:27 +02:00
florian.wildeandClaude Sonnet 5 358872d56c fix: BRANDING_DIR im app-Dienst setzen, sonst 500 beim Logo/Favicon-Upload
Ohne die Variable weicht lib/branding.ts auf ${cwd}/data/branding
(= /app/data/branding) aus statt das im Dockerfile vorbereitete und
gemountete /data/branding zu nutzen. /app gehört root, der App-Prozess
läuft aber als nicht-root – das Anlegen des Fallback-Verzeichnisses
schlägt mit EACCES fehl.

Version 1.6.3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:10:46 +02:00
39 changed files with 2044 additions and 368 deletions
+12
View File
@@ -48,3 +48,15 @@ ADMIN_EMAIL=
# Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen"
# inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen.
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=
+103
View File
@@ -4,6 +4,109 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
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
### Behoben
- `docker-compose.yml`: Der `app`-Dienst setzte `BRANDING_DIR` nicht, sodass
`lib/branding.ts` beim Hochladen von Logo/Favicon auf den Fallback-Pfad
`${cwd}/data/branding` (= `/app/data/branding`) statt auf das im
Dockerfile vorbereitete, gemountete Verzeichnis `/data/branding` auswich.
`/app` gehört im Container `root`, der App-Prozess läuft aber als
nicht-root-Nutzer `nextjs` – das Anlegen des Fallback-Verzeichnisses
scheiterte mit `EACCES`, der Upload endete mit „Upload fehlgeschlagen:
500". Jetzt wird `BRANDING_DIR=/data/branding` explizit gesetzt, passend
zum Volume-Mount und zum in `Dockerfile` per `chown` freigegebenen
Verzeichnis.
## [1.6.2] – 2026-07-18
### Behoben
+35
View File
@@ -149,6 +149,32 @@ docker compose --env-file .env.local up -d
werden im Kantinen-Bereich gepflegt.
- 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
### `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
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
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 { 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 { isSameOrigin } from "@/lib/origin";
@@ -8,14 +8,58 @@ export async function GET() {
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) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const { colors } = (await request.json()) as { colors: Record<string, string> };
await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]);
// Farben bleiben admin-only wie bisher; die Allergen-/Zusatzstoff-
// Anzeige darf auch Kantinenpersonal umschalten/bearbeiten.
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 });
} catch (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>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${dayContent}]]></description>
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
</item>`;
})
.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"?>
<rss version="2.0">
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
<link>${appUrl}</link>
+4
View File
@@ -1,11 +1,15 @@
import CodeListManager from "@/components/CodeListManager";
import LabelingNoticeEditor from "@/components/LabelingNoticeEditor";
export default function KennzeichnungPage() {
const translateEnabled = !!process.env.DEEPL_API_KEY;
return (
<div className="flex flex-col gap-6">
<LabelingNoticeEditor />
<div className="grid gap-6 lg:grid-cols-2">
<CodeListManager table="allergens" translateEnabled={translateEnabled} />
<CodeListManager table="additives" translateEnabled={translateEnabled} />
</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";
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 (/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>
);
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return <LoginForm oidcEnabled={isConfigured()} initialError={error ?? null} />;
}
+32 -3
View File
@@ -42,6 +42,7 @@ export default async function Home({
]);
const logo = logoUrl(settings?.logo_path);
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
return (
<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>
{/* 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 */}
<div className="hidden sm:block">
@@ -148,7 +154,9 @@ export default async function Home({
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
@@ -163,7 +171,9 @@ export default async function Home({
</div>
</div>
{/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
{/* Allergene & Zusatzstoffe: entweder Legende zu den Kürzeln bei den
Gerichten, oder – falls im Hinweismodus – nur ein Hinweistext. */}
{labelingDetailed ? (
<section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700">
<div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
@@ -192,6 +202,25 @@ export default async function Home({
</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>
);
}
+112 -22
View File
@@ -3,7 +3,8 @@
// Gemeinsamer Kopfbereich für alle internen Seiten. Statt zweier
// getrennter Bereiche (Kantine/Verwaltung) gibt es eine Navigation:
// 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 { usePathname } from "next/navigation";
@@ -13,6 +14,11 @@ import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
interface NavLink {
href: string;
label: string;
}
export default function AppHeader({
role,
displayName,
@@ -23,6 +29,7 @@ export default function AppHeader({
const pathname = usePathname();
const dict = useDict();
const [suggestionCount, setSuggestionCount] = useState(0);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
fetch("/api/dish-suggestions")
@@ -31,14 +38,19 @@ export default function AppHeader({
.catch(() => {});
}, [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/essenszeiten", label: dict.header.sections },
{ href: "/intern/gerichte", label: dict.header.dishes },
{ href: "/intern/kennzeichnung", label: dict.header.labels },
{ href: "/intern/vorschlaege", label: dict.header.suggestions },
];
const adminLinks = [
const adminLinks: NavLink[] = [
{ href: "/admin", label: dict.header.users },
{ href: "/admin/design", label: dict.header.design },
];
@@ -48,19 +60,10 @@ export default function AppHeader({
pathname === href ? "bg-white/20 font-semibold" : "text-white/90"
}`;
return (
<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">
<span className="font-bold text-base">{dict.header.canteen}</span>
<nav
className="flex flex-wrap items-center gap-x-1 gap-y-1"
aria-label={dict.header.menuGroup}
>
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{menuLinks.map((l) => (
// 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 && (
@@ -74,7 +77,98 @@ export default function AppHeader({
</span>
)}
</Link>
))}
));
}
return (
<header className="bg-[var(--c-primary-dark)] text-white">
{/* 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>
<nav
className="flex flex-wrap items-center gap-x-1 gap-y-1"
aria-label={dict.header.menuGroup}
>
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.menuGroup}
</span>
{renderLinks(menuLinks)}
</nav>
{role === "admin" && (
@@ -85,11 +179,7 @@ export default function AppHeader({
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
{dict.header.adminGroup}
</span>
{adminLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
{renderLinks(adminLinks)}
</nav>
)}
+70 -2
View File
@@ -34,6 +34,8 @@ export default function CodeListManager({
namePl: 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">(
"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 (
<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">
{title}
</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
type="button"
className="btn btn-add"
@@ -148,6 +215,7 @@ export default function CodeListManager({
{error}
</p>
)}
{notice && <p className="mb-2 text-sm text-green-700">{notice}</p>}
{editing && (
<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">
{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="font-semibold">{item.code}</span> –{" "}
{localized(item, locale)}
+84 -4
View File
@@ -40,10 +40,13 @@ export default function DishManager({
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | 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 [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState("");
const [labelingDetailed, setLabelingDetailed] = useState(true);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
@@ -55,10 +58,11 @@ export default function DishManager({
}, [dishes, search]);
const load = useCallback(async () => {
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
const [dishesRes, allergensRes, additivesRes, settingsRes] = await Promise.all([
fetch("/api/dishes"),
fetch("/api/allergens"),
fetch("/api/additives"),
fetch("/api/settings"),
]);
if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) {
setError(formatMessage(dict.dishManager.loadError, { message: "load failed" }));
@@ -67,6 +71,10 @@ export default function DishManager({
setAllergens(await allergensRes.json());
setAdditives(await additivesRes.json());
}
if (settingsRes.ok) {
const settings = await settingsRes.json();
setLabelingDetailed((settings?.labeling_mode ?? "detailed") !== "notice");
}
setLoading(false);
}, [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 =
visible.length > 0 && visible.every((d) => selected.has(d.id));
return (
<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">
{dict.dishManager.title}
</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
type="button"
className="btn btn-add"
@@ -245,6 +318,7 @@ export default function DishManager({
{error}
</p>
)}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">
@@ -322,6 +396,8 @@ export default function DishManager({
</div>
)}
{labelingDetailed ? (
<>
<ChipPicker
label={dict.codeList.allergensTitle}
items={allergens}
@@ -334,6 +410,10 @@ export default function DishManager({
selected={form.additives}
onToggle={toggleAdditive}
/>
</>
) : (
<p className="text-sm text-gray-500">{dict.dishManager.labelingDisabledHint}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
@@ -364,7 +444,7 @@ export default function DishManager({
/>
)}
{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">
<input
type="checkbox"
@@ -394,7 +474,7 @@ export default function DishManager({
{visible.map((dish) => {
const codes = dishCodes(dish);
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
type="checkbox"
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">
{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="font-semibold">{pk.name || "Passkey"}</span>{" "}
<span className="text-gray-500">
+5 -1
View File
@@ -27,10 +27,12 @@ export default function PublicMobileMenu({
monday,
sections,
entries,
showLabeling,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
showLabeling: boolean;
}) {
const router = useRouter();
const dict = useDict();
@@ -86,7 +88,9 @@ export default function PublicMobileMenu({
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
+2 -2
View File
@@ -124,7 +124,7 @@ export default function SectionManager() {
return (
<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">
{dict.sectionManager.title}
</h1>
@@ -232,7 +232,7 @@ export default function SectionManager() {
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{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">
<button
type="button"
+2 -2
View File
@@ -106,7 +106,7 @@ export default function UserManager({ meId }: { meId: string }) {
return (
<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">
{dict.userManager.title}
</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">
{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="font-semibold">{p.full_name || p.email}</span>{" "}
<span className="text-gray-500">
+117 -64
View File
@@ -10,6 +10,7 @@ import {
formatPrice,
mondayOf,
todayISO,
weekdayIndex,
} from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
@@ -22,8 +23,11 @@ export default function WeekEditor({ monday }: { monday: string }) {
const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
short: dict.weekdays.short[i],
iso: addDays(monday, i),
}));
const isCurrentWeek = monday === mondayOf(todayISO());
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]);
@@ -36,6 +40,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
);
const [selectedDish, setSelectedDish] = 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) {
setAdding({ iso, section });
@@ -108,66 +114,20 @@ export default function WeekEditor({ monday }: { monday: string }) {
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: 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 (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.weekEditor.title}
</h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
{dict.publicMenu.prevWeek}
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
{dict.publicMenu.today}
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => {
const isAdding =
adding?.iso === d.iso && adding?.section === s.key;
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
<>
{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>
<span className="font-bold">{localized(e.dish, locale)}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
@@ -245,11 +205,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
>
{dict.common.add}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={cancelAdding}
>
<button type="button" className="btn btn-neutral" onClick={cancelAdding}>
{dict.common.cancel}
</button>
</div>
@@ -258,18 +214,115 @@ export default function WeekEditor({ monday }: { monday: string }) {
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(d.iso, s.key)}
onClick={() => startAdding(iso, sectionKey)}
>
{dict.weekEditor.addMeal}
</button>
)}
</td>
</>
);
})}
}
const selectedDay = days[selectedDayIdx];
return (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.weekEditor.title}
</h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
{dict.publicMenu.prevWeek}
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
{dict.publicMenu.today}
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<>
{/* Mobil (< "sm"): ein Tag auf einmal, per Tabs gewählt */}
<div className="sm:hidden">
<div className="flex gap-1 mb-3">
{days.map((d, i) => (
<button
key={d.iso}
type="button"
onClick={() => setSelectedDayIdx(i)}
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>
))}
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
</h2>
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div>
</div>
))}
</div>
</div>
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<p className="mt-4 text-sm text-gray-500">
{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()
);
+16
View File
@@ -34,6 +34,19 @@ services:
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
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
# "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese
# Variable würde lib/branding.ts auf ${PWD}/data/branding
# zurückfallen, das weder gemountet noch für den nicht-root
# App-Nutzer beschreibbar ist (Upload schlägt mit 500 fehl).
BRANDING_DIR: /data/branding
ports:
- "0.0.0.0:3000:3000"
volumes:
@@ -71,6 +84,9 @@ services:
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
ports:
- "0.0.0.0:3000:3000"
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 { query, queryOne } from "@/lib/db";
import { query, queryOne, withTransaction } from "@/lib/db";
import type { CodeItem } from "@/lib/types";
// "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> {
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 { queryOne } from "@/lib/db";
export type LabelingMode = "detailed" | "notice";
export interface Settings {
colors: Record<string, string>;
logo_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> {
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",
viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto",
menuToggle: "Menü öffnen/schließen",
},
login: {
title: "Anmeldung",
submit: "Anmelden",
or: "oder",
passkeyButton: "Mit Passkey anmelden",
ssoButton: "Mit Single Sign-On anmelden",
backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled:
"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: {
prevWeek: "← Vorherige Woche",
@@ -63,6 +70,7 @@ const de = {
weekTab: "Woche",
allergens: "Allergene",
additives: "Zusatzstoffe",
labelingNoticeTitle: "Allergene & Zusatzstoffe",
suggestDish: "Rezept vorschlagen",
noEntry: "–",
rssFeed: "RSS-Feed abonnieren",
@@ -106,6 +114,15 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern 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: {
allergensTitle: "Allergene",
@@ -119,6 +136,13 @@ const de = {
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern 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: {
title: "Essenszeiten",
@@ -185,6 +209,20 @@ const de = {
confirmRemoveLogo: "Logo 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: {
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.",
@@ -296,18 +334,24 @@ const en: Dict = {
openSuggestions: "{count} open dish suggestions",
viewMenu: "View menu",
myAccount: "My account",
menuToggle: "Open/close menu",
},
login: {
title: "Sign in",
submit: "Log in",
or: "or",
passkeyButton: "Sign in with passkey",
ssoButton: "Sign in with Single Sign-On",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled:
"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: {
prevWeek: "← Previous week",
@@ -319,6 +363,7 @@ const en: Dict = {
weekTab: "Week",
allergens: "Allergens",
additives: "Additives",
labelingNoticeTitle: "Allergens & additives",
suggestDish: "Suggest a dish",
noEntry: "–",
rssFeed: "Subscribe to RSS feed",
@@ -362,6 +407,15 @@ const en: Dict = {
loadError: "Loading failed: {message}",
saveError: "Saving 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: {
allergensTitle: "Allergens",
@@ -375,6 +429,13 @@ const en: Dict = {
loadError: "Loading failed: {message}",
saveError: "Saving 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: {
title: "Meal times",
@@ -441,6 +502,20 @@ const en: Dict = {
confirmRemoveLogo: "Really remove the logo?",
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: {
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.",
@@ -550,18 +625,25 @@ const pl: Dict = {
openSuggestions: "{count} otwartych propozycji dań",
viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto",
menuToggle: "Otwórz/zamknij menu",
},
login: {
title: "Logowanie",
submit: "Zaloguj się",
or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu",
ssoButton: "Zaloguj się przez Single Sign-On",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled:
"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: {
prevWeek: "← Poprzedni tydzień",
@@ -573,6 +655,7 @@ const pl: Dict = {
weekTab: "Tydzień",
allergens: "Alergeny",
additives: "Dodatki",
labelingNoticeTitle: "Alergeny i dodatki",
suggestDish: "Zaproponuj danie",
noEntry: "–",
rssFeed: "Subskrybuj kanał RSS",
@@ -616,6 +699,15 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie 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: {
allergensTitle: "Alergeny",
@@ -629,6 +721,13 @@ const pl: Dict = {
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie 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: {
title: "Pory posiłków",
@@ -695,6 +794,20 @@ const pl: Dict = {
confirmRemoveLogo: "Czy na pewno usunąć logo?",
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: {
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.",
+78 -2
View File
@@ -1,15 +1,16 @@
{
"name": "canteen-menu",
"version": "1.6.2",
"version": "1.9.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.6.2",
"version": "1.9.5",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
@@ -1767,6 +1768,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4864,6 +4931,15 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.6.2",
"version": "1.9.5",
"private": true,
"scripts": {
"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": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10",
"pg": "^8.22.0",
"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