diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f065a7..2be0e1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.47.2] - 2026-05-05
+
+### Changed
+- **Dependencies**: updated express-rate-limit from 8.4.1 to 8.5.0 (async store initialization support) and tsdav from 2.1.8 to 2.2.0 (native fetch, enhanced OAuth token handling, improved CalDAV/CardDAV sync reliability, security improvements).
+
## [0.47.1] - 2026-05-04
### Fixed
diff --git a/package-lock.json b/package-lock.json
index df794b6..148c6d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,8 +17,7 @@
"express-session": "^1.19.0",
"helmet": "^8.1.0",
"node-cron": "^4.2.1",
- "node-fetch": "^3.3.2",
- "tsdav": "2.2.0"
+ "node-fetch": "^3.3.2"
},
"devDependencies": {
"sharp": "^0.34.5"
diff --git a/package.json b/package.json
index bdaae7f..4401e53 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oikos",
- "version": "0.47.1",
+ "version": "0.47.2",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js",
"type": "module",
diff --git a/public/pages/settings.js.backup b/public/pages/settings.js.backup
deleted file mode 100644
index f85921e..0000000
--- a/public/pages/settings.js.backup
+++ /dev/null
@@ -1,2383 +0,0 @@
-/**
- * Modul: Einstellungen (Settings)
- * Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder
- * Abhängigkeiten: /api.js
- */
-
-import { api, auth } from '/api.js';
-import { openModal, closeModal, confirmModal } from '/components/modal.js';
-import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, getDateFormat } from '/i18n.js';
-import { esc } from '/utils/html.js';
-import '/components/oikos-locale-picker.js';
-
-const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
-const SETTINGS_TAB_KEY = 'oikos:settings:tab';
-const APP_NAME_STORAGE_KEY = 'oikos-app-name';
-const DEFAULT_APP_NAME = 'Oikos';
-const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'];
-const MAX_AVATAR_DATA_LENGTH = 768 * 1024;
-
-const CATEGORY_I18N = {
- 'Obst & Gemüse': 'shopping.catFruitVeg',
- 'Backwaren': 'shopping.catBakery',
- 'Milchprodukte': 'shopping.catDairy',
- 'Fleisch & Fisch': 'shopping.catMeatFish',
- 'Tiefkühl': 'shopping.catFrozen',
- 'Getränke': 'shopping.catDrinks',
- 'Haushalt': 'shopping.catHousehold',
- 'Drogerie': 'shopping.catDrugstore',
- 'Sonstiges': 'shopping.catMisc',
-};
-function catLabel(name) {
- const key = CATEGORY_I18N[name];
- return key ? t(key) : name;
-}
-
-function buildCurrencyOptions(selected) {
- const display = typeof Intl.DisplayNames !== 'undefined'
- ? new Intl.DisplayNames([document.documentElement.lang || 'en'], { type: 'currency' })
- : null;
- return SUPPORTED_CURRENCIES
- .map((code) => {
- const label = display ? `${code} - ${display.of(code)}` : code;
- const sel = code === selected ? ' selected' : '';
- return ``;
- })
- .join('');
-}
-
-function familyRoleLabel(role) {
- return t(`settings.familyRole${String(role || 'other').replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase())}`);
-}
-
-function buildFamilyRoleOptions(selected = 'other') {
- return FAMILY_ROLES.map((role) => `
-
- `).join('');
-}
-
-function maskDateInputValue(value) {
- const digits = String(value || '').replace(/\D/g, '').slice(0, 8);
- if (!digits) return '';
-
- if (getDateFormat() === 'ymd') {
- return [
- digits.slice(0, 4),
- digits.slice(4, 6),
- digits.slice(6, 8),
- ].filter(Boolean).join('-');
- }
-
- return [
- digits.slice(0, 2),
- digits.slice(2, 4),
- digits.slice(4, 8),
- ].filter(Boolean).join('/');
-}
-
-function bindSettingsDateInputs(root) {
- root.querySelectorAll('.js-date-input').forEach((input) => {
- input.addEventListener('input', () => {
- input.value = maskDateInputValue(input.value);
- });
- input.addEventListener('blur', () => {
- const parsed = parseDateInput(input.value);
- if (parsed) input.value = formatDateInput(parsed);
- });
- });
-}
-
-function avatarHtml(user, className = 'settings-avatar') {
- const safeName = esc(user?.display_name || '');
- const fallback = esc(initials(user?.display_name || ''));
- const bg = esc(user?.avatar_color || '#007AFF');
- return `
-
- ${user?.avatar_data ? `
})
` : fallback}
-
- `;
-}
-
-function avatarEditorHtml(user, prefix) {
- return `
-
-
-
-
-
-
-
-
- `;
-}
-
-function setAvatarPreview(container, selector, user) {
- const preview = container.querySelector(selector);
- if (!preview) return;
- preview.replaceChildren();
- preview.insertAdjacentHTML('beforeend', avatarHtml(user, 'settings-avatar settings-avatar--lg'));
-}
-
-function bindAvatarPicker(container, prefix) {
- const fileInput = container.querySelector(`#${prefix}-avatar-file`);
- const pickers = [
- container.querySelector(`#${prefix}-avatar-preview`),
- container.querySelector(`#${prefix}-avatar-edit`),
- ];
- pickers.forEach((picker) => {
- picker?.addEventListener('click', () => fileInput?.click());
- });
-}
-
-function readImageAsDataUrl(file) {
- return new Promise((resolve, reject) => {
- if (!file) return resolve(undefined);
- if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
- return reject(new Error(t('settings.profilePictureTypeError')));
- }
- if (file.size > 5 * 1024 * 1024) {
- return reject(new Error(t('settings.profilePictureFileTooLarge')));
- }
-
- const reader = new FileReader();
- reader.onload = () => {
- const img = new Image();
- img.onload = () => {
- try {
- const maxSize = 512;
- const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
- const width = Math.max(1, Math.round(img.width * scale));
- const height = Math.max(1, Math.round(img.height * scale));
- const canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext('2d');
- ctx.drawImage(img, 0, 0, width, height);
- const dataUrl = canvas.toDataURL('image/jpeg', 0.86);
- if (dataUrl.length > MAX_AVATAR_DATA_LENGTH) {
- reject(new Error(t('settings.profilePictureTooLarge')));
- } else {
- resolve(dataUrl);
- }
- } catch (err) {
- reject(err);
- }
- };
- img.onerror = () => reject(new Error(t('settings.profilePictureReadError')));
- img.src = reader.result;
- };
- reader.onerror = () => reject(new Error(t('settings.profilePictureReadError')));
- reader.readAsDataURL(file);
- });
-}
-
-/**
- * @param {HTMLElement} container
- * @param {{ user: object }} context
- */
-export async function render(container, { user }) {
- try {
- const me = await auth.me();
- if (me?.user && user) Object.assign(user, me.user);
- else if (me?.user) user = me.user;
- } catch {
- // Non-critical: render with the user object provided by the router.
- }
-
- // URL-Parameter auswerten (z.B. nach OAuth-Callback)
- const params = new URLSearchParams(location.search);
- const syncOk = params.get('sync_ok');
- const syncErr = params.get('sync_error');
-
- // State für Familienmitglieder + Sync-Status
- let users = [];
- let googleStatus = { configured: false, connected: false, lastSync: null };
- let appleStatus = { configured: false, lastSync: null };
- let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME, disabled_modules: [] };
- let categories = [];
- let icsSubscriptions = [];
- let apiTokens = [];
-
- try {
- const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes, apiTokensRes] = await Promise.allSettled([
- user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
- api.get('/calendar/google/status'),
- api.get('/calendar/apple/status'),
- api.get('/preferences'),
- api.get('/shopping/categories'),
- api.get('/calendar/subscriptions'),
- user.role === 'admin' ? api.get('/auth/api-tokens') : Promise.resolve({ data: [] }),
- ]);
- if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
- if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
- if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
- if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
- if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
- if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? [];
- if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
- } catch (_) { /* non-critical */ }
-
- if (prefs.date_format) {
- try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {}
- }
- if (prefs.time_format) {
- try { localStorage.setItem('oikos-time-format', prefs.time_format); } catch (_) {}
- }
- if (prefs.app_name) {
- try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {}
- }
-
- const googleStatusText = googleStatus.connected
- ? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected'))
- : googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
-
- const appleStatusText = appleStatus.connected
- ? (appleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.connected'))
- : appleStatus.configured
- ? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.configured'))
- : t('settings.notConnected');
-
- const allowedTabs = [
- 'general', 'meals', 'budget', 'shopping', 'calendar',
- ...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
- 'account',
- ...(user?.role === 'admin' ? ['backup'] : []),
- ];
- const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
- const activeTab = (syncOk || syncErr)
- ? 'calendar'
- : (allowedTabs.includes(storedTab) ? storedTab : 'general');
-
- const panelHidden = (id) => id === activeTab ? '' : ' hidden';
- const btnClass = (id) => `settings-tab-btn${id === activeTab ? ' settings-tab-btn--active' : ''}`;
- const btnAria = (id) => id === activeTab ? 'true' : 'false';
-
- container.innerHTML = `
-
-
-
- ${syncOk ? `
${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}
` : ''}
- ${syncErr ? `
${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}
` : ''}
-
-
-
-
-
-
- ${t('settings.sectionDesign')}
-
-
${t('settings.cardAppearance')}
-
-
-
-
-
-
-
-
- ${user?.role === 'admin' ? `
-
- ${t('settings.sectionAppName')}
-
-
${t('settings.appNameTitle')}
-
${t('settings.appNameHint')}
-
-
-
- ` : ''}
-
-
- ${t('settings.sectionDate')}
-
-
${t('settings.dateFormatTitle')}
-
${t('settings.dateFormatHint')}
-
-
-
-
-
-
-
-
- ${t('settings.languageTitle')}
-
-
-
-
-
- ${user?.role === 'admin' ? `
-
- ${t('settings.sectionModules')}
-
-
${t('settings.modulesTitle')}
-
${t('settings.modulesHint')}
-
- ${[
- ['tasks', 'nav.tasks'],
- ['calendar', 'nav.calendar'],
- ['meals', 'nav.meals'],
- ['recipes', 'nav.recipes'],
- ['shopping', 'nav.shopping'],
- ['birthdays', 'nav.birthdays'],
- ['notes', 'nav.notes'],
- ['contacts', 'nav.contacts'],
- ['budget', 'nav.budget'],
- ['documents', 'nav.documents'],
- ].map(([slug, labelKey]) => `
-
- `).join('')}
-
-
-
- ` : ''}
-
-
-
-
-
- ${t('settings.sectionMeals')}
-
-
-
-
-
-
-
- ${t('settings.sectionBudget')}
-
-
${t('settings.currencyLabel')}
-
${t('settings.currencyHint')}
-
-
-
-
-
-
-
-
- ${t('settings.sectionShopping')}
-
-
${t('settings.shoppingCategoriesLabel')}
-
${t('settings.shoppingCategoriesHint')}
-
- ${categories.map((c, i) => categoryRowHtml(c, i === 0, i === categories.length - 1)).join('')}
-
-
-
-
-
-
-
-
-
- ${t('settings.sectionCalendarSync')}
-
-
-
-
- ${googleStatus.configured ? `
-
- ${googleStatus.connected ? `
-
- ${user?.role === 'admin' ? `
` : ''}
- ` : `
- ${user?.role === 'admin' ? `
${t('settings.connectGoogle')}` : `
${t('settings.googleOnlyAdmin')}`}
- `}
-
- ` : ''}
-
-
-
-
-
- ${appleStatus.configured ? `
-
-
- ${appleStatus.connected && user?.role === 'admin' ? `` : ''}
-
- ` : user?.role === 'admin' ? `
-
- ` : `
${t('settings.appleOnlyAdmin')}`}
-
-
-
-
-
${t('settings.caldavTitle')}
-
${t('settings.caldavDescription')}
-
-
-
-
${t('settings.caldavEmptyState')}
-
-
- ${user?.role === 'admin' ? `
-
- ` : ''}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${user?.role === 'admin' ? `
-
-
-
- ${t('settings.sectionFamily')}
-
-
- ${users.map(memberHtml).join('')}
-
-
-
-
-
-
-
- ` : ''}
-
- ${user?.role === 'admin' ? `
-
-
-
- ${t('settings.apiTokensTitle')}
-
-
${t('settings.apiTokensCardTitle')}
-
${t('settings.apiTokensHint')}
-
- ${apiTokens.map(apiTokenHtml).join('')}
-
-
-
-
-
- ` : ''}
-
-
-
-
- ${t('settings.sectionAccount')}
-
-
-
- ${avatarHtml(user)}
-
-
${esc(user?.display_name)}
-
@${esc(user?.username)}
-
-
-
-
-
-
${t('settings.profilePictureTitle')}
-
-
-
-
-
${t('settings.changePassword')}
-
-
-
-
-
-
-
-
-
- ${user?.role === 'admin' ? `
-
-
-
- ${t('settings.sectionBackup')}
-
-
-
-
-
-
-
${t('settings.backupDownloadTitle')}
-
${t('settings.backupDownloadHint')}
-
-
-
-
-
-
-
-
-
-
${t('settings.backupRestoreTitle')}
-
${t('settings.backupRestoreHint')}
-
-
-
-
-
-
${t('settings.backupSchedulerTitle')}
-
${t('settings.backupSchedulerHint')}
-
-
-
-
-
-
-
${t('settings.backupCliTitle')}
-
${t('settings.backupCliHint')}
-
SERVICE=oikos
-BACKUP="$PWD/oikos-backup.db"
-docker compose stop "$SERVICE"
-docker compose run --rm -v "$BACKUP:/tmp/oikos-restore.db:ro" --entrypoint sh "$SERVICE" -c 'set -eu; target="\${DB_PATH:-/data/oikos.db}"; stamp=$(date -u +%Y%m%dT%H%M%SZ); if [ -f "$target" ]; then cp "$target" "$target.pre-restore-$stamp"; fi; rm -f "$target-wal" "$target-shm"; cp /tmp/oikos-restore.db "$target"; chown node:node "$target" 2>/dev/null || true'
-docker compose up -d "$SERVICE"
-
${t('settings.backupCliBackupHint')}
-
docker compose exec oikos node -e "import('./server/db.js').then(async db => { await db.backupToFile('/data/oikos-backup.db'); process.exit(0); })"
-docker cp oikos:/data/oikos-backup.db ./oikos-backup.db
-
-
-
- ` : ''}
-
- `;
-
- // Meal-Type-Checkboxen initialisieren
- const toggles = container.querySelector('#meal-type-toggles');
- if (toggles) {
- toggles.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
- cb.checked = prefs.visible_meal_types.includes(cb.value);
- });
- }
-
- bindEvents(container, user, users, categories, icsSubscriptions, apiTokens);
- if (window.lucide) window.lucide.createIcons();
-}
-
-// --------------------------------------------------------
-// Event-Binding
-// --------------------------------------------------------
-
-function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
- bindTabEvents(container);
- bindSettingsDateInputs(container);
- bindCategoryEvents(container);
- bindIcsEvents(container, user, icsSubscriptions);
- bindApiTokenEvents(container, apiTokens);
- if (typeof bindBackupEvents === 'function') bindBackupEvents(container);
- // Theme-Toggle
- const themeToggle = container.querySelector('#theme-toggle');
- if (themeToggle) {
- themeToggle.addEventListener('click', (e) => {
- const btn = e.target.closest('[data-theme-value]');
- if (!btn) return;
- const value = btn.dataset.themeValue;
- applyTheme(value);
- themeToggle.querySelectorAll('.theme-toggle__btn').forEach(b => b.classList.remove('theme-toggle__btn--active'));
- btn.classList.add('theme-toggle__btn--active');
- });
- }
-
- // Modul-Toggles (admin-only)
- const moduleToggles = container.querySelector('#module-toggles');
- if (moduleToggles) {
- moduleToggles.addEventListener('change', async () => {
- const disabled = [...moduleToggles.querySelectorAll('input:not(:checked)')].map((cb) => cb.value);
- try {
- const res = await api.put('/preferences', { disabled_modules: disabled });
- const saved = res?.data?.disabled_modules ?? disabled;
- window.oikos?.setDisabledModules?.(saved);
- window.oikos?.showToast(t('settings.modulesSaved'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- }
- });
- }
-
- // Meal-Type-Toggles
- const mealToggles = container.querySelector('#meal-type-toggles');
- if (mealToggles) {
- mealToggles.addEventListener('change', async () => {
- const checked = [...mealToggles.querySelectorAll('input:checked')].map((cb) => cb.value);
- if (checked.length === 0) {
- window.oikos?.showToast(t('settings.mealTypesMinOne'), 'error');
- // Revert: re-check all
- mealToggles.querySelectorAll('input').forEach((cb) => { cb.checked = true; });
- return;
- }
- try {
- await api.put('/preferences', { visible_meal_types: checked });
- window.oikos?.showToast(t('settings.mealTypesSaved'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- }
- });
- }
-
- // Währungs-Auswahl
- const currencySelect = container.querySelector('#currency-select');
- if (currencySelect) {
- currencySelect.addEventListener('change', async () => {
- try {
- await api.put('/preferences', { currency: currencySelect.value });
- window.oikos?.showToast(t('settings.currencySaved'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- }
- });
- }
-
- const dateFormatSelect = container.querySelector('#date-format-select');
- if (dateFormatSelect) {
- dateFormatSelect.addEventListener('change', async () => {
- try {
- await api.put('/preferences', { date_format: dateFormatSelect.value });
- try { localStorage.setItem('oikos-date-format', dateFormatSelect.value); } catch (_) {}
- window.dispatchEvent(new CustomEvent('date-format-changed', { detail: { dateFormat: dateFormatSelect.value } }));
- window.oikos?.showToast(t('settings.dateFormatSavedToast'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- }
- });
- }
-
- const timeFormatSelect = container.querySelector('#time-format-select');
- if (timeFormatSelect) {
- timeFormatSelect.addEventListener('change', async () => {
- try {
- await api.put('/preferences', { time_format: timeFormatSelect.value });
- try { localStorage.setItem('oikos-time-format', timeFormatSelect.value); } catch (_) {}
- window.dispatchEvent(new CustomEvent('time-format-changed', { detail: { timeFormat: timeFormatSelect.value } }));
- window.oikos?.showToast(t('settings.timeFormatSavedToast'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- }
- });
- }
-
- const appNameForm = container.querySelector('#app-name-form');
- if (appNameForm) {
- appNameForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const errorEl = container.querySelector('#app-name-error');
- const input = container.querySelector('#app-name-input');
- errorEl.hidden = true;
- const value = input.value.trim();
- try {
- await api.put('/preferences', { app_name: value });
- try {
- if (value) localStorage.setItem(APP_NAME_STORAGE_KEY, value);
- else localStorage.removeItem(APP_NAME_STORAGE_KEY);
- } catch (_) {}
- input.value = value || DEFAULT_APP_NAME;
- window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: value || DEFAULT_APP_NAME } }));
- window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
- } catch (err) {
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- }
- });
-
- container.querySelector('#app-name-reset-btn')?.addEventListener('click', async () => {
- const errorEl = container.querySelector('#app-name-error');
- const input = container.querySelector('#app-name-input');
- errorEl.hidden = true;
- input.value = DEFAULT_APP_NAME;
- try {
- await api.put('/preferences', { app_name: '' });
- try { localStorage.removeItem(APP_NAME_STORAGE_KEY); } catch (_) {}
- window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: DEFAULT_APP_NAME } }));
- window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
- } catch (err) {
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- }
- });
- }
-
- const profileState = { avatarData: user?.avatar_data ?? null };
- const profileAvatarFile = container.querySelector('#profile-avatar-file');
- bindAvatarPicker(container, 'profile');
- if (profileAvatarFile) {
- profileAvatarFile.addEventListener('change', async () => {
- const errorEl = container.querySelector('#profile-error');
- errorEl.hidden = true;
- try {
- const avatarData = await readImageAsDataUrl(profileAvatarFile.files?.[0]);
- if (avatarData !== undefined) {
- profileState.avatarData = avatarData;
- setAvatarPreview(container, '#profile-avatar-preview', {
- display_name: container.querySelector('#profile-display-name')?.value || user?.display_name,
- avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color,
- avatar_data: avatarData,
- });
- }
- } catch (err) {
- profileAvatarFile.value = '';
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- }
- });
- }
-
- container.querySelector('#profile-avatar-remove')?.addEventListener('click', () => {
- profileState.avatarData = null;
- if (profileAvatarFile) profileAvatarFile.value = '';
- setAvatarPreview(container, '#profile-avatar-preview', {
- display_name: container.querySelector('#profile-display-name')?.value || user?.display_name,
- avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color,
- avatar_data: null,
- });
- });
-
- const profileForm = container.querySelector('#profile-form');
- if (profileForm) {
- profileForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const errorEl = container.querySelector('#profile-error');
- const btn = profileForm.querySelector('[type=submit]');
- const birthDateRaw = container.querySelector('#profile-birth-date')?.value || '';
- errorEl.hidden = true;
- if (!isDateInputValid(birthDateRaw)) {
- showError(errorEl, t('settings.memberBirthDateInvalid'));
- return;
- }
- btn.disabled = true;
- try {
- const res = await auth.updateProfile({
- display_name: container.querySelector('#profile-display-name').value.trim(),
- avatar_color: container.querySelector('#profile-avatar-color').value,
- avatar_data: profileState.avatarData,
- phone: container.querySelector('#profile-phone')?.value.trim() || null,
- email: container.querySelector('#profile-email')?.value.trim() || null,
- birth_date: parseDateInput(birthDateRaw) || null,
- });
- Object.assign(user, res.user);
- window.oikos?.showToast(t('settings.profileSavedToast'), 'success');
- render(container, { user });
- } catch (err) {
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- } finally {
- btn.disabled = false;
- }
- });
- }
-
- // Passwort ändern
- const passwordForm = container.querySelector('#password-form');
- if (passwordForm) {
- passwordForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const currentPw = container.querySelector('#current-password').value;
- const newPw = container.querySelector('#new-password').value;
- const confirmPw = container.querySelector('#confirm-password').value;
- const errorEl = container.querySelector('#password-error');
-
- errorEl.hidden = true;
-
- if (newPw !== confirmPw) {
- showError(errorEl, t('settings.passwordMismatch'));
- return;
- }
-
- const btn = passwordForm.querySelector('[type=submit]');
- btn.disabled = true;
- try {
- await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
- passwordForm.reset();
- window.oikos?.showToast(t('settings.passwordSavedToast'), 'success');
- } catch (err) {
- showError(errorEl, err.message);
- } finally {
- btn.disabled = false;
- }
- });
- }
-
- // Google Sync
- const googleSyncBtn = container.querySelector('#google-sync-btn');
- if (googleSyncBtn) {
- googleSyncBtn.addEventListener('click', async () => {
- googleSyncBtn.disabled = true;
- googleSyncBtn.textContent = t('settings.synchronizing');
- try {
- await api.post('/calendar/google/sync', {});
- window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Google Calendar' }), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- } finally {
- googleSyncBtn.disabled = false;
- googleSyncBtn.textContent = t('settings.syncNow');
- }
- });
- }
-
- // Google Disconnect (Admin)
- const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
- if (googleDisconnectBtn) {
- googleDisconnectBtn.addEventListener('click', async () => {
- if (!await confirmModal(t('settings.googleDisconnectConfirm'), { danger: true })) return;
- try {
- await api.delete('/calendar/google/disconnect');
- window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default');
- window.oikos?.navigate('/settings');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- });
- }
-
- // Apple Sync
- const appleSyncBtn = container.querySelector('#apple-sync-btn');
- if (appleSyncBtn) {
- appleSyncBtn.addEventListener('click', async () => {
- appleSyncBtn.disabled = true;
- appleSyncBtn.textContent = t('settings.synchronizing');
- try {
- await api.post('/calendar/apple/sync', {});
- window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Apple Calendar' }), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- } finally {
- appleSyncBtn.disabled = false;
- appleSyncBtn.textContent = t('settings.syncNow');
- }
- });
- }
-
- // Apple Disconnect (Admin)
- const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
- if (appleDisconnectBtn) {
- appleDisconnectBtn.addEventListener('click', async () => {
- if (!await confirmModal(t('settings.appleDisconnectConfirm'), { danger: true })) return;
- try {
- await api.delete('/calendar/apple/disconnect');
- window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default');
- window.oikos?.navigate('/settings');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- });
- }
-
- // Apple Connect-Formular (Admin)
- const appleConnectForm = container.querySelector('#apple-connect-form');
- if (appleConnectForm) {
- appleConnectForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const errorEl = container.querySelector('#apple-connect-error');
- errorEl.hidden = true;
-
- const url = container.querySelector('#apple-caldav-url').value.trim();
- const username = container.querySelector('#apple-username').value.trim();
- const password = container.querySelector('#apple-password').value;
- const btn = container.querySelector('#apple-connect-btn');
-
- btn.disabled = true;
- btn.textContent = t('settings.appleConnecting');
- try {
- await api.post('/calendar/apple/connect', { url, username, password });
- window.oikos?.showToast(t('settings.appleConnectedToast'), 'success');
- window.oikos?.navigate('/settings');
- } catch (err) {
- showError(errorEl, err.message);
- } finally {
- btn.disabled = false;
- btn.textContent = t('settings.appleConnectBtn');
- }
- });
- }
-
- // CalDAV-Konten laden
- async function loadCalDAVAccounts(container) {
- const listEl = container.querySelector('#caldav-accounts-list');
- const emptyEl = container.querySelector('#caldav-empty-state');
- if (!listEl || !emptyEl) return;
-
- try {
- const accountsRes = await api.get('/calendar/caldav/accounts');
- const accounts = accountsRes.data || [];
-
- if (accounts.length === 0) {
- listEl.replaceChildren();
- emptyEl.style.display = '';
- return;
- }
-
- emptyEl.style.display = 'none';
- listEl.replaceChildren();
-
- for (const account of accounts) {
- const calendarsRes = await api.get(`/calendar/caldav/accounts/${account.id}/calendars`);
- const calendars = calendarsRes.data || [];
-
- const accountCard = document.createElement('div');
- accountCard.className = 'caldav-account-item';
- accountCard.insertAdjacentHTML('beforeend', `
-
-
-
- ${t('settings.caldavCalendarsToggle')} (${calendars.length})
-
-
- ${calendars.map((cal) => `
-
- `).join('')}
-
-
-
-
-
- ${user?.role === 'admin' ? `` : ''}
-
- `);
- listEl.appendChild(accountCard);
- }
-
- if (window.lucide) lucide.createIcons({ el: listEl });
-
- // Bind calendar checkbox events
- listEl.querySelectorAll('.caldav-calendar-checkbox').forEach((checkbox) => {
- checkbox.addEventListener('change', async () => {
- const accountId = parseInt(checkbox.dataset.accountId, 10);
- const calendarUrl = checkbox.dataset.calendarUrl;
- const enabled = checkbox.checked;
-
- try {
- await api.patch(`/calendar/caldav/accounts/${accountId}/calendars`, {
- calendarUrl,
- enabled,
- });
- window.oikos?.showToast(
- enabled ? t('settings.calendarEnabled') : t('settings.calendarDisabled'),
- 'success'
- );
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- checkbox.checked = !enabled; // Revert on error
- }
- });
- });
-
- // Bind sync buttons
- listEl.querySelectorAll('[data-caldav-sync]').forEach((btn) => {
- btn.addEventListener('click', async () => {
- btn.disabled = true;
- const originalText = btn.textContent;
- btn.textContent = t('settings.synchronizing');
- try {
- await api.post('/calendar/caldav/sync');
- window.oikos?.showToast(t('settings.caldavSyncSuccess'), 'success');
- await loadCalDAVAccounts(container);
- } catch (err) {
- window.oikos?.showToast(err.message || t('settings.caldavSyncFailed'), 'danger');
- } finally {
- btn.disabled = false;
- btn.textContent = originalText;
- }
- });
- });
-
- // Bind refresh buttons
- listEl.querySelectorAll('[data-caldav-refresh]').forEach((btn) => {
- btn.addEventListener('click', async () => {
- const accountId = parseInt(btn.dataset.caldavRefresh, 10);
- btn.disabled = true;
- const originalText = btn.textContent;
- btn.textContent = t('settings.loading');
- try {
- await api.get(`/calendar/caldav/accounts/${accountId}/calendars?refresh=true`);
- await loadCalDAVAccounts(container);
- window.oikos?.showToast(t('settings.calendarsRefreshed'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- } finally {
- btn.disabled = false;
- btn.textContent = originalText;
- }
- });
- });
-
- // Bind delete buttons
- listEl.querySelectorAll('[data-caldav-delete]').forEach((btn) => {
- btn.addEventListener('click', async () => {
- const accountId = parseInt(btn.dataset.caldavDelete, 10);
- if (!await confirmModal(t('settings.deleteAccountConfirm'), { danger: true })) return;
- try {
- await api.delete(`/calendar/caldav/accounts/${accountId}`);
- window.oikos?.showToast(t('settings.caldavAccountDeleted'), 'success');
- await loadCalDAVAccounts(container);
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- });
- });
-
- } catch (err) {
- console.error('Failed to load CalDAV accounts:', err);
- window.oikos?.showToast(t('settings.caldavConnectionFailed'), 'danger');
- }
- }
-
- // Load CalDAV accounts on page load
- if (user?.role === 'admin') {
- loadCalDAVAccounts(container);
- }
-
- // CalDAV add account button
- const caldavAddBtn = container.querySelector('#caldav-add-account-btn');
- if (caldavAddBtn) {
- caldavAddBtn.addEventListener('click', () => {
- openModal({
- title: t('settings.caldavAddAccount'),
- size: 'sm',
- content: `
-
- `,
- onSave: async (panel) => {
- const form = panel.querySelector('#caldav-add-form');
- const errorEl = panel.querySelector('#caldav-add-error');
- errorEl.hidden = true;
-
- const name = panel.querySelector('#caldav-name').value.trim();
- const caldavUrl = panel.querySelector('#caldav-url').value.trim();
- const username = panel.querySelector('#caldav-username').value.trim();
- const password = panel.querySelector('#caldav-password').value;
-
- if (!name || !caldavUrl || !username || !password) {
- showError(errorEl, t('common.requiredFields'));
- return;
- }
-
- try {
- await api.post('/calendar/caldav/accounts', {
- name,
- caldavUrl,
- username,
- password,
- });
- closeModal({ force: true });
- window.oikos?.showToast(t('settings.caldavAccountAdded'), 'success');
- await loadCalDAVAccounts(container);
- } catch (err) {
- showError(errorEl, err.message);
- }
- },
- });
- });
- }
-
- // Mitglied hinzufügen (Admin)
- const addMemberBtn = container.querySelector('#add-member-btn');
- if (addMemberBtn) {
- addMemberBtn.addEventListener('click', () => {
- container.querySelector('#add-member-form-card').classList.remove('settings-card--hidden');
- addMemberBtn.hidden = true;
- });
- }
-
- const cancelAddMember = container.querySelector('#cancel-add-member');
- if (cancelAddMember) {
- cancelAddMember.addEventListener('click', () => {
- container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
- container.querySelector('#add-member-btn').hidden = false;
- container.querySelector('#add-member-form').reset();
- container.querySelector('#member-error').hidden = true;
- });
- }
-
- const addMemberForm = container.querySelector('#add-member-form');
- if (addMemberForm) {
- addMemberForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const errorEl = container.querySelector('#member-error');
- errorEl.hidden = true;
- const birthDateRaw = container.querySelector('#new-member-birth-date')?.value || '';
- if (!isDateInputValid(birthDateRaw)) {
- showError(errorEl, t('settings.memberBirthDateInvalid'));
- return;
- }
-
- const data = {
- username: container.querySelector('#new-username').value.trim(),
- display_name: container.querySelector('#new-display-name').value.trim(),
- password: container.querySelector('#new-member-password').value,
- avatar_color: container.querySelector('#new-avatar-color').value,
- family_role: container.querySelector('#new-family-role').value,
- system_admin: container.querySelector('#new-system-admin')?.checked === true,
- phone: container.querySelector('#new-member-phone')?.value.trim() || null,
- email: container.querySelector('#new-member-email')?.value.trim() || null,
- birth_date: parseDateInput(birthDateRaw) || null,
- };
-
- const btn = addMemberForm.querySelector('[type=submit]');
- btn.disabled = true;
- try {
- const res = await auth.createUser(data);
- const list = container.querySelector('#members-list');
- users.push(res.user);
- list.insertAdjacentHTML('beforeend', memberHtml(res.user));
- addMemberForm.reset();
- container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
- container.querySelector('#add-member-btn').hidden = false;
- window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success');
- bindDeleteButtons(container, user);
- bindEditButtons(container, user, users);
- } catch (err) {
- showError(errorEl, err.message);
- } finally {
- btn.disabled = false;
- }
- });
- }
-
- bindDeleteButtons(container, user);
- bindEditButtons(container, user, users);
-
- // Abmelden
- const logoutBtn = container.querySelector('#logout-btn');
- if (logoutBtn) {
- logoutBtn.addEventListener('click', async () => {
- try {
- await auth.logout();
- } finally {
- window.location.href = '/login';
- }
- });
- }
-}
-
-// --------------------------------------------------------
-// Tab-Navigation
-// --------------------------------------------------------
-
-function bindTabEvents(container) {
- const tabList = container.querySelector('.settings-tabs');
- if (!tabList) return;
-
- tabList.addEventListener('click', (e) => {
- const btn = e.target.closest('[data-tab]');
- if (!btn) return;
- const tab = btn.dataset.tab;
-
- tabList.querySelectorAll('[data-tab]').forEach((b) => {
- const active = b.dataset.tab === tab;
- b.classList.toggle('settings-tab-btn--active', active);
- b.setAttribute('aria-selected', String(active));
- });
-
- container.querySelectorAll('[data-panel]').forEach((panel) => {
- panel.hidden = panel.dataset.panel !== tab;
- });
-
- try { sessionStorage.setItem(SETTINGS_TAB_KEY, tab); } catch (_) {}
- });
-}
-
-
-function bindDeleteButtons(container, user) {
- container.querySelectorAll('[data-delete-user]').forEach((btn) => {
- btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
- });
- container.querySelectorAll('[data-delete-user]').forEach((btn) => {
- btn.addEventListener('click', async () => {
- const id = parseInt(btn.dataset.deleteUser, 10);
- const name = btn.dataset.name;
- if (!await confirmModal(t('settings.deleteMemberConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
- try {
- await auth.deleteUser(id);
- btn.closest('.settings-member').remove();
- window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- });
- });
-}
-
-function bindEditButtons(container, currentUser, users) {
- container.querySelectorAll('[data-edit-user]').forEach((btn) => {
- btn.replaceWith(btn.cloneNode(true));
- });
- container.querySelectorAll('[data-edit-user]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const id = parseInt(btn.dataset.editUser, 10);
- const member = users.find((u) => u.id === id);
- if (member) openEditMemberModal(member, currentUser, users, container);
- });
- });
-}
-
-function openEditMemberModal(member, currentUser, users, container) {
- const state = { avatarData: member.avatar_data ?? null };
- openModal({
- title: t('settings.editMemberTitle'),
- size: 'md',
- content: `
-
- `,
- onSave(panel) {
- const fileInput = panel.querySelector('#edit-member-avatar-file');
- const errorEl = panel.querySelector('#edit-member-error');
- bindSettingsDateInputs(panel);
- bindAvatarPicker(panel, 'edit-member');
- fileInput?.addEventListener('change', async () => {
- errorEl.hidden = true;
- try {
- const avatarData = await readImageAsDataUrl(fileInput.files?.[0]);
- if (avatarData !== undefined) {
- state.avatarData = avatarData;
- setAvatarPreview(panel, '#edit-member-avatar-preview', {
- display_name: panel.querySelector('#edit-member-display-name')?.value || member.display_name,
- avatar_color: panel.querySelector('#edit-member-avatar-color')?.value || member.avatar_color,
- avatar_data: avatarData,
- });
- }
- } catch (err) {
- fileInput.value = '';
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- }
- });
-
- panel.querySelector('#edit-member-avatar-remove')?.addEventListener('click', () => {
- state.avatarData = null;
- if (fileInput) fileInput.value = '';
- setAvatarPreview(panel, '#edit-member-avatar-preview', {
- display_name: panel.querySelector('#edit-member-display-name')?.value || member.display_name,
- avatar_color: panel.querySelector('#edit-member-avatar-color')?.value || member.avatar_color,
- avatar_data: null,
- });
- });
-
- panel.querySelector('#edit-member-cancel')?.addEventListener('click', closeModal);
- panel.querySelector('#edit-member-form')?.addEventListener('submit', async (e) => {
- e.preventDefault();
- const submitBtn = panel.querySelector('[type=submit]');
- errorEl.hidden = true;
- const birthDateRaw = panel.querySelector('#edit-member-birth-date')?.value || '';
- if (!isDateInputValid(birthDateRaw)) {
- showError(errorEl, t('settings.memberBirthDateInvalid'));
- submitBtn.disabled = false;
- return;
- }
- submitBtn.disabled = true;
- try {
- const res = await auth.updateUser(member.id, {
- username: panel.querySelector('#edit-member-username').value.trim(),
- display_name: panel.querySelector('#edit-member-display-name').value.trim(),
- avatar_color: panel.querySelector('#edit-member-avatar-color').value,
- avatar_data: state.avatarData,
- family_role: panel.querySelector('#edit-member-family-role').value,
- system_admin: panel.querySelector('#edit-member-system-admin').checked,
- phone: panel.querySelector('#edit-member-phone')?.value.trim() || null,
- email: panel.querySelector('#edit-member-email')?.value.trim() || null,
- birth_date: parseDateInput(birthDateRaw) || null,
- });
- const idx = users.findIndex((u) => u.id === member.id);
- if (idx !== -1) users[idx] = res.user;
- if (currentUser.id === member.id) Object.assign(currentUser, res.user);
- closeModal({ force: true });
- window.oikos?.showToast(t('settings.memberUpdatedToast', { name: res.user.display_name }), 'success');
- render(container, { user: currentUser });
- } catch (err) {
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- } finally {
- submitBtn.disabled = false;
- }
- });
- },
- });
-}
-
-function apiTokenHtml(token) {
- const status = token.revoked_at
- ? t('settings.apiTokenRevoked')
- : token.expires_at && new Date(token.expires_at).getTime() <= Date.now()
- ? t('settings.apiTokenExpired')
- : t('settings.apiTokenActive');
- const meta = [
- `${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`,
- token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'),
- token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'),
- status,
- ].join(' · ');
-
- return `
-
-
- ${esc(token.name)}
- ${esc(meta)}
-
-
-
- `;
-}
-
-function renderApiTokenList(container, tokens) {
- const list = container.querySelector('#api-token-list');
- if (!list) return;
- list.replaceChildren();
- tokens.forEach((token) => {
- const tmp = document.createElement('template');
- tmp.innerHTML = apiTokenHtml(token);
- list.appendChild(tmp.content.firstElementChild);
- });
- if (window.lucide) window.lucide.createIcons();
-}
-
-function datetimeLocalToIso(value) {
- if (!value) return null;
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? null : date.toISOString();
-}
-
-function bindApiTokenEvents(container, initialTokens) {
- const form = container.querySelector('#api-token-form');
- const list = container.querySelector('#api-token-list');
- if (!form || !list) return;
-
- let tokens = [...initialTokens];
-
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const errorEl = container.querySelector('#api-token-error');
- const output = container.querySelector('#api-token-created');
- const outputValue = container.querySelector('#api-token-created-value');
- errorEl.hidden = true;
- output.hidden = true;
-
- const name = container.querySelector('#api-token-name').value.trim();
- const expiresValue = container.querySelector('#api-token-expires').value;
- const expires_at = datetimeLocalToIso(expiresValue);
- if (expiresValue && !expires_at) {
- showError(errorEl, t('settings.apiTokenInvalidExpiration'));
- return;
- }
-
- const btn = form.querySelector('[type=submit]');
- btn.disabled = true;
- try {
- const res = await api.post('/auth/api-tokens', { name, expires_at });
- tokens.unshift(res.data);
- renderApiTokenList(container, tokens);
- form.reset();
- outputValue.value = res.token;
- output.hidden = false;
- outputValue.focus();
- outputValue.select();
- window.oikos?.showToast(t('settings.apiTokenCreatedToast'), 'success');
- } catch (err) {
- showError(errorEl, err.message);
- } finally {
- btn.disabled = false;
- }
- });
-
- list.addEventListener('click', async (e) => {
- const btn = e.target.closest('[data-revoke-api-token]');
- if (!btn) return;
- const id = Number(btn.dataset.revokeApiToken);
- const name = btn.dataset.name;
- if (!await confirmModal(t('settings.apiTokenRevokeConfirm', { name }), { danger: true, confirmLabel: t('settings.apiTokenRevoke') })) return;
- try {
- await api.delete(`/auth/api-tokens/${id}`);
- tokens = tokens.map((token) => token.id === id ? { ...token, revoked_at: new Date().toISOString() } : token);
- renderApiTokenList(container, tokens);
- window.oikos?.showToast(t('settings.apiTokenRevokedToast'), 'default');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- });
-}
-
-async function loadBackupSchedulerStatus(container) {
- const infoContainer = container.querySelector('#backup-scheduler-info');
- if (!infoContainer) return;
-
- try {
- const res = await api.get('/backup/status');
- const scheduler = res.data?.scheduler;
- if (!scheduler) return;
-
- const { enabled, schedule, keepCount, lastBackup } = scheduler;
-
- let lastBackupText = t('settings.backupSchedulerNever');
- if (lastBackup?.timestamp) {
- const date = formatDate(lastBackup.timestamp) + ' ' + formatTime(lastBackup.timestamp);
- lastBackupText = lastBackup.success
- ? t('settings.backupSchedulerLastSuccess', { date })
- : t('settings.backupSchedulerLastFail', { date });
- }
-
- const html = `
-
- ${t('settings.backupSchedulerStatus')}
-
- ${enabled ? t('settings.backupSchedulerEnabled') : t('settings.backupSchedulerDisabled')}
-
-
- ${enabled ? `
-
- ${t('settings.backupSchedulerSchedule')}
- ${esc(schedule)}
-
-
- ${t('settings.backupSchedulerKeep')}
- ${t('settings.backupSchedulerKeepCount', { count: keepCount })}
-
-
- ${t('settings.backupSchedulerLastBackup')}
- ${esc(lastBackupText)}
-
-
-
-
- ` : ''}
- `;
-
- infoContainer.replaceChildren();
- infoContainer.insertAdjacentHTML('beforeend', html);
-
- if (window.lucide) window.lucide.createIcons();
-
- // Event-Handler für manuellen Trigger
- const triggerBtn = infoContainer.querySelector('#backup-trigger-btn');
- if (triggerBtn) {
- triggerBtn.addEventListener('click', async () => {
- triggerBtn.disabled = true;
- triggerBtn.textContent = t('settings.backupSchedulerTriggering');
- try {
- await api.post('/backup/trigger');
- window.oikos?.showToast(t('settings.backupSchedulerTriggeredToast'), 'success');
- // Status neu laden
- loadBackupSchedulerStatus(container);
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- triggerBtn.disabled = false;
- triggerBtn.textContent = t('settings.backupSchedulerTrigger');
- }
- });
- }
- } catch (err) {
- console.error('Failed to load backup scheduler status:', err);
- }
-}
-
-function bindBackupEvents(container) {
- // Scheduler-Status laden und anzeigen
- loadBackupSchedulerStatus(container);
-
- const form = container.querySelector('#backup-restore-form');
- const fileInput = container.querySelector('#backup-restore-file');
- const selectedFile = container.querySelector('#backup-selected-file');
- const restoreBtn = container.querySelector('#backup-restore-btn');
- const errorEl = container.querySelector('#backup-restore-error');
- const dropzone = container.querySelector('#backup-dropzone');
-
- if (!form || !fileInput || !selectedFile || !restoreBtn || !errorEl) return;
-
- function setFile(file) {
- if (!file) {
- selectedFile.hidden = true;
- selectedFile.textContent = '';
- restoreBtn.disabled = true;
- return;
- }
- selectedFile.textContent = `${file.name} · ${Math.round(file.size / 1024)} KB`;
- selectedFile.hidden = false;
- restoreBtn.disabled = false;
- }
-
- fileInput.addEventListener('change', () => {
- errorEl.hidden = true;
- setFile(fileInput.files?.[0]);
- });
-
- dropzone?.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropzone.classList.add('settings-backup-dropzone--active');
- });
-
- dropzone?.addEventListener('dragleave', () => {
- dropzone.classList.remove('settings-backup-dropzone--active');
- });
-
- dropzone?.addEventListener('drop', (e) => {
- e.preventDefault();
- dropzone.classList.remove('settings-backup-dropzone--active');
- const file = e.dataTransfer?.files?.[0];
- if (!file) return;
- const transfer = new DataTransfer();
- transfer.items.add(file);
- fileInput.files = transfer.files;
- errorEl.hidden = true;
- setFile(file);
- });
-
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const file = fileInput.files?.[0];
- if (!file) return;
- if (!await confirmModal(t('settings.backupRestoreConfirm'), { danger: true, confirmLabel: t('settings.backupRestoreButton') })) return;
-
- errorEl.hidden = true;
- restoreBtn.disabled = true;
- restoreBtn.textContent = t('settings.backupRestoring');
- try {
- await api.rawPost('/backup/restore', file);
- window.oikos?.showToast(t('settings.backupRestoredToast'), 'success');
- window.location.reload();
- } catch (err) {
- showError(errorEl, err.message ?? t('common.errorGeneric'));
- restoreBtn.disabled = false;
- restoreBtn.textContent = t('settings.backupRestoreButton');
- }
- });
-}
-
-
-// --------------------------------------------------------
-// Kategorie-Verwaltung
-// --------------------------------------------------------
-
-function categoryRowHtml(cat, isFirst, isLast) {
- return `
-
-
- ${esc(catLabel(cat.name))}
-
-
-
-
-
- `;
-}
-
-function renderCatList(container, cats) {
- const list = container.querySelector('#cat-list');
- if (!list) return;
- // DOM-API statt innerHTML (Security-Constraint des Projekts)
- list.replaceChildren();
- cats.forEach((c, i) => {
- const tmp = document.createElement('template');
- tmp.innerHTML = categoryRowHtml(c, i === 0, i === cats.length - 1);
- list.appendChild(tmp.content.firstElementChild);
- });
- if (window.lucide) window.lucide.createIcons();
-}
-
-function bindCategoryEvents(container) {
- let cats = [];
-
- api.get('/shopping/categories').then((res) => {
- cats = res.data ?? [];
- renderCatList(container, cats);
- }).catch(() => {});
-
- const addForm = container.querySelector('#cat-add-form');
- if (addForm) {
- addForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const input = container.querySelector('#cat-add-input');
- const name = input.value.trim();
- if (!name) return;
- try {
- const res = await api.post('/shopping/categories', { name });
- cats.push(res.data);
- renderCatList(container, cats);
- input.value = '';
- input.focus();
- window.oikos?.showToast(t('settings.shoppingCategoryAdded'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- });
- }
-
- const catList = container.querySelector('#cat-list');
- if (!catList) return;
-
- catList.addEventListener('click', async (e) => {
- const target = e.target.closest('[data-action]');
- if (!target) return;
- const action = target.dataset.action;
- const rowEl = target.closest('[data-cat-id]');
- const id = rowEl ? Number(rowEl.dataset.catId) : Number(target.dataset.id);
-
- if (action === 'rename-cat') {
- const cat = cats.find((c) => c.id === id);
- if (!cat) return;
- const { promptModal } = await import('/components/modal.js');
- const newName = await promptModal(t('settings.shoppingCategoryRenamePrompt'), catLabel(cat.name));
- if (!newName || newName === cat.name) return;
- try {
- const res = await api.put(`/shopping/categories/${id}`, { name: newName });
- const idx = cats.findIndex((c) => c.id === id);
- if (idx >= 0) cats[idx] = res.data;
- renderCatList(container, cats);
- window.oikos?.showToast(t('settings.shoppingCategoryRenamed'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- }
-
- if (action === 'move-cat-up') {
- const idx = cats.findIndex((c) => c.id === id);
- if (idx <= 0) return;
- [cats[idx - 1], cats[idx]] = [cats[idx], cats[idx - 1]];
- renderCatList(container, cats);
- try {
- await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- }
-
- if (action === 'move-cat-down') {
- const idx = cats.findIndex((c) => c.id === id);
- if (idx < 0 || idx >= cats.length - 1) return;
- [cats[idx], cats[idx + 1]] = [cats[idx + 1], cats[idx]];
- renderCatList(container, cats);
- try {
- await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- }
-
- if (action === 'delete-cat') {
- const cat = cats.find((c) => c.id === id);
- if (!cat) return;
- const { confirmModal: confirmDel } = await import('/components/modal.js');
- if (!await confirmDel(
- t('settings.shoppingCategoryDeleteConfirm', { name: catLabel(cat.name) }),
- { danger: true, confirmLabel: t('common.delete') }
- )) return;
- try {
- await api.delete(`/shopping/categories/${id}`);
- cats = cats.filter((c) => c.id !== id);
- renderCatList(container, cats);
- window.oikos?.showToast(t('settings.shoppingCategoryDeleted'), 'default');
- } catch (err) {
- window.oikos?.showToast(err.message, 'danger');
- }
- }
- });
-}
-
-function memberHtml(u) {
- const familyRole = familyRoleLabel(u.family_role);
- const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : '';
- const profileMeta = [
- u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
- u.email || '',
- u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
- ].filter(Boolean).map(esc).join(' · ');
- return `
-
- ${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
-
- ${esc(u.display_name)}
- @${esc(u.username)} · ${esc(familyRole)}${systemRole}
- ${profileMeta ? `${profileMeta}` : ''}
-
-
-
-
- `;
-}
-
-// --------------------------------------------------------
-// ICS-Abonnements
-// --------------------------------------------------------
-
-function renderIcsList(container, subs, user) {
- const listEl = container.querySelector('#ics-list-container');
- if (!listEl) return;
- listEl.replaceChildren();
-
- if (subs.length === 0) {
- const empty = document.createElement('p');
- empty.className = 'form-hint';
- empty.style.padding = 'var(--space-3) 0';
- empty.textContent = t('settings.ics.empty');
- listEl.appendChild(empty);
- return;
- }
-
- const ul = document.createElement('ul');
- ul.className = 'settings-members';
- subs.forEach((sub) => {
- const li = document.createElement('li');
- li.className = 'settings-member';
- li.dataset.subId = sub.id;
-
- const dot = document.createElement('span');
- dot.className = 'settings-avatar settings-avatar--sm';
- dot.style.background = sub.color;
- dot.style.flexShrink = '0';
- li.appendChild(dot);
-
- const info = document.createElement('div');
- info.className = 'settings-member__info';
-
- const nameLine = document.createElement('span');
- nameLine.className = 'settings-member__name';
- nameLine.textContent = sub.name;
-
- const badge = document.createElement('span');
- badge.className = `badge ${sub.shared ? 'badge--success' : 'badge--neutral'}`;
- badge.style.marginLeft = 'var(--space-2)';
- badge.textContent = sub.shared ? t('settings.ics.badges.shared') : t('settings.ics.badges.private');
- nameLine.appendChild(badge);
- info.appendChild(nameLine);
-
- const meta = document.createElement('span');
- meta.className = 'settings-member__meta';
- if (sub.last_sync) {
- const d = new Date(sub.last_sync);
- meta.textContent = `${t('settings.ics.status.lastSync')} ${formatDate(d)} ${formatTime(d)}`;
- } else {
- meta.textContent = t('settings.ics.status.never');
- }
- info.appendChild(meta);
- li.appendChild(info);
-
- const isOwner = sub.created_by === user.id || user.role === 'admin';
- if (isOwner) {
- const actions = document.createElement('div');
- actions.className = 'cat-row__actions';
-
- const syncBtn = document.createElement('button');
- syncBtn.className = 'btn btn--icon btn--ghost';
- syncBtn.title = t('settings.ics.actions.sync');
- syncBtn.setAttribute('aria-label', t('settings.ics.actions.sync'));
- syncBtn.dataset.action = 'ics-sync';
- syncBtn.dataset.id = sub.id;
- const syncIcon = document.createElement('i');
- syncIcon.setAttribute('data-lucide', 'refresh-cw');
- syncIcon.style.cssText = 'width:16px;height:16px';
- syncIcon.setAttribute('aria-hidden', 'true');
- syncBtn.appendChild(syncIcon);
- actions.appendChild(syncBtn);
-
- const editBtn = document.createElement('button');
- editBtn.className = 'btn btn--icon btn--ghost';
- editBtn.title = t('settings.ics.actions.edit');
- editBtn.setAttribute('aria-label', t('settings.ics.actions.edit'));
- editBtn.dataset.action = 'ics-edit';
- editBtn.dataset.id = sub.id;
- const editIcon = document.createElement('i');
- editIcon.setAttribute('data-lucide', 'pencil');
- editIcon.style.cssText = 'width:14px;height:14px';
- editIcon.setAttribute('aria-hidden', 'true');
- editBtn.appendChild(editIcon);
- actions.appendChild(editBtn);
-
- const delBtn = document.createElement('button');
- delBtn.className = 'btn btn--icon btn--danger-outline';
- delBtn.title = t('settings.ics.actions.delete');
- delBtn.setAttribute('aria-label', t('settings.ics.actions.delete'));
- delBtn.dataset.action = 'ics-delete';
- delBtn.dataset.id = sub.id;
- delBtn.dataset.name = sub.name;
- const delIcon = document.createElement('i');
- delIcon.setAttribute('data-lucide', 'trash-2');
- delIcon.style.cssText = 'width:14px;height:14px';
- delIcon.setAttribute('aria-hidden', 'true');
- delBtn.appendChild(delIcon);
- actions.appendChild(delBtn);
-
- li.appendChild(actions);
- }
-
- ul.appendChild(li);
- });
- listEl.appendChild(ul);
- if (window.lucide) window.lucide.createIcons();
-}
-
-function bindIcsEvents(container, user, initialSubs) {
- let subs = [...initialSubs];
- renderIcsList(container, subs, user);
-
- const addBtn = container.querySelector('#ics-add-btn');
- const formWrapper = container.querySelector('#ics-add-form-wrapper');
- const addForm = container.querySelector('#ics-add-form');
- const cancelBtn = container.querySelector('#ics-cancel-btn');
- const submitBtn = container.querySelector('#ics-submit-btn');
- const errorEl = container.querySelector('#ics-add-error');
- const listEl = container.querySelector('#ics-list-container');
-
- if (addBtn) {
- addBtn.addEventListener('click', () => {
- formWrapper.hidden = false;
- addBtn.hidden = true;
- container.querySelector('#ics-url')?.focus();
- });
- }
-
- if (cancelBtn) {
- cancelBtn.addEventListener('click', () => {
- formWrapper.hidden = true;
- addBtn.hidden = false;
- addForm?.reset();
- errorEl.hidden = true;
- });
- }
-
- if (addForm) {
- addForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- errorEl.hidden = true;
- const url = container.querySelector('#ics-url').value.trim();
- const name = container.querySelector('#ics-name').value.trim();
- const color = container.querySelector('#ics-color').value;
- const shared = container.querySelector('#ics-shared').checked ? 1 : 0;
-
- submitBtn.disabled = true;
- try {
- const res = await api.post('/calendar/subscriptions', { url, name, color, shared });
- subs.push(res.data);
- renderIcsList(container, subs, user);
- addForm.reset();
- formWrapper.hidden = true;
- addBtn.hidden = false;
- if (res.syncError) {
- window.oikos?.showToast(`${t('settings.ics.status.syncError')}: ${res.syncError}`, 'danger');
- } else {
- window.oikos?.showToast(t('settings.ics.addedToast'), 'success');
- }
- } catch (err) {
- errorEl.textContent = err.message ?? t('common.errorGeneric');
- errorEl.hidden = false;
- } finally {
- submitBtn.disabled = false;
- }
- });
- }
-
- if (listEl) {
- listEl.addEventListener('click', async (e) => {
- const target = e.target.closest('[data-action]');
- if (!target) return;
- const action = target.dataset.action;
- const id = parseInt(target.dataset.id, 10);
-
- if (action === 'ics-sync') {
- const origIcon = target.querySelector('[data-lucide]');
- const origTitle = target.title;
- target.disabled = true;
- target.title = t('settings.ics.status.syncing');
- if (origIcon) origIcon.setAttribute('data-lucide', 'loader');
- if (window.lucide) window.lucide.createIcons();
- try {
- const res = await api.post(`/calendar/subscriptions/${id}/sync`, {});
- const idx = subs.findIndex((s) => s.id === id);
- if (idx >= 0) subs[idx] = res.data;
- renderIcsList(container, subs, user);
- window.oikos?.showToast(t('settings.ics.syncedToast'), 'success');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- target.disabled = false;
- target.title = origTitle;
- if (origIcon) origIcon.setAttribute('data-lucide', 'refresh-cw');
- if (window.lucide) window.lucide.createIcons();
- }
- }
-
- if (action === 'ics-edit') {
- const sub = subs.find((s) => s.id === id);
- if (!sub) return;
- openModal({
- title: t('settings.ics.actions.edit'),
- size: 'sm',
- content: `
-
- `,
- onSave(panel) {
- panel.querySelector('#ics-edit-cancel')?.addEventListener('click', () => closeModal());
- panel.querySelector('#ics-edit-form')?.addEventListener('submit', async (e) => {
- e.preventDefault();
- const submitBtn = panel.querySelector('[type=submit]');
- const errEl = panel.querySelector('#ics-edit-error');
- const name = panel.querySelector('#ics-edit-name').value.trim();
- const color = panel.querySelector('#ics-edit-color').value;
- const shared = panel.querySelector('#ics-edit-shared').checked ? 1 : 0;
- errEl.hidden = true;
- submitBtn.disabled = true;
- try {
- const res = await api.patch(`/calendar/subscriptions/${id}`, { name, color, shared });
- const idx = subs.findIndex((s) => s.id === id);
- if (idx >= 0) subs[idx] = res.data;
- renderIcsList(container, subs, user);
- window.oikos?.showToast(t('settings.ics.updatedToast'), 'success');
- closeModal({ force: true });
- } catch (err) {
- errEl.textContent = err.message ?? t('common.errorGeneric');
- errEl.hidden = false;
- submitBtn.disabled = false;
- }
- });
- },
- });
- }
-
- if (action === 'ics-delete') {
- const name = target.dataset.name;
- if (!await confirmModal(t('settings.ics.confirm_delete'), { danger: true, confirmLabel: t('common.delete') })) return;
- try {
- await api.delete(`/calendar/subscriptions/${id}`);
- subs = subs.filter((s) => s.id !== id);
- renderIcsList(container, subs, user);
- window.oikos?.showToast(t('settings.ics.deletedToast'), 'default');
- } catch (err) {
- window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
- }
- }
- });
- }
-}
-
-function initials(name) {
- if (!name) return '?';
- return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
-}
-
-function formatDateTime(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- return `${formatDate(d)} ${formatTime(d)}`.trim();
-}
-
-function currentTheme() {
- return localStorage.getItem('oikos-theme') || 'system';
-}
-
-function applyTheme(value) {
- window.oikos?.applyTheme(value);
-}
-
-function showError(el, msg) {
- el.textContent = msg;
- el.hidden = false;
-}