feat: Implement secure logout functionality, add theme toggle, week navigation, and update version to 1.7.2.

This commit is contained in:
Kantine Wrapper
2026-03-12 15:35:09 +01:00
parent b93f1000be
commit 16fe0884aa
13 changed files with 138 additions and 43 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-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-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-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** | | | | | **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-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 | | FR-011 | Das System muss dem Benutzer die Navigation zwischen der aktuellen und der kommenden Woche ermöglichen. | Mittel | v1.0.1 |

View File

@@ -1,3 +1,8 @@
## 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) ## v1.7.1 (2026-03-12)
- 🛡️ **Security**: Kritischer Security-Fix und Härtung: - 🛡️ **Security**: Kritischer Security-Fix und Härtung:
- **XSS-Schutz**: `innerHTML` durch `textContent` in `renderTagsList` (Actions) und `showErrorModal` (UI-Helpers) ersetzt. - **XSS-Schutz**: `innerHTML` durch `textContent` in `renderTagsList` (Actions) und `showErrorModal` (UI-Helpers) ersetzt.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
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

View File

@@ -1164,7 +1164,7 @@ function githubHeaders() {
const API_BASE = 'https://api.bessa.app/v1'; const API_BASE = 'https://api.bessa.app/v1';
/** The client version injected into every API request header. */ /** The client version injected into every API request header. */
const CLIENT_VERSION = 'v1.7.1'; const CLIENT_VERSION = '{{VERSION}}';
/** Bessa venue ID for Knapp-Kantine. */ /** Bessa venue ID for Knapp-Kantine. */
const VENUE_ID = 591; const VENUE_ID = 591;
@@ -3278,7 +3278,7 @@ function bindEvents() {
const email = `knapp-${employeeId}@bessa.app`; const email = `knapp-${employeeId}@bessa.app`;
const response = await fetch(`${constants/* API_BASE */.tE}/auth/login/`, { const response = await fetch(`${constants/* API_BASE */.tE}/auth/login/`, {
method: 'POST', method: 'POST',
headers: (0,api/* apiHeaders */.H)(constants.GUEST_TOKEN), headers: (0,api/* apiHeaders */.H)(),
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}); });
@@ -3324,10 +3324,13 @@ function bindEvents() {
}); });
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', () => {
localStorage.removeItem(constants.LS.AUTH_TOKEN); // Secure Logout (FR-006): Clear all application-related data from localStorage
localStorage.removeItem(constants.LS.CURRENT_USER); Object.keys(localStorage).forEach(key => {
localStorage.removeItem(constants.LS.FIRST_NAME); if (key.startsWith('kantine_')) {
localStorage.removeItem(constants.LS.LAST_NAME); localStorage.removeItem(key);
}
});
(0,state/* setAuthToken */.O5)(null); (0,state/* setAuthToken */.O5)(null);
(0,state/* setCurrentUser */.lt)(null); (0,state/* setCurrentUser */.lt)(null);
(0,state/* setOrderMap */.di)(new Map()); (0,state/* setOrderMap */.di)(new Map());

View File

@@ -8,7 +8,7 @@
export const API_BASE = 'https://api.bessa.app/v1'; export const API_BASE = 'https://api.bessa.app/v1';
/** The client version injected into every API request header. */ /** The client version injected into every API request header. */
export const CLIENT_VERSION = 'v1.7.1'; export const CLIENT_VERSION = '{{VERSION}}';
/** Bessa venue ID for Knapp-Kantine. */ /** Bessa venue ID for Knapp-Kantine. */
export const VENUE_ID = 591; export const VENUE_ID = 591;

View File

@@ -1,7 +1,7 @@
import { displayMode, langMode, authToken, currentUser, orderMap, userFlags, pollIntervalId, setLangMode, setDisplayMode, setAuthToken, setCurrentUser, setOrderMap } from './state.js'; 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 { updateAuthUI, loadMenuDataFromAPI, fetchOrders, startPolling, stopPolling, fetchFullOrderHistory, addHighlightTag, renderTagsList, refreshFlaggedItems } from './actions.js';
import { renderVisibleWeeks, openVersionMenu, updateNextWeekBadge, updateAlarmBell, syncMenuItemHeights } from './ui_helpers.js'; import { renderVisibleWeeks, openVersionMenu, updateNextWeekBadge, updateAlarmBell, syncMenuItemHeights } from './ui_helpers.js';
import { API_BASE, GUEST_TOKEN, LS } from './constants.js'; import { API_BASE, LS } from './constants.js';
import { apiHeaders } from './api.js'; import { apiHeaders } from './api.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { debounce } from './utils.js'; import { debounce } from './utils.js';
@@ -308,7 +308,7 @@ export function bindEvents() {
const email = `knapp-${employeeId}@bessa.app`; const email = `knapp-${employeeId}@bessa.app`;
const response = await fetch(`${API_BASE}/auth/login/`, { const response = await fetch(`${API_BASE}/auth/login/`, {
method: 'POST', method: 'POST',
headers: apiHeaders(GUEST_TOKEN), headers: apiHeaders(),
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}); });
@@ -354,10 +354,13 @@ export function bindEvents() {
}); });
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', () => {
localStorage.removeItem(LS.AUTH_TOKEN); // Secure Logout (FR-006): Clear all application-related data from localStorage
localStorage.removeItem(LS.CURRENT_USER); Object.keys(localStorage).forEach(key => {
localStorage.removeItem(LS.FIRST_NAME); if (key.startsWith('kantine_')) {
localStorage.removeItem(LS.LAST_NAME); localStorage.removeItem(key);
}
});
setAuthToken(null); setAuthToken(null);
setCurrentUser(null); setCurrentUser(null);
setOrderMap(new Map()); setOrderMap(new Map());

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 { 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 { 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 { apiHeaders, githubHeaders } from './api.js';
import { placeOrder, cancelOrder, toggleFlag, showToast, checkHighlight, loadMenuDataFromAPI } from './actions.js'; import { placeOrder, cancelOrder, toggleFlag, showToast, checkHighlight, loadMenuDataFromAPI } from './actions.js';
import { t } from './i18n.js'; import { t } from './i18n.js';

View File

@@ -8,12 +8,15 @@ console.log("=== Running API Unit Tests ===");
const apiPath = path.join(__dirname, '..', 'src', 'api.js'); const apiPath = path.join(__dirname, '..', 'src', 'api.js');
const constantsPath = path.join(__dirname, '..', 'src', 'constants.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 apiCode = fs.readFileSync(apiPath, 'utf8');
let constantsCode = fs.readFileSync(constantsPath, 'utf8'); let constantsCode = fs.readFileSync(constantsPath, 'utf8');
// Strip exports and imports for VM // Strip exports and imports for VM
apiCode = apiCode.replace(/export /g, '').replace(/import .*? from .*?;/g, ''); 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 // 2. Setup Mock Environment
const sandbox = { const sandbox = {

View File

@@ -47,8 +47,13 @@ const createMockElement = (id = 'mock') => {
}, },
value: '', value: '',
style: { cssText: '', display: '' }, style: { cssText: '', display: '' },
addEventListener: () => { }, _listeners: {},
removeEventListener: () => { }, 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) { appendChild: function(child) {
if (this.id === 'tags-list' || this.id === 'toast-container') { if (this.id === 'tags-list' || this.id === 'toast-container') {
// Check children for XSS // Check children for XSS
@@ -73,17 +78,46 @@ const createMockElement = (id = 'mock') => {
const sandbox = { const sandbox = {
console: console, console: console,
document: { document: {
_elements: {},
body: createMockElement('body'), body: createMockElement('body'),
documentElement: createMockElement('html'),
createElement: (tag) => createMockElement(tag), createElement: (tag) => createMockElement(tag),
getElementById: (id) => createMockElement(id), getElementById: function(id) {
querySelector: (sel) => createMockElement(sel), if (!this._elements[id]) this._elements[id] = createMockElement(id);
return this._elements[id];
}, },
localStorage: { querySelector: (sel) => createMockElement(sel),
querySelectorAll: (sel) => [createMockElement(sel)],
},
localStorage: new Proxy({
_data: {}, _data: {},
getItem: function(key) { return this._data[key] || null; }, getItem: function(key) { return this._data[key] || null; },
setItem: function(key, val) { this._data[key] = String(val); }, setItem: function(key, val) { this._data[key] = String(val); },
removeItem: function(key) { delete this._data[key]; } 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')), fetch: () => Promise.reject(new Error('Network error')),
setTimeout: (cb) => cb(), setTimeout: (cb) => cb(),
setInterval: () => { }, setInterval: () => { },
@@ -93,7 +127,10 @@ const sandbox = {
window: { window: {
location: { href: '' }, location: { href: '' },
open: () => {}, open: () => {},
crypto: { randomUUID: () => '1234' } crypto: { randomUUID: () => '1234' },
matchMedia: () => ({ matches: false }),
addEventListener: function(type, cb) { this['on' + type] = cb; },
confirm: () => true
}, },
crypto: { randomUUID: () => '1234' } crypto: { randomUUID: () => '1234' }
}; };
@@ -104,9 +141,12 @@ const files = [
'../src/constants.js', '../src/constants.js',
'../src/api.js', '../src/api.js',
'../src/ui_helpers.js', '../src/ui_helpers.js',
'../src/actions.js' '../src/actions.js',
'../src/events.js'
]; ];
const versionSnippet = fs.readFileSync(path.join(__dirname, '..', 'version.txt'), 'utf8').trim();
vm.createContext(sandbox); vm.createContext(sandbox);
// Helper to load and wrap ESM-like files into CJS for VM // Helper to load and wrap ESM-like files into CJS for VM
@@ -118,6 +158,8 @@ function loadFile(relPath) {
// We handle dependencies manually in this narrow test context // We handle dependencies manually in this narrow test context
return `// ${match}`; return `// ${match}`;
}); });
// Replace version placeholder
code = code.replace(/{{VERSION}}/g, versionSnippet);
return code; return code;
} }
@@ -127,6 +169,7 @@ vm.runInContext(`
var currentUser = null; var currentUser = null;
var orderMap = new Map(); var orderMap = new Map();
var userFlags = new Set(); var userFlags = new Set();
var pollIntervalId = null;
var highlightTags = []; var highlightTags = [];
var allWeeks = []; var allWeeks = [];
var currentWeekNumber = 1; var currentWeekNumber = 1;
@@ -141,6 +184,9 @@ vm.runInContext(`
function setAllWeeks(v) { allWeeks = v; } function setAllWeeks(v) { allWeeks = v; }
function setCurrentWeekNumber(v) { currentWeekNumber = v; } function setCurrentWeekNumber(v) { currentWeekNumber = v; }
function setCurrentYear(v) { currentYear = v; } function setCurrentYear(v) { currentYear = v; }
function setOrderMap(v) { orderMap = v; }
function setUserFlags(v) { userFlags = v; }
function setPollIntervalId(v) { pollIntervalId = v; }
`, sandbox); `, sandbox);
files.forEach(f => vm.runInContext(loadFile(f), sandbox)); files.forEach(f => vm.runInContext(loadFile(f), sandbox));
@@ -148,6 +194,8 @@ files.forEach(f => vm.runInContext(loadFile(f), sandbox));
// i18n mock // i18n mock
vm.runInContext(` vm.runInContext(`
function t(key) { return key; } function t(key) { return key; }
// Initialize events
bindEvents();
`, sandbox); `, sandbox);
async function runTests() { async function runTests() {
@@ -225,6 +273,30 @@ async function runTests() {
} }
console.log("✅ PASS: Auth guards prevented unauthenticated API calls."); 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! ✨"); console.log("\n✨ ALL SECURITY TESTS PASSED! ✨");
} }

View File

@@ -1 +1 @@
v1.7.1 v1.7.2