feat: Implement secure logout functionality, add theme toggle, week navigation, and update version to 1.7.2.
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
2
dist/bookmarklet-payload.js
vendored
2
dist/bookmarklet-payload.js
vendored
File diff suppressed because one or more lines are too long
2
dist/bookmarklet.txt
vendored
2
dist/bookmarklet.txt
vendored
File diff suppressed because one or more lines are too long
18
dist/install.html
vendored
18
dist/install.html
vendored
File diff suppressed because one or more lines are too long
23
dist/kantine-standalone.html
vendored
23
dist/kantine-standalone.html
vendored
File diff suppressed because one or more lines are too long
15
dist/kantine.bundle.js
vendored
15
dist/kantine.bundle.js
vendored
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (!this._elements[id]) this._elements[id] = createMockElement(id);
|
||||||
|
return this._elements[id];
|
||||||
|
},
|
||||||
querySelector: (sel) => createMockElement(sel),
|
querySelector: (sel) => createMockElement(sel),
|
||||||
|
querySelectorAll: (sel) => [createMockElement(sel)],
|
||||||
},
|
},
|
||||||
localStorage: {
|
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! ✨");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v1.7.1
|
v1.7.2
|
||||||
|
|||||||
Reference in New Issue
Block a user