Compare commits

...

11 Commits

Author SHA1 Message Date
Kantine Wrapper
e62f0f03f3 chore: update build artifacts for v1.7.2 2026-03-12 15:35:46 +01:00
Kantine Wrapper
16fe0884aa feat: Implement secure logout functionality, add theme toggle, week navigation, and update version to 1.7.2. 2026-03-12 15:35:09 +01:00
Kantine Wrapper
b93f1000be chore: update build artifacts for v1.7.1 2026-03-12 13:13:42 +01:00
Kantine Wrapper
32c1e1e383 feat: Remove guest token usage, enhance highlight tag management with validation and improved UI, and add security tests. 2026-03-12 13:13:32 +01:00
Kantine Wrapper
877f0f3649 chore: update build artifacts for v1.6.25 2026-03-12 11:28:51 +01:00
Kantine Wrapper
b5568c964b perf: Refactor menu item height synchronization to prevent layout thrashing and introduce a debounced resize listener. 2026-03-12 11:28:32 +01:00
Kantine Wrapper
d1d355d3c2 chore: update build artifacts for v1.6.23 2026-03-12 10:54:54 +01:00
Kantine Wrapper
e33ec3eb1a style: Refine UI/UX with color and spacing adjustments, enhance accessibility and mobile responsiveness, and update the application version. 2026-03-12 10:51:05 +01:00
Kantine Wrapper
a9ec4ff8f6 feat: Replaced inline language selection with a dropdown menu and removed the weekly cost display. 2026-03-12 10:32:03 +01:00
Kantine Wrapper
38b6ad503f chore: update build artifacts for v1.6.19 2026-03-11 13:05:34 +01:00
Kantine Wrapper
570b0674b7 refactor: Adjust card and menu item styling for improved layout and spacing, and update the client version. 2026-03-11 13:05:26 +01:00
21 changed files with 1349 additions and 478 deletions

View File

@@ -17,7 +17,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-003 | Das System darf keine Zugangsdaten dauerhaft speichern. Die Authentifizierung muss sitzungsbasiert sein. | Hoch | v1.0.1 |
| FR-004 | Dem Benutzer muss angezeigt werden, ob und als wer er angemeldet ist (Vorname, Name oder ID). | Mittel | v1.0.1 |
| FR-005 | Nicht authentifizierte Benutzer müssen die Menüdaten einsehen können (eingeschränkter Lesezugriff). | Mittel | v1.0.1 |
| FR-006 | Das System muss eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 |
| FR-006 | Das System muss eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 (Update v1.7.2) |
| **Menüanzeige** | | | |
| FR-010 | Das System muss dem Benutzer alle verfügbaren Tagesmenüs einer Woche gleichzeitig in einer Übersicht darstellen. | Hoch | v1.0.1 |
| FR-011 | Das System muss dem Benutzer die Navigation zwischen der aktuellen und der kommenden Woche ermöglichen. | Mittel | v1.0.1 |
@@ -39,7 +39,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-032 | Nach Bestellschluss (Cutoff-Zeit) dürfen keine neuen Bestellungen oder Stornierungen für diesen Tag möglich sein. | Hoch | v1.0.1 |
| FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 |
| **Kostentransparenz & Bestellhistorie** | | | |
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
| ~~FR-040~~ | ~~Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen.~~ | ~~Mittel~~ | ~~v1.1.0~~ (Obsolet: Display entfernt auf User-Wunsch) |
| FR-041 | Das System muss dem Benutzer eine Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. Die Historie muss über ein lokales Delta-Caching verfügen, um Ladezeiten zu minimieren. | Mittel | v1.4.0 (Update v1.4.7) |
| **Bestell-Countdown** | | | |
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
@@ -63,7 +63,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-092 | Solange bestellbare Menüs für nächste Woche vorhanden sind, aber noch keine Bestellungen getätigt wurden (Prüfung MontagDonnerstag; Freitag ist ausgenommen), muss der entsprechende Navigation-Button animiert und farblich hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.7.0) |
| FR-093 | Das System muss dem Benutzer ermöglichen, durch Klicken auf das Alarm-Icon im Header eine manuelle Prüfung der geflaggten Menüs auszulösen. Während der Prüfung muss das Icon visuell animiert sein (Rotation). Nach Abschluss der Prüfung muss eine Toast-Nachricht mit der Anzahl der geprüften Menüs angezeigt werden. | Mittel | v1.6.13 |
| **Sprachfilter** | | | |
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Toggle zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 |
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Dropdown (Icon mit Label) zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 (Update v1.6.21) |
| FR-121 | Das System muss bei fehlenden Übersetzungen in zweisprachigen Menüs robust reagieren. Wenn ein Gang nur in einer Sprache vorliegt, muss dieser Teil für beide Sprachansichten herangezogen werden, um die Konsistenz der Ganganzahl zu gewährleisten. | Mittel | v1.6.10 |
| FR-122 | Bei Auswahl von EN muss die gesamte Benutzeroberfläche (Buttons, Tooltips, Modale, Status-Badges) auf Englisch umgestellt werden. Bei DE oder ALL verbleibt die GUI auf Deutsch. | Mittel | v1.7.0 |
| **Benutzer-Feedback** | | | |
@@ -88,7 +88,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| **Performance** | NFR-001 | Die Darstellung bereits gecachter Daten muss ohne spürbare Verzögerung erfolgen. | < 200 ms (UI-Rendering) |
| **Performance** | NFR-002 | Das Polling für geflaggte Menüs darf die reguläre Nutzung nicht beeinträchtigen. | Intervall ≥ 5 Minuten |
| **Sicherheit** | NFR-003 | Es dürfen keine Zugangsdaten dauerhaft gespeichert werden. | 0 (keine persistente Speicherung von Passwörtern) |
| **Sicherheit** | NFR-004 | Auth-Tokens müssen sitzungsbasiert gespeichert werden und bei Schließen des Browsers verfallen. | sessionStorage |
| **Sicherheit** | NFR-004 | Auth-Tokens werden persistent gespeichert, um eine dauerhafte Anmeldung zu ermöglichen. | localStorage |
| **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite |
| **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage |
| **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss standardmäßig in Deutsch sein. Bei aktivem EN-Modus muss die gesamte GUI auf Englisch umgestellt werden. | Vollständige Lokalisierung (DE default / EN on demand) |
@@ -97,7 +97,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
## 4. Technische Randbedingungen
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
* **Datenquelle**: Direkte Integration mit der Bessa REST-API (`api.bessa.app/v1`).
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token).
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme, Auth-Token).
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
* **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags.
* **Tests**: Python-basierte Build-Tests (`python3`) + Node.js-basierte Logik-Tests + Node.js-basierte DOM-Interaktionstests (JSDOM).

View File

@@ -1,4 +1,49 @@
## v1.6.18 (2026-03-11)
## v1.7.2 (2026-03-12)
- 🛡️ **Security**: Logout-Logik vervollständigt (FR-006). Beim Abmelden werden nun alle App-bezogenen Daten (inkl. Bestellhistorie, Cache und Einstellungen) aus dem `localStorage` gelöscht.
- 🧹 **Cleanup**: Veraltete `GUEST_TOKEN` Rückstände in `events.js` und `ui_helpers.js` entfernt.
- 🧪 **Testing**: Die Security-Test-Suite wurde um eine Verifikation der Logout-Datenlöschung erweitert.
## v1.7.1 (2026-03-12)
- 🛡️ **Security**: Kritischer Security-Fix und Härtung:
- **XSS-Schutz**: `innerHTML` durch `textContent` in `renderTagsList` (Actions) und `showErrorModal` (UI-Helpers) ersetzt.
- **XSS-Schutz**: Dynamische Kartenelemente in `createDayCard` validiert.
- **Input-Validierung**: Neue Schlagwörter werden nun auf Länge (2-20 Zeichen) und erlaubte Zeichen (Alphanumerisch + Food-Sonderzeichen) geprüft.
- **GUEST_TOKEN**: Der hardcodierte Gast-Token wurde komplett aus dem Code entfernt. Nicht-eingeloggte Nutzer haben keinen API-Zugriff mehr (Sicherheitsbestimmung).
- **Auth-Guards**: API-Funktionen (`loadMenuDataFromAPI`, `refreshFlaggedItems`) prüfen nun explizit auf vorhandene Authentifizierung vor dem Fetch.
- 🛡️ **Tech**: Sicherheits-Test-Suite `tests/test_security.js` implementiert.
## v1.6.25 (2026-03-12)
-**Performance**: Debounced Resize-Listener hinzugefügt. Die Höhen-Synchronisierung der Menü-Karten wird nun auch bei Viewport-Änderungen (z.B. Fenster-Skalierung oder Orientierungswechsel) automatisch und effizient ausgeführt.
- 🧹 **Tech**: `debounce` Utility-Funktion in `utils.js` ergänzt.
## v1.6.24 (2026-03-12)
-**Performance**: Layout Thrashing in `syncMenuItemHeights` behoben. Durch Batch-Verarbeitung von DOM-Lese- und Schreibvorgängen wurde die Rendering-Effizienz beim Wochenwechsel verbessert.
## v1.6.23 (2026-03-12)
- 🎨 **UI**: Umfassende UI-Verbesserungen umgesetzt:
- **Glassmorphism**: Header-Hintergrundtransparenz auf 72% reduziert (war 90%) der Blur-Effekt ist nun beim Scrollen sichtbar.
- **Dark-Mode Kontrast**: `--bg-card` abgedunkelt (`#283548`), `--border-color` leicht aufgehellt (`#526377`) bessere Trennung zwischen Body und Card.
- **Accent-Color**: Im Light-Mode von Slate-900 (fast schwarz) auf Blue-600 (`#2563eb`) geändert klarer sichtbarer Akzent.
- **Typography**: `.item-desc` `line-height` auf 1.5 (body-konsistent), `.day-date` kleiner und dezenter (0.8rem, opacity 0.75), `.item-name` leicht reduziert (0.95rem).
- **Item-Separator**: Subtile Trennlinie zwischen Menü-Items in der Tageskarte.
- **Badge-Konsistenz**: Alle Badges (`badge`, `tag-badge-small`) auf `border-radius: 6px` vereinheitlicht.
- **A11y Reduced Motion**: `@media (prefers-reduced-motion: reduce)` deaktiviert alle dekorativen Puls-/Glow-Animationen für Motion-sensitive Nutzer.
- **A11y Focus-Visible**: Globaler `:focus-visible` Outline-Ring (2px, accent-color) für Tastaturnavigation.
- **Active-States**: `:active` Feedback (`scale(0.97)`) für Bestell-, Storno- und Flag-Buttons.
- **Mobile Breakpoint**: Von 600px auf 768px erweitert (deckt Tablets ab); Grid-Deklaration explizit gesetzt um Browser-Override-Bug zu vermeiden.
## v1.6.22 (2026-03-12)
- 🧹 **UX Cleanup**: Text-Label am Sprachumschalter entfernt. Der Button zeigt nun nur noch das `translate`-Icon an, was die Controls-Bar ruhiger macht.
## v1.6.21 (2026-03-12)
-**Feature**: Sprachumschaltung Redesign Die Sprachwahl (DE/EN/ALL) wurde von der Header-Mitte in den rechten Controls-Bereich verschoben. Sie ist nun als Icon-Dropdown mit aktueller Status-Anzeige (z.B. "DE") verfügbar. Für die deutsche Sprache wird die 🇦🇹 Flagge verwendet.
## v1.6.20 (2026-03-12)
- 🧹 **Cleanup**: Wochenkosten-Anzeige entfernt (Weekly Cost Display) Auf User-Wunsch wurde die Anzeige der wöchentlichen Gesamtkosten im Header entfernt, um die UI zu entschlacken. FR-040 als obsolet markiert.
## v1.6.19 (2026-03-11)
- 🎨 **UX**: Grid-Layout & Glow Overlap Fix Die Karten-Inhalte wurden auf ein sauberes Grid-Gap-Modell umgestellt (`row-gap: 1.5rem`). Dies verhindert technische Überlappungen von Menü-Items und stellt sicher, dass Glow-Effekte (Bestellt, Highlight) alle Inhalte korrekt umschließen. Manuelle Abstände wurden bereinigt.
- 🎨 **UX**: Glow-Styling angepasst Die farblichen Hervorhebungen (Bestellt, Highlight, Flagged) wurden so korrigiert, dass sie nicht mehr bis an den Kartenrand reichen, sondern innerhalb des Karten-Bodys mit entsprechendem Seitenabstand angezeigt werden.
- 🎨 **UX**: Fix Card Content Overflow In der 5-Tage-Ansicht (Landscape) auf schmalen Bildschirmen umbrechen die Status-Badges und Buttons jetzt korrekt in eine neue Zeile, statt über den Kartenrand hinauszuragen. Das Karten-Padding wurde für Desktop-Ansichten optimiert.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

65
dist/install.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

327
dist/kantine.bundle.js vendored
View File

@@ -533,7 +533,12 @@ function saveFlags() {
async function refreshFlaggedItems() {
if (_state_js__WEBPACK_IMPORTED_MODULE_0__/* .userFlags */ .BY.size === 0) return;
const token = _state_js__WEBPACK_IMPORTED_MODULE_0__/* .authToken */ .gX || _constants_js__WEBPACK_IMPORTED_MODULE_2__/* .GUEST_TOKEN */ .f9;
const token = _state_js__WEBPACK_IMPORTED_MODULE_0__/* .authToken */ .gX;
if (!token) {
const bellBtn = document.getElementById('alarm-bell');
if (bellBtn) bellBtn.classList.remove('refreshing');
return;
}
// Collect unique dates that have flagged items
const datesToFetch = new Set();
@@ -726,14 +731,26 @@ function saveHighlightTags() {
}
function addHighlightTag(tag) {
tag = tag.trim().toLowerCase();
if (tag && !_state_js__WEBPACK_IMPORTED_MODULE_0__/* .highlightTags */ .yz.includes(tag)) {
const newTags = [..._state_js__WEBPACK_IMPORTED_MODULE_0__/* .highlightTags */ .yz, tag];
(0,_state_js__WEBPACK_IMPORTED_MODULE_0__/* .setHighlightTags */ .iw)(newTags);
saveHighlightTags();
return true;
if (!tag) return false;
tag = tag.trim();
if (tag.length < 2) {
showToast('Tag muss mindestens 2 Zeichen lang sein.', 'error');
return false;
}
return false;
if (tag.length > 20) {
showToast('Tag darf maximal 20 Zeichen lang sein.', 'error');
return false;
}
// Only allow alphanumeric characters, spaces and common special chars for food
if (!/^[a-zA-Z0-9äöüÄÖÜß\s\-\.]+$/.test(tag)) {
showToast('Ungültige Zeichen im Tag.', 'error');
return false;
}
if (_state_js__WEBPACK_IMPORTED_MODULE_0__/* .highlightTags */ .yz.includes(tag)) return false;
const newTags = [..._state_js__WEBPACK_IMPORTED_MODULE_0__/* .highlightTags */ .yz, tag];
(0,_state_js__WEBPACK_IMPORTED_MODULE_0__/* .setHighlightTags */ .iw)(newTags);
saveHighlightTags();
return true;
}
function removeHighlightTag(tag) {
@@ -744,19 +761,26 @@ function removeHighlightTag(tag) {
function renderTagsList() {
const list = document.getElementById('tags-list');
list.innerHTML = '';
if (!list) return;
list.innerHTML = ''; // Clear existing content
_state_js__WEBPACK_IMPORTED_MODULE_0__/* .highlightTags */ .yz.forEach(tag => {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">&times;</span>`;
list.appendChild(badge);
});
list.querySelectorAll('.tag-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
removeHighlightTag(e.target.dataset.tag);
const label = document.createElement('span');
label.textContent = tag;
badge.appendChild(label);
const removeBtn = document.createElement('span');
removeBtn.className = 'tag-remove';
removeBtn.innerHTML = '&times;';
removeBtn.title = (0,_i18n_js__WEBPACK_IMPORTED_MODULE_5__.t)('removeTagTooltip') || 'Entfernen';
removeBtn.onclick = () => {
removeHighlightTag(tag);
renderTagsList();
});
};
badge.appendChild(removeBtn);
list.appendChild(badge);
});
}
@@ -840,7 +864,11 @@ async function loadMenuDataFromAPI() {
loading.classList.remove('hidden');
const token = _state_js__WEBPACK_IMPORTED_MODULE_0__/* .authToken */ .gX || _constants_js__WEBPACK_IMPORTED_MODULE_2__/* .GUEST_TOKEN */ .f9;
const token = _state_js__WEBPACK_IMPORTED_MODULE_0__/* .authToken */ .gX;
if (!token) {
loading.classList.add('hidden');
return;
}
try {
progressModal.classList.remove('hidden');
@@ -1007,7 +1035,8 @@ async function loadMenuDataFromAPI() {
Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 842)).then(uiHelpers => {
uiHelpers.showErrorModal(
'Keine Verbindung',
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${(0,_utils_js__WEBPACK_IMPORTED_MODULE_1__/* .escapeHtml */ .ZD)(error.message)}</small>`,
'Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.',
error.message,
'Zur Original-Seite',
'https://web.bessa.app/knapp-kantine'
);
@@ -1084,16 +1113,19 @@ function showToast(message, type = 'info') {
/**
* Returns request headers for the Bessa REST API.
* @param {string|null} token - Auth token; falls back to GUEST_TOKEN if absent.
* @param {string|null} token - Auth token.
* @returns {Object} HTTP headers for fetch()
*/
function apiHeaders(token) {
return {
'Authorization': `Token ${token || _constants_js__WEBPACK_IMPORTED_MODULE_0__/* .GUEST_TOKEN */ .f9}`,
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Client-Version': _constants_js__WEBPACK_IMPORTED_MODULE_0__/* .CLIENT_VERSION */ .fZ
};
if (token) {
headers['Authorization'] = `Token ${token}`;
}
return headers;
}
/**
@@ -1116,7 +1148,6 @@ function githubHeaders() {
/* harmony export */ YU: () => (/* binding */ MENU_ID),
/* harmony export */ d_: () => (/* binding */ INSTALLER_BASE),
/* harmony export */ eW: () => (/* binding */ VENUE_ID),
/* harmony export */ f9: () => (/* binding */ GUEST_TOKEN),
/* harmony export */ fZ: () => (/* binding */ CLIENT_VERSION),
/* harmony export */ fv: () => (/* binding */ POLL_INTERVAL_MS),
/* harmony export */ pe: () => (/* binding */ GITHUB_API),
@@ -1132,11 +1163,8 @@ function githubHeaders() {
/** Base URL for the Bessa REST API (v1). */
const API_BASE = 'https://api.bessa.app/v1';
/** Guest token for unauthenticated API calls (e.g. browsing the menu). */
const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
/** The client version injected into every API request header. */
const CLIENT_VERSION = 'v1.6.18';
const CLIENT_VERSION = '{{VERSION}}';
/** Bessa venue ID for Knapp-Kantine. */
const VENUE_ID = 591;
@@ -1292,7 +1320,6 @@ const TRANSLATIONS = {
noMenuDataHint: 'Versuchen Sie eine andere Woche oder schauen Sie später vorbei.',
// Weekly cost
costLabel: 'Gesamt',
// Countdown
orderDeadline: 'Bestellschluss',
@@ -1428,7 +1455,6 @@ const TRANSLATIONS = {
noMenuDataHint: 'Try another week or check back later.',
// Weekly cost
costLabel: 'Total',
// Countdown
orderDeadline: 'Order deadline',
@@ -1580,9 +1606,10 @@ function setLangMode(lang) {
/* harmony export */ OR: () => (/* binding */ renderVisibleWeeks),
/* harmony export */ Ux: () => (/* binding */ checkForUpdates),
/* harmony export */ gJ: () => (/* binding */ updateNextWeekBadge),
/* harmony export */ showErrorModal: () => (/* binding */ showErrorModal)
/* harmony export */ showErrorModal: () => (/* binding */ showErrorModal),
/* harmony export */ wy: () => (/* binding */ syncMenuItemHeights)
/* harmony export */ });
/* unused harmony exports updateWeeklyCost, syncMenuItemHeights, createDayCard, fetchVersions, updateCountdown, removeCountdown */
/* unused harmony exports createDayCard, fetchVersions, updateCountdown, removeCountdown */
/* harmony import */ var _state_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(901);
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(413);
/* harmony import */ var _constants_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(521);
@@ -1681,29 +1708,6 @@ function updateNextWeekBadge() {
}
}
function updateWeeklyCost(days) {
let totalCost = 0;
if (days && days.length > 0) {
days.forEach(day => {
if (day.items) {
day.items.forEach(item => {
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const key = `${day.date}_${articleId}`;
const orders = _state_js__WEBPACK_IMPORTED_MODULE_0__/* .orderMap */ .L.get(key) || [];
if (orders.length > 0) totalCost += item.price * orders.length;
});
}
});
}
const costDisplay = document.getElementById('weekly-cost-display');
if (totalCost > 0) {
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>${(0,_i18n_js__WEBPACK_IMPORTED_MODULE_5__.t)('costLabel')}: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
costDisplay.classList.remove('hidden');
} else {
costDisplay.classList.add('hidden');
}
}
function renderVisibleWeeks() {
const menuContainer = document.getElementById('menu-container');
@@ -1730,11 +1734,9 @@ function renderVisibleWeeks() {
<p>${(0,_i18n_js__WEBPACK_IMPORTED_MODULE_5__.t)('noMenuData')} ${targetWeek} (${targetYear}).</p>
<small>${(0,_i18n_js__WEBPACK_IMPORTED_MODULE_5__.t)('noMenuDataHint')}</small>
</div>`;
document.getElementById('weekly-cost-display').classList.add('hidden');
return;
}
updateWeeklyCost(daysInTargetWeek);
const headerWeekInfo = document.getElementById('header-week-info');
const weekTitle = _state_js__WEBPACK_IMPORTED_MODULE_0__/* .displayMode */ .sw === 'this-week' ? (0,_i18n_js__WEBPACK_IMPORTED_MODULE_5__.t)('thisWeek') : (0,_i18n_js__WEBPACK_IMPORTED_MODULE_5__.t)('nextWeek');
@@ -1765,23 +1767,39 @@ function renderVisibleWeeks() {
function syncMenuItemHeights(grid) {
const cards = grid.querySelectorAll('.menu-card');
if (cards.length === 0) return;
// 1. Gather all menu-item groups (rows) across cards
const itemRows = [];
let maxItems = 0;
cards.forEach(card => {
maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);
const cardItems = Array.from(cards).map(card => {
const items = Array.from(card.querySelectorAll('.menu-item'));
maxItems = Math.max(maxItems, items.length);
return items;
});
for (let i = 0; i < maxItems; i++) {
let maxHeight = 0;
const itemsAtPos = [];
cards.forEach(card => {
const items = card.querySelectorAll('.menu-item');
if (items[i]) {
items[i].style.height = 'auto';
maxHeight = Math.max(maxHeight, items[i].offsetHeight);
itemsAtPos.push(items[i]);
}
});
itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });
// Collect i-th item from each card (forming a "row")
itemRows[i] = cardItems.map(items => items[i]).filter(item => !!item);
}
// 2. Batch Reset (Write phase) - clear old heights to let them flow naturally
itemRows.flat().forEach(item => {
item.style.height = 'auto';
});
// 3. Batch Read (Read phase) - measure all heights in one pass to avoid layout thrashing
const rowMaxHeights = itemRows.map(row => {
return Math.max(...row.map(item => item.offsetHeight));
});
// 4. Batch Apply (Write phase) - set synchronized heights
itemRows.forEach((row, i) => {
const height = `${rowMaxHeights[i]}px`;
row.forEach(item => {
item.style.height = height;
});
});
}
function createDayCard(day) {
@@ -2232,56 +2250,79 @@ function removeCountdown() {
setInterval(updateCountdown, 60000);
setTimeout(updateCountdown, 1000);
function showErrorModal(title, htmlContent, btnText, url) {
function showErrorModal(title, message, details, btnText, url) {
const modalId = 'error-modal';
let modal = document.getElementById(modalId);
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = modalId;
modal.className = 'modal hidden';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
<span class="material-icons-round">signal_wifi_off</span>
${(0,_utils_js__WEBPACK_IMPORTED_MODULE_1__/* .escapeHtml */ .ZD)(title)}
</h2>
</div>
<div style="padding: 20px;">
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
<div style="margin-top: 20px; display: flex; justify-content: center;">
<button id="btn-error-redirect" style="
background-color: var(--accent-color);
color: white;
padding: 12px 24px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
transition: transform 0.1s;
">
${(0,_utils_js__WEBPACK_IMPORTED_MODULE_1__/* .escapeHtml */ .ZD)(btnText)}
<span class="material-icons-round">open_in_new</span>
</button>
</div>
</div>
</div>
modal.className = 'modal'; // Removed hidden because we are showing it now
const content = document.createElement('div');
content.className = 'modal-content';
const header = document.createElement('div');
header.className = 'modal-header';
const h2 = document.createElement('h2');
h2.style.cssText = 'color: var(--error-color); display: flex; align-items: center; gap: 10px;';
const icon = document.createElement('span');
icon.className = 'material-icons-round';
icon.textContent = 'signal_wifi_off';
h2.appendChild(icon);
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
h2.appendChild(titleSpan);
header.appendChild(h2);
content.appendChild(header);
const body = document.createElement('div');
body.style.padding = '20px';
const p = document.createElement('p');
p.style.cssText = 'margin-bottom: 15px; color: var(--text-primary);';
p.textContent = message;
body.appendChild(p);
if (details) {
const small = document.createElement('small');
small.style.cssText = 'display: block; margin-top: 10px; color: var(--text-secondary);';
small.textContent = details;
body.appendChild(small);
}
const footer = document.createElement('div');
footer.style.cssText = 'margin-top: 20px; display: flex; justify-content: center;';
const btn = document.createElement('button');
btn.style.cssText = `
background-color: var(--accent-color);
color: white;
padding: 12px 24px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.3);
`;
btn.textContent = btnText || 'Zur Original-Seite';
btn.onclick = () => {
window.open(url || 'https://web.bessa.app/knapp-kantine', '_blank');
modal.classList.add('hidden');
};
footer.appendChild(btn);
body.appendChild(footer);
content.appendChild(body);
modal.appendChild(content);
document.body.appendChild(modal);
document.getElementById('btn-error-redirect').addEventListener('click', () => {
window.location.href = url;
});
requestAnimationFrame(() => {
modal.classList.remove('hidden');
});
}
function updateAlarmBell() {
@@ -2356,6 +2397,7 @@ function updateAlarmBell() {
/* harmony export */ U4: () => (/* binding */ isNewer),
/* harmony export */ ZD: () => (/* binding */ escapeHtml),
/* harmony export */ gs: () => (/* binding */ getRelativeTime),
/* harmony export */ sg: () => (/* binding */ debounce),
/* harmony export */ sn: () => (/* binding */ getISOWeek)
/* harmony export */ });
/* unused harmony export splitLanguage */
@@ -2609,6 +2651,18 @@ function getLocalizedText(text) {
return split.de || split.raw;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/***/ }
@@ -2718,13 +2772,7 @@ function injectUI() {
</button>
</div>
<div class="header-center-wrapper">
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
<button class="lang-btn${state/* langMode */.Kl === 'de' ? ' active' : ''}" data-lang="de">DE</button>
<button class="lang-btn${state/* langMode */.Kl === 'en' ? ' active' : ''}" data-lang="en">EN</button>
<button class="lang-btn${state/* langMode */.Kl === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
</div>
<div id="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div>
<div class="controls">
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
@@ -2739,6 +2787,16 @@ function injectUI() {
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme" title="Erscheinungsbild (Hell/Dunkel) wechseln">
<span class="material-icons-round theme-icon">light_mode</span>
</button>
<div id="lang-toggle" class="lang-toggle-dropdown" title="Sprache der Menübeschreibung">
<button id="btn-lang-toggle" class="icon-btn" aria-label="Sprache wählen" title="Sprache der Menübeschreibung">
<span class="material-icons-round">translate</span>
</button>
<div id="lang-dropdown" class="lang-dropdown-menu hidden">
<button class="lang-btn${state/* langMode */.Kl === 'de' ? ' active' : ''}" data-lang="de">🇦🇹 DE</button>
<button class="lang-btn${state/* langMode */.Kl === 'en' ? ' active' : ''}" data-lang="en">🇬🇧 EN</button>
<button class="lang-btn${state/* langMode */.Kl === 'all' ? ' active' : ''}" data-lang="all">🌐 ALL</button>
</div>
</div>
<button id="btn-login-open" class="user-badge-btn icon-btn-small" title="Mit Bessa.app Account anmelden">
<span class="material-icons-round">login</span>
<span>Anmelden</span>
@@ -2907,6 +2965,8 @@ var constants = __webpack_require__(521);
var api = __webpack_require__(672);
// EXTERNAL MODULE: ./src/i18n.js
var i18n = __webpack_require__(646);
// EXTERNAL MODULE: ./src/utils.js
var utils = __webpack_require__(413);
;// ./src/events.js
@@ -2915,6 +2975,7 @@ var i18n = __webpack_require__(646);
/**
* Updates all static UI labels/tooltips to match the current language.
* Called when the user switches the language toggle.
@@ -3030,12 +3091,22 @@ function bindEvents() {
const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close');
const btnLangToggle = document.getElementById('btn-lang-toggle');
const langDropdown = document.getElementById('lang-dropdown');
if (btnLangToggle && langDropdown) {
btnLangToggle.addEventListener('click', (e) => {
e.stopPropagation();
langDropdown.classList.toggle('hidden');
});
}
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => {
(0,state/* setLangMode */.UD)(btn.dataset.lang);
localStorage.setItem(constants.LS.LANG, btn.dataset.lang);
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (langDropdown) langDropdown.classList.add('hidden');
updateUILanguage();
});
});
@@ -3069,6 +3140,9 @@ function bindEvents() {
window.addEventListener('click', (e) => {
if (e.target === historyModal) historyModal.classList.add('hidden');
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
if (langDropdown && !langDropdown.classList.contains('hidden') && !e.target.closest('#lang-toggle')) {
langDropdown.classList.add('hidden');
}
});
const versionTag = document.querySelector('.version-tag');
@@ -3204,7 +3278,7 @@ function bindEvents() {
const email = `knapp-${employeeId}@bessa.app`;
const response = await fetch(`${constants/* API_BASE */.tE}/auth/login/`, {
method: 'POST',
headers: (0,api/* apiHeaders */.H)(constants/* GUEST_TOKEN */.f9),
headers: (0,api/* apiHeaders */.H)(),
body: JSON.stringify({ email, password })
});
@@ -3250,10 +3324,13 @@ function bindEvents() {
});
btnLogout.addEventListener('click', () => {
localStorage.removeItem(constants.LS.AUTH_TOKEN);
localStorage.removeItem(constants.LS.CURRENT_USER);
localStorage.removeItem(constants.LS.FIRST_NAME);
localStorage.removeItem(constants.LS.LAST_NAME);
// Secure Logout (FR-006): Clear all application-related data from localStorage
Object.keys(localStorage).forEach(key => {
if (key.startsWith('kantine_')) {
localStorage.removeItem(key);
}
});
(0,state/* setAuthToken */.O5)(null);
(0,state/* setCurrentUser */.lt)(null);
(0,state/* setOrderMap */.di)(new Map());
@@ -3261,6 +3338,12 @@ function bindEvents() {
(0,actions/* updateAuthUI */.i_)();
(0,ui_helpers/* renderVisibleWeeks */.OR)();
});
// Sync heights on window resize (FR-Performance)
window.addEventListener('resize', (0,utils/* debounce */.sg)(() => {
const grid = document.querySelector('.days-grid');
if (grid) (0,ui_helpers/* syncMenuItemHeights */.wy)(grid);
}, 150));
}
;// ./src/index.js

View File

@@ -1,6 +1,6 @@
import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js';
import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer } from './utils.js';
import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
import { API_BASE, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
import { apiHeaders, githubHeaders } from './api.js';
import { renderVisibleWeeks, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js';
import { t } from './i18n.js';
@@ -500,7 +500,12 @@ export function saveFlags() {
export async function refreshFlaggedItems() {
if (userFlags.size === 0) return;
const token = authToken || GUEST_TOKEN;
const token = authToken;
if (!token) {
const bellBtn = document.getElementById('alarm-bell');
if (bellBtn) bellBtn.classList.remove('refreshing');
return;
}
// Collect unique dates that have flagged items
const datesToFetch = new Set();
@@ -693,14 +698,26 @@ export function saveHighlightTags() {
}
export function addHighlightTag(tag) {
tag = tag.trim().toLowerCase();
if (tag && !highlightTags.includes(tag)) {
const newTags = [...highlightTags, tag];
setHighlightTags(newTags);
saveHighlightTags();
return true;
if (!tag) return false;
tag = tag.trim();
if (tag.length < 2) {
showToast('Tag muss mindestens 2 Zeichen lang sein.', 'error');
return false;
}
return false;
if (tag.length > 20) {
showToast('Tag darf maximal 20 Zeichen lang sein.', 'error');
return false;
}
// Only allow alphanumeric characters, spaces and common special chars for food
if (!/^[a-zA-Z0-9äöüÄÖÜß\s\-\.]+$/.test(tag)) {
showToast('Ungültige Zeichen im Tag.', 'error');
return false;
}
if (highlightTags.includes(tag)) return false;
const newTags = [...highlightTags, tag];
setHighlightTags(newTags);
saveHighlightTags();
return true;
}
export function removeHighlightTag(tag) {
@@ -711,19 +728,26 @@ export function removeHighlightTag(tag) {
export function renderTagsList() {
const list = document.getElementById('tags-list');
list.innerHTML = '';
if (!list) return;
list.innerHTML = ''; // Clear existing content
highlightTags.forEach(tag => {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">&times;</span>`;
list.appendChild(badge);
});
list.querySelectorAll('.tag-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
removeHighlightTag(e.target.dataset.tag);
const label = document.createElement('span');
label.textContent = tag;
badge.appendChild(label);
const removeBtn = document.createElement('span');
removeBtn.className = 'tag-remove';
removeBtn.innerHTML = '&times;';
removeBtn.title = t('removeTagTooltip') || 'Entfernen';
removeBtn.onclick = () => {
removeHighlightTag(tag);
renderTagsList();
});
};
badge.appendChild(removeBtn);
list.appendChild(badge);
});
}
@@ -807,7 +831,11 @@ export async function loadMenuDataFromAPI() {
loading.classList.remove('hidden');
const token = authToken || GUEST_TOKEN;
const token = authToken;
if (!token) {
loading.classList.add('hidden');
return;
}
try {
progressModal.classList.remove('hidden');
@@ -974,7 +1002,8 @@ export async function loadMenuDataFromAPI() {
import('./ui_helpers.js').then(uiHelpers => {
uiHelpers.showErrorModal(
'Keine Verbindung',
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${escapeHtml(error.message)}</small>`,
'Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.',
error.message,
'Zur Original-Seite',
'https://web.bessa.app/knapp-kantine'
);

View File

@@ -3,20 +3,23 @@
* All fetch calls in the app route through these helpers to ensure
* consistent auth and versioning headers.
*/
import { API_BASE, GUEST_TOKEN, CLIENT_VERSION } from './constants.js';
import { API_BASE, CLIENT_VERSION } from './constants.js';
/**
* Returns request headers for the Bessa REST API.
* @param {string|null} token - Auth token; falls back to GUEST_TOKEN if absent.
* @param {string|null} token - Auth token.
* @returns {Object} HTTP headers for fetch()
*/
export function apiHeaders(token) {
return {
'Authorization': `Token ${token || GUEST_TOKEN}`,
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Client-Version': CLIENT_VERSION
};
if (token) {
headers['Authorization'] = `Token ${token}`;
}
return headers;
}
/**

View File

@@ -7,11 +7,8 @@
/** Base URL for the Bessa REST API (v1). */
export const API_BASE = 'https://api.bessa.app/v1';
/** Guest token for unauthenticated API calls (e.g. browsing the menu). */
export const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
/** The client version injected into every API request header. */
export const CLIENT_VERSION = 'v1.6.18';
export const CLIENT_VERSION = '{{VERSION}}';
/** Bessa venue ID for Knapp-Kantine. */
export const VENUE_ID = 591;

View File

@@ -1,9 +1,10 @@
import { displayMode, langMode, authToken, currentUser, orderMap, userFlags, pollIntervalId, setLangMode, setDisplayMode, setAuthToken, setCurrentUser, setOrderMap } from './state.js';
import { updateAuthUI, loadMenuDataFromAPI, fetchOrders, startPolling, stopPolling, fetchFullOrderHistory, addHighlightTag, renderTagsList, refreshFlaggedItems } from './actions.js';
import { renderVisibleWeeks, openVersionMenu, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js';
import { API_BASE, GUEST_TOKEN, LS } from './constants.js';
import { renderVisibleWeeks, openVersionMenu, updateNextWeekBadge, updateAlarmBell, syncMenuItemHeights } from './ui_helpers.js';
import { API_BASE, LS } from './constants.js';
import { apiHeaders } from './api.js';
import { t } from './i18n.js';
import { debounce } from './utils.js';
/**
* Updates all static UI labels/tooltips to match the current language.
@@ -120,12 +121,22 @@ export function bindEvents() {
const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close');
const btnLangToggle = document.getElementById('btn-lang-toggle');
const langDropdown = document.getElementById('lang-dropdown');
if (btnLangToggle && langDropdown) {
btnLangToggle.addEventListener('click', (e) => {
e.stopPropagation();
langDropdown.classList.toggle('hidden');
});
}
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => {
setLangMode(btn.dataset.lang);
localStorage.setItem(LS.LANG, btn.dataset.lang);
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (langDropdown) langDropdown.classList.add('hidden');
updateUILanguage();
});
});
@@ -159,6 +170,9 @@ export function bindEvents() {
window.addEventListener('click', (e) => {
if (e.target === historyModal) historyModal.classList.add('hidden');
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
if (langDropdown && !langDropdown.classList.contains('hidden') && !e.target.closest('#lang-toggle')) {
langDropdown.classList.add('hidden');
}
});
const versionTag = document.querySelector('.version-tag');
@@ -294,7 +308,7 @@ export function bindEvents() {
const email = `knapp-${employeeId}@bessa.app`;
const response = await fetch(`${API_BASE}/auth/login/`, {
method: 'POST',
headers: apiHeaders(GUEST_TOKEN),
headers: apiHeaders(),
body: JSON.stringify({ email, password })
});
@@ -340,10 +354,13 @@ export function bindEvents() {
});
btnLogout.addEventListener('click', () => {
localStorage.removeItem(LS.AUTH_TOKEN);
localStorage.removeItem(LS.CURRENT_USER);
localStorage.removeItem(LS.FIRST_NAME);
localStorage.removeItem(LS.LAST_NAME);
// Secure Logout (FR-006): Clear all application-related data from localStorage
Object.keys(localStorage).forEach(key => {
if (key.startsWith('kantine_')) {
localStorage.removeItem(key);
}
});
setAuthToken(null);
setCurrentUser(null);
setOrderMap(new Map());
@@ -351,4 +368,10 @@ export function bindEvents() {
updateAuthUI();
renderVisibleWeeks();
});
// Sync heights on window resize (FR-Performance)
window.addEventListener('resize', debounce(() => {
const grid = document.querySelector('.days-grid');
if (grid) syncMenuItemHeights(grid);
}, 150));
}

View File

@@ -100,7 +100,6 @@ const TRANSLATIONS = {
noMenuDataHint: 'Versuchen Sie eine andere Woche oder schauen Sie später vorbei.',
// Weekly cost
costLabel: 'Gesamt',
// Countdown
orderDeadline: 'Bestellschluss',
@@ -236,7 +235,6 @@ const TRANSLATIONS = {
noMenuDataHint: 'Try another week or check back later.',
// Weekly cost
costLabel: 'Total',
// Countdown
orderDeadline: 'Order deadline',

View File

@@ -54,13 +54,7 @@ export function injectUI() {
</button>
</div>
<div class="header-center-wrapper">
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">DE</button>
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">EN</button>
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
</div>
<div id="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div>
<div class="controls">
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
@@ -75,6 +69,16 @@ export function injectUI() {
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme" title="Erscheinungsbild (Hell/Dunkel) wechseln">
<span class="material-icons-round theme-icon">light_mode</span>
</button>
<div id="lang-toggle" class="lang-toggle-dropdown" title="Sprache der Menübeschreibung">
<button id="btn-lang-toggle" class="icon-btn" aria-label="Sprache wählen" title="Sprache der Menübeschreibung">
<span class="material-icons-round">translate</span>
</button>
<div id="lang-dropdown" class="lang-dropdown-menu hidden">
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">🇦🇹 DE</button>
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">🇬🇧 EN</button>
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">🌐 ALL</button>
</div>
</div>
<button id="btn-login-open" class="user-badge-btn icon-btn-small" title="Mit Bessa.app Account anmelden">
<span class="material-icons-round">login</span>
<span>Anmelden</span>

View File

@@ -1,6 +1,6 @@
import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js';
import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer, getLocalizedText } from './utils.js';
import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
import { API_BASE, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
import { apiHeaders, githubHeaders } from './api.js';
import { placeOrder, cancelOrder, toggleFlag, showToast, checkHighlight, loadMenuDataFromAPI } from './actions.js';
import { t } from './i18n.js';
@@ -90,29 +90,6 @@ export function updateNextWeekBadge() {
}
}
export function updateWeeklyCost(days) {
let totalCost = 0;
if (days && days.length > 0) {
days.forEach(day => {
if (day.items) {
day.items.forEach(item => {
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const key = `${day.date}_${articleId}`;
const orders = orderMap.get(key) || [];
if (orders.length > 0) totalCost += item.price * orders.length;
});
}
});
}
const costDisplay = document.getElementById('weekly-cost-display');
if (totalCost > 0) {
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>${t('costLabel')}: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
costDisplay.classList.remove('hidden');
} else {
costDisplay.classList.add('hidden');
}
}
export function renderVisibleWeeks() {
const menuContainer = document.getElementById('menu-container');
@@ -139,11 +116,9 @@ export function renderVisibleWeeks() {
<p>${t('noMenuData')} ${targetWeek} (${targetYear}).</p>
<small>${t('noMenuDataHint')}</small>
</div>`;
document.getElementById('weekly-cost-display').classList.add('hidden');
return;
}
updateWeeklyCost(daysInTargetWeek);
const headerWeekInfo = document.getElementById('header-week-info');
const weekTitle = displayMode === 'this-week' ? t('thisWeek') : t('nextWeek');
@@ -174,23 +149,39 @@ export function renderVisibleWeeks() {
export function syncMenuItemHeights(grid) {
const cards = grid.querySelectorAll('.menu-card');
if (cards.length === 0) return;
// 1. Gather all menu-item groups (rows) across cards
const itemRows = [];
let maxItems = 0;
cards.forEach(card => {
maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);
const cardItems = Array.from(cards).map(card => {
const items = Array.from(card.querySelectorAll('.menu-item'));
maxItems = Math.max(maxItems, items.length);
return items;
});
for (let i = 0; i < maxItems; i++) {
let maxHeight = 0;
const itemsAtPos = [];
cards.forEach(card => {
const items = card.querySelectorAll('.menu-item');
if (items[i]) {
items[i].style.height = 'auto';
maxHeight = Math.max(maxHeight, items[i].offsetHeight);
itemsAtPos.push(items[i]);
}
});
itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });
// Collect i-th item from each card (forming a "row")
itemRows[i] = cardItems.map(items => items[i]).filter(item => !!item);
}
// 2. Batch Reset (Write phase) - clear old heights to let them flow naturally
itemRows.flat().forEach(item => {
item.style.height = 'auto';
});
// 3. Batch Read (Read phase) - measure all heights in one pass to avoid layout thrashing
const rowMaxHeights = itemRows.map(row => {
return Math.max(...row.map(item => item.offsetHeight));
});
// 4. Batch Apply (Write phase) - set synchronized heights
itemRows.forEach((row, i) => {
const height = `${rowMaxHeights[i]}px`;
row.forEach(item => {
item.style.height = height;
});
});
}
export function createDayCard(day) {
@@ -641,56 +632,79 @@ export function removeCountdown() {
setInterval(updateCountdown, 60000);
setTimeout(updateCountdown, 1000);
export function showErrorModal(title, htmlContent, btnText, url) {
export function showErrorModal(title, message, details, btnText, url) {
const modalId = 'error-modal';
let modal = document.getElementById(modalId);
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = modalId;
modal.className = 'modal hidden';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
<span class="material-icons-round">signal_wifi_off</span>
${escapeHtml(title)}
</h2>
</div>
<div style="padding: 20px;">
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
<div style="margin-top: 20px; display: flex; justify-content: center;">
<button id="btn-error-redirect" style="
background-color: var(--accent-color);
color: white;
padding: 12px 24px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
transition: transform 0.1s;
">
${escapeHtml(btnText)}
<span class="material-icons-round">open_in_new</span>
</button>
</div>
</div>
</div>
modal.className = 'modal'; // Removed hidden because we are showing it now
const content = document.createElement('div');
content.className = 'modal-content';
const header = document.createElement('div');
header.className = 'modal-header';
const h2 = document.createElement('h2');
h2.style.cssText = 'color: var(--error-color); display: flex; align-items: center; gap: 10px;';
const icon = document.createElement('span');
icon.className = 'material-icons-round';
icon.textContent = 'signal_wifi_off';
h2.appendChild(icon);
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
h2.appendChild(titleSpan);
header.appendChild(h2);
content.appendChild(header);
const body = document.createElement('div');
body.style.padding = '20px';
const p = document.createElement('p');
p.style.cssText = 'margin-bottom: 15px; color: var(--text-primary);';
p.textContent = message;
body.appendChild(p);
if (details) {
const small = document.createElement('small');
small.style.cssText = 'display: block; margin-top: 10px; color: var(--text-secondary);';
small.textContent = details;
body.appendChild(small);
}
const footer = document.createElement('div');
footer.style.cssText = 'margin-top: 20px; display: flex; justify-content: center;';
const btn = document.createElement('button');
btn.style.cssText = `
background-color: var(--accent-color);
color: white;
padding: 12px 24px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.3);
`;
btn.textContent = btnText || 'Zur Original-Seite';
btn.onclick = () => {
window.open(url || 'https://web.bessa.app/knapp-kantine', '_blank');
modal.classList.add('hidden');
};
footer.appendChild(btn);
body.appendChild(footer);
content.appendChild(body);
modal.appendChild(content);
document.body.appendChild(modal);
document.getElementById('btn-error-redirect').addEventListener('click', () => {
window.location.href = url;
});
requestAnimationFrame(() => {
modal.classList.remove('hidden');
});
}
export function updateAlarmBell() {

View File

@@ -246,3 +246,15 @@ export function getLocalizedText(text) {
if (langMode === 'en') return split.en || split.raw;
return split.de || split.raw;
}
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

189
style.css
View File

@@ -6,8 +6,8 @@
--text-primary: #334155;
/* Slate 700 */
--text-secondary: #64748b;
--accent-color: #0f172a;
/* Slate 900 (High contrast) */
--accent-color: #2563eb;
/* Blue 600 visible accent, distinguishable from text */
--border-color: #cbd5e1;
/* Slate 300 */
--banner-bg: #e2e8f0;
@@ -15,7 +15,8 @@
--success-color: #059669;
--error-color: #dc2626;
--card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--header-bg: rgba(255, 255, 255, 0.9);
/* Reduced opacity for visible glassmorphism blur effect */
--header-bg: rgba(255, 255, 255, 0.72);
--header-border: 1px solid rgba(203, 213, 225, 0.6);
}
@@ -23,19 +24,20 @@
/* Premium Slate/Gray-Blue Palette - Dark Mode */
--bg-body: #1e293b;
/* Deep Slate Gray (Requested) */
--bg-card: #334155;
/* Slate 700 */
--bg-card: #283548;
/* Darker than Slate 700 → more layer contrast vs bg-body */
--text-primary: #f8fafc;
/* Slate 50 */
--text-secondary: #cbd5e1;
/* Slate 300 */
--accent-color: #60a5fa;
/* Blue 400 */
--border-color: #475569;
/* Slate 600 */
--border-color: #526377;
/* Slightly lighter → visible border on darker card bg */
--banner-bg: #475569;
--banner-text: #e2e8f0;
--header-bg: rgba(30, 41, 59, 0.9);
/* Reduced opacity for visible glassmorphism blur effect */
--header-bg: rgba(30, 41, 59, 0.72);
--header-border: 1px solid rgba(71, 85, 105, 0.6);
--card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4);
}
@@ -404,23 +406,6 @@ body {
font-weight: 500;
}
.weekly-cost {
background-color: rgba(59, 130, 246, 0.1);
/* Blue tint */
color: var(--accent-color);
padding: 0.4rem 0.8rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.weekly-cost .material-icons-round {
font-size: 18px;
}
/* Container - flex column, full width so child scrollbar is at edge */
.container {
@@ -470,6 +455,64 @@ body {
font-weight: 500;
}
/* Language Toggle */
.lang-toggle-dropdown {
position: relative;
display: flex;
align-items: center;
}
#btn-lang-toggle {
padding: 0;
min-width: 42px;
}
.lang-dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
z-index: 1001;
min-width: 120px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
animation: modalSlide 0.2s ease-out;
}
.lang-dropdown-menu .lang-btn {
background: none;
border: none;
padding: 10px 14px;
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.lang-dropdown-menu .lang-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: var(--accent-color);
}
.lang-dropdown-menu .lang-btn.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent-color);
font-weight: 700;
}
.icon-btn-small {
background: none;
border: none;
@@ -829,7 +872,7 @@ body {
.days-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 0.5rem;
flex: 1;
overflow-y: auto;
@@ -883,7 +926,9 @@ body {
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6);
border-radius: 8px;
padding: 1rem;
margin: 0 0 1.5rem 0;
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
position: relative;
z-index: 5;
@@ -930,7 +975,8 @@ body {
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
grid-template-columns: 1fr;
row-gap: 1.5rem;
align-content: start;
}
@@ -940,8 +986,11 @@ body {
}
.day-date {
font-size: 0.875rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--text-secondary);
opacity: 0.75;
/* Visually subordinate to day-name */
}
@@ -954,9 +1003,12 @@ body {
/* Menu Items */
.menu-item {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
/* Spacing now handled by .card-body grid gap */
display: flex;
flex-direction: column;
/* Subtle separator between items */
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.25rem;
}
.menu-item:last-child {
@@ -976,7 +1028,8 @@ body {
.item-name {
font-weight: 600;
color: var(--text-primary);
font-size: 1rem;
font-size: 0.95rem;
/* Slightly smaller to reduce visual competition with day header */
}
.item-price {
@@ -988,7 +1041,8 @@ body {
.item-desc {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.6;
line-height: 1.5;
/* Consistent with body line-height */
margin-bottom: 0.75rem;
white-space: pre-wrap;
}
@@ -1015,7 +1069,8 @@ body {
height: 24px;
font-size: 0.75rem;
padding: 0 10px;
border-radius: 4px;
border-radius: 6px;
/* Unified radius matching buttons and tag-badge-small */
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1109,6 +1164,11 @@ body {
transform: translateY(-1px);
}
.btn-order:active:not(:disabled) {
transform: scale(0.97);
filter: brightness(0.95);
}
.btn-order:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -1152,6 +1212,11 @@ body {
transform: translateY(-1px);
}
.btn-cancel:active:not(:disabled) {
transform: scale(0.97);
filter: brightness(0.95);
}
.btn-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -1231,9 +1296,12 @@ body {
}
/* === Mobile Responsiveness === */
@media (max-width: 600px) {
/* 768px covers tablets (e.g. iPad Mini); 600px was too narrow-only */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
display: grid;
/* Ensure grid is active, prevents flex-only fallback */
grid-template-columns: 1fr;
gap: 1rem;
padding: 0.75rem;
}
@@ -1256,8 +1324,9 @@ body {
}
.days-grid {
display: grid;
/* Explicit grid declaration to prevent flex-context override */
grid-template-columns: 1fr;
/* Force single column */
}
.main-content {
@@ -1287,11 +1356,38 @@ body {
.card-body {
padding: 1rem 0.75rem;
}
.item-header {
gap: 0.5rem;
}
}
/* === Accessibility: Respect prefers-reduced-motion === */
@media (prefers-reduced-motion: reduce) {
/* Disable all decorative pulse/glow animations */
.menu-item.today-ordered,
.menu-item.flagged-sold-out,
.menu-item.flagged-available,
.menu-item.highlight-glow,
.nav-btn.new-week-available,
.update-icon,
#order-countdown.urgent {
animation: none;
}
/* Keep functional animations (modal slide, spinner) */
.toast {
transition: none;
}
}
/* === Focus Visibility (A11y: Keyboard Navigation) === */
:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 4px;
}
/* === Flagging & Notification Styles === */
.btn-flag {
@@ -1323,6 +1419,10 @@ body {
border-color: #eab308;
}
.btn-flag:active {
transform: scale(0.97);
}
.btn-flag .material-icons-round {
font-size: 1.1rem;
}
@@ -1333,7 +1433,9 @@ body {
box-shadow: 0 0 10px rgba(234, 179, 8, 0.2);
border-radius: 8px;
padding: 1rem;
margin: 0 0 1.5rem 0;
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
position: relative;
z-index: 5;
@@ -1360,7 +1462,9 @@ body {
box-shadow: 0 0 15px rgba(16, 185, 129, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 0 0 1.5rem 0;
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
position: relative;
z-index: 5;
@@ -1531,7 +1635,9 @@ body {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
border-radius: 8px;
padding: 1rem;
margin: 0 0 1.5rem 0;
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
position: relative;
z-index: 5;
@@ -1665,7 +1771,8 @@ body {
align-items: center;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 4px;
border-radius: 6px;
/* Unified with .badge and button border-radius */
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);

View File

@@ -8,12 +8,15 @@ console.log("=== Running API Unit Tests ===");
const apiPath = path.join(__dirname, '..', 'src', 'api.js');
const constantsPath = path.join(__dirname, '..', 'src', 'constants.js');
// Load version from version.txt for placeholder replacement
const versionSnippet = fs.readFileSync(path.join(__dirname, '..', 'version.txt'), 'utf8').trim();
let apiCode = fs.readFileSync(apiPath, 'utf8');
let constantsCode = fs.readFileSync(constantsPath, 'utf8');
// Strip exports and imports for VM
apiCode = apiCode.replace(/export /g, '').replace(/import .*? from .*?;/g, '');
constantsCode = constantsCode.replace(/export /g, '');
constantsCode = constantsCode.replace(/export /g, '').replace(/{{VERSION}}/g, versionSnippet);
// 2. Setup Mock Environment
const sandbox = {
@@ -45,12 +48,11 @@ try {
throw new Error(`Expected Authorization header 'Token ${token}', but got '${headersWithToken['Authorization']}'`);
}
// Test without token (should use GUEST_TOKEN)
// Test without token (should NOT have Authorization header)
const headersWithoutToken = sandbox.apiHeaders();
console.log("Without token:", JSON.stringify(headersWithoutToken));
const guestToken = vm.runInContext('GUEST_TOKEN', sandbox);
if (headersWithoutToken['Authorization'] !== `Token ${guestToken}`) {
throw new Error(`Expected Authorization header 'Token ${guestToken}', but got '${headersWithoutToken['Authorization']}'`);
if (headersWithoutToken['Authorization']) {
throw new Error(`Expected NO Authorization header when token is missing, but got '${headersWithoutToken['Authorization']}'`);
}
if (headersWithoutToken['Accept'] !== 'application/json') {

View File

@@ -63,9 +63,14 @@ const html = `
<button id="btn-next-week">Next Week</button>
<!-- Mocks for Language Toggle -->
<button class="lang-btn" data-lang="de">DE</button>
<button class="lang-btn" data-lang="en">EN</button>
<button class="lang-btn" data-lang="all">ALL</button>
<div id="lang-toggle">
<button id="btn-lang-toggle"><span class="material-icons-round">translate</span></button>
<div id="lang-dropdown" class="hidden">
<button class="lang-btn" data-lang="de">🇦🇹 DE</button>
<button class="lang-btn" data-lang="en">🇬🇧 EN</button>
<button class="lang-btn" data-lang="all">🌐 ALL</button>
</div>
</div>
<button id="btn-refresh">Refresh</button>
<button id="btn-logout">Logout</button>

306
tests/test_security.js Normal file
View File

@@ -0,0 +1,306 @@
const fs = require('fs');
const vm = require('vm');
const path = require('path');
console.log("=== Running Security Enhancement Verification Tests ===");
// Helper to check for XSS patterns in strings
function containsXSS(str) {
const malicious = [
'<img',
'onerror',
'alert(1)',
'<script',
'javascript:'
];
return malicious.some(m => String(str).toLowerCase().includes(m));
}
// Mock DOM
const createMockElement = (id = 'mock') => {
const el = {
id,
classList: {
add: () => { },
remove: () => { },
contains: () => false,
toggle: () => { }
},
_innerHTML: '',
get innerHTML() { return this._innerHTML; },
set innerHTML(val) {
this._innerHTML = val;
if (containsXSS(val)) {
console.error(`❌ SECURITY VULNERABILITY: XSS payload detected in innerHTML of element "${id}"!`);
console.error(`Payload: ${val}`);
process.exit(1);
}
},
_textContent: '',
get textContent() { return this._textContent; },
set textContent(val) {
this._textContent = val;
// textContent is safe, so we don't crash here even if it contains payload
if (containsXSS(val)) {
console.log(`✅ Safe textContent usage detected in element "${id}" (Payload neutralized)`);
}
},
value: '',
style: { cssText: '', display: '' },
_listeners: {},
addEventListener: function(type, cb) {
this._listeners[type] = cb;
// Also assign to on[type] for easier testing
this['on' + type] = cb;
},
removeEventListener: function(type) { delete this._listeners[type]; },
appendChild: function(child) {
if (this.id === 'tags-list' || this.id === 'toast-container') {
// Check children for XSS
if (child._innerHTML && containsXSS(child._innerHTML)) {
console.error(`❌ SECURITY VULNERABILITY: Malicious child appended to "${this.id}"!`);
process.exit(1);
}
}
},
removeChild: () => { },
querySelector: (sel) => createMockElement(sel),
querySelectorAll: () => [createMockElement()],
getAttribute: () => '',
setAttribute: () => { },
remove: () => { },
dataset: {},
forEach: (cb) => [].forEach(cb) // for querySelectorAll
};
return el;
};
const sandbox = {
console: console,
document: {
_elements: {},
body: createMockElement('body'),
documentElement: createMockElement('html'),
createElement: (tag) => createMockElement(tag),
getElementById: function(id) {
if (!this._elements[id]) this._elements[id] = createMockElement(id);
return this._elements[id];
},
querySelector: (sel) => createMockElement(sel),
querySelectorAll: (sel) => [createMockElement(sel)],
},
localStorage: new Proxy({
_data: {},
getItem: function(key) { return this._data[key] || null; },
setItem: function(key, val) { this._data[key] = String(val); },
removeItem: function(key) { delete this._data[key]; },
clear: function() { this._data = {}; }
}, {
get(target, prop) {
if (prop in target) return target[prop];
return target._data[prop] || null;
},
set(target, prop, value) {
if (prop === '_data') { target._data = value; return true; }
target._data[prop] = String(value);
return true;
},
deleteProperty(target, prop) {
delete target._data[prop];
return true;
},
ownKeys(target) {
return Object.keys(target._data);
},
getOwnPropertyDescriptor(target, prop) {
if (prop in target._data) {
return { enumerable: true, configurable: true, value: target._data[prop], writable: true };
}
}
}),
fetch: () => Promise.reject(new Error('Network error')),
setTimeout: (cb) => cb(),
setInterval: () => { },
requestAnimationFrame: (cb) => cb(),
Date: Date,
Notification: { permission: 'denied', requestPermission: () => { } },
window: {
location: { href: '' },
open: () => {},
crypto: { randomUUID: () => '1234' },
matchMedia: () => ({ matches: false }),
addEventListener: function(type, cb) { this['on' + type] = cb; },
confirm: () => true
},
crypto: { randomUUID: () => '1234' }
};
// Load source files
const files = [
'../src/utils.js',
'../src/constants.js',
'../src/api.js',
'../src/ui_helpers.js',
'../src/actions.js',
'../src/events.js'
];
const versionSnippet = fs.readFileSync(path.join(__dirname, '..', 'version.txt'), 'utf8').trim();
vm.createContext(sandbox);
// Helper to load and wrap ESM-like files into CJS for VM
function loadFile(relPath) {
let code = fs.readFileSync(path.join(__dirname, relPath), 'utf8');
// Simple regex replacements for imports/exports
code = code.replace(/export /g, '');
code = code.replace(/import .*? from .*?;/g, (match) => {
// We handle dependencies manually in this narrow test context
return `// ${match}`;
});
// Replace version placeholder
code = code.replace(/{{VERSION}}/g, versionSnippet);
return code;
}
// Initial state mock
vm.runInContext(`
var authToken = null;
var currentUser = null;
var orderMap = new Map();
var userFlags = new Set();
var pollIntervalId = null;
var highlightTags = [];
var allWeeks = [];
var currentWeekNumber = 1;
var currentYear = 2024;
var displayMode = 'this-week';
var langMode = 'de';
// State setters
function setAuthToken(v) { authToken = v; }
function setCurrentUser(v) { currentUser = v; }
function setHighlightTags(v) { highlightTags = v; }
function setAllWeeks(v) { allWeeks = v; }
function setCurrentWeekNumber(v) { currentWeekNumber = v; }
function setCurrentYear(v) { currentYear = v; }
function setOrderMap(v) { orderMap = v; }
function setUserFlags(v) { userFlags = v; }
function setPollIntervalId(v) { pollIntervalId = v; }
`, sandbox);
files.forEach(f => vm.runInContext(loadFile(f), sandbox));
// i18n mock
vm.runInContext(`
function t(key) { return key; }
// Initialize events
bindEvents();
`, sandbox);
async function runTests() {
console.log("--- Test 1: GUEST_TOKEN Removal ---");
const headers = sandbox.apiHeaders(null);
if (headers['Authorization']) {
console.error("❌ FAIL: Authorization header present for null token!");
process.exit(1);
} else {
console.log("✅ PASS: No Authorization header for unauthenticated calls.");
}
console.log("--- Test 2: XSS in renderTagsList ---");
sandbox.highlightTags = ['<img src=x onerror=alert(1)>'];
// This should NOT crash the test because it uses textContent now
sandbox.renderTagsList();
console.log("✅ PASS: renderTagsList handled malicious tag safely.");
console.log("--- Test 3: showErrorModal Security ---");
// New signature: title, message, details, btnText, url
sandbox.showErrorModal(
'<img src=x onerror=alert(1)>',
'<img src=x onerror=alert(1)>',
'<img src=x onerror=alert(1)>',
'Safe Btn',
'javascript:alert(1)'
);
console.log("✅ PASS: showErrorModal handled malicious payloads safely.");
console.log("--- Test 4: addHighlightTag Validation ---");
const invalidInputs = [
'<script>alert(1)</script>',
'a', // too short (min 2)
'verylongtagnameover20characterslong', // too long (max 20)
'invalid;char' // invalid chars
];
invalidInputs.forEach(input => {
const result = sandbox.addHighlightTag(input);
if (result === true) {
console.error(`❌ FAIL: Invalid input "${input}" was accepted!`);
process.exit(1);
}
});
const validInputs = ['short', 'Bio', 'Vegg-I', 'Menü 1'];
validInputs.forEach(input => {
const result = sandbox.addHighlightTag(input);
if (result === false && !sandbox.highlightTags.includes(input)) {
console.error(`❌ FAIL: Valid input "${input}" was rejected!`);
process.exit(1);
}
});
console.log("✅ PASS: addHighlightTag correctly rejected malicious/invalid inputs.");
console.log("--- Test 5: Auth Guards in Actions ---");
let fetchCalled = false;
sandbox.fetch = () => {
fetchCalled = true;
return Promise.resolve({ ok: true, json: () => Promise.resolve({ results: [] }) });
};
sandbox.authToken = null;
await sandbox.loadMenuDataFromAPI();
if (fetchCalled) {
console.error("❌ FAIL: loadMenuDataFromAPI attempted fetch without token!");
process.exit(1);
}
fetchCalled = false;
await sandbox.refreshFlaggedItems();
if (fetchCalled) {
console.error("❌ FAIL: refreshFlaggedItems attempted fetch without token!");
process.exit(1);
}
console.log("✅ PASS: Auth guards prevented unauthenticated API calls.");
console.log("--- Test 6: Secure Logout (FR-006) ---");
sandbox.localStorage.setItem('kantine_token', 'secret');
sandbox.localStorage.setItem('kantine_history', 'orders');
sandbox.localStorage.setItem('other_app_data', 'keep_me');
// Trigger logout
const btnLogout = sandbox.document.getElementById('btn-logout');
if (btnLogout.onclick) {
btnLogout.onclick();
} else {
console.error("❌ FAIL: Logout button has no click listener!");
process.exit(1);
}
if (sandbox.localStorage.getItem('kantine_token') || sandbox.localStorage.getItem('kantine_history')) {
console.error("❌ FAIL: Logout did not clear all kantine_ keys!");
process.exit(1);
}
if (sandbox.localStorage.getItem('other_app_data') !== 'keep_me') {
console.error("❌ FAIL: Logout cleared non-kantine keys!");
process.exit(1);
}
console.log("✅ PASS: Secure logout cleared all app-related data while preserving other data.");
console.log("\n✨ ALL SECURITY TESTS PASSED! ✨");
}
runTests().catch(err => {
console.error("Test execution failed:", err);
process.exit(1);
});

View File

@@ -1 +1 @@
v1.6.18
v1.7.2