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-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 |

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)
- 🛡️ **Security**: Kritischer Security-Fix und Härtung:
- **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

18
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';
/** 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. */
const VENUE_ID = 591;
@@ -3278,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),
headers: (0,api/* apiHeaders */.H)(),
body: JSON.stringify({ email, password })
});
@@ -3324,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());

View File

@@ -8,7 +8,7 @@
export const API_BASE = 'https://api.bessa.app/v1';
/** 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. */
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 { updateAuthUI, loadMenuDataFromAPI, fetchOrders, startPolling, stopPolling, fetchFullOrderHistory, addHighlightTag, renderTagsList, refreshFlaggedItems } from './actions.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 { t } from './i18n.js';
import { debounce } from './utils.js';
@@ -308,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 })
});
@@ -354,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());

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';

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 = {

View File

@@ -47,8 +47,13 @@ const createMockElement = (id = 'mock') => {
},
value: '',
style: { cssText: '', display: '' },
addEventListener: () => { },
removeEventListener: () => { },
_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
@@ -73,17 +78,46 @@ const createMockElement = (id = 'mock') => {
const sandbox = {
console: console,
document: {
_elements: {},
body: createMockElement('body'),
documentElement: createMockElement('html'),
createElement: (tag) => createMockElement(tag),
getElementById: (id) => createMockElement(id),
querySelector: (sel) => createMockElement(sel),
getElementById: function(id) {
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: {},
getItem: function(key) { return this._data[key] || null; },
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')),
setTimeout: (cb) => cb(),
setInterval: () => { },
@@ -93,7 +127,10 @@ const sandbox = {
window: {
location: { href: '' },
open: () => {},
crypto: { randomUUID: () => '1234' }
crypto: { randomUUID: () => '1234' },
matchMedia: () => ({ matches: false }),
addEventListener: function(type, cb) { this['on' + type] = cb; },
confirm: () => true
},
crypto: { randomUUID: () => '1234' }
};
@@ -104,9 +141,12 @@ const files = [
'../src/constants.js',
'../src/api.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);
// 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
return `// ${match}`;
});
// Replace version placeholder
code = code.replace(/{{VERSION}}/g, versionSnippet);
return code;
}
@@ -127,6 +169,7 @@ vm.runInContext(`
var currentUser = null;
var orderMap = new Map();
var userFlags = new Set();
var pollIntervalId = null;
var highlightTags = [];
var allWeeks = [];
var currentWeekNumber = 1;
@@ -141,6 +184,9 @@ vm.runInContext(`
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));
@@ -148,6 +194,8 @@ files.forEach(f => vm.runInContext(loadFile(f), sandbox));
// i18n mock
vm.runInContext(`
function t(key) { return key; }
// Initialize events
bindEvents();
`, sandbox);
async function runTests() {
@@ -225,6 +273,30 @@ async function runTests() {
}
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! ✨");
}

View File

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