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 ? `${safeName}` : 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.mealTypesLabel')}

-

${t('settings.mealTypesHint')}

-
- - - - -
-
-
-
- - -
-
-

${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')}

- - -
-
- -
-
${t('settings.googleCalendar')}
-
- ${googleStatusText} -
-
-
- ${googleStatus.configured ? ` -
- ${googleStatus.connected ? ` - - ${user?.role === 'admin' ? `` : ''} - ` : ` - ${user?.role === 'admin' ? `${t('settings.connectGoogle')}` : `${t('settings.googleOnlyAdmin')}`} - `} -
- ` : ''} -
- - -
-
- -
-
${t('settings.appleCalendar')}
-
- ${appleStatusText} -
-
-
- ${appleStatus.configured ? ` -
- - ${appleStatus.connected && user?.role === 'admin' ? `` : ''} -
- ` : user?.role === 'admin' ? ` -
-
- - -
-
- - -
-
- - - ${t('settings.applePasswordHint')} -
- - -
- ` : `${t('settings.appleOnlyAdmin')}`} -
- - -
-

${t('settings.caldavTitle')}

-

${t('settings.caldavDescription')}

- -
- - - ${user?.role === 'admin' ? ` - - ` : ''} -
- - -
-
-
-
${t('settings.ics.title')}
-
-
-
- -
- -
-
-
-
- - ${user?.role === 'admin' ? ` - -
-
-

${t('settings.sectionFamily')}

-
-
    - ${users.map(memberHtml).join('')} -
- -
- -
-

${t('settings.newMemberTitle')}

-
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
- -
- - -

${t('settings.memberContactBirthdayHint')}

-
- -

${t('settings.systemAdminHint')}

- -
- - -
-
-
-
-
- ` : ''} - - ${user?.role === 'admin' ? ` - -
-
-

${t('settings.apiTokensTitle')}

-
-

${t('settings.apiTokensCardTitle')}

-

${t('settings.apiTokensHint')}

-
    - ${apiTokens.map(apiTokenHtml).join('')} -
-
-
- - -
-
- - -

${t('settings.apiTokenExpiresHint')}

-
- - - -
-
-
-
- ` : ''} - - -
-
-

${t('settings.sectionAccount')}

- -
- -
- -
-

${t('settings.profilePictureTitle')}

-
-
- ${avatarEditorHtml(user, 'profile')} -
-
-
- - -
-
- - -
-
-
-
- -
- - -

${t('settings.memberContactBirthdayHint')}

-
- -
- -
-
-
- -
-

${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', ` -
-

${esc(account.name)}

-
- ${esc(account.caldav_url)} - ${account.last_sync ? `${t('settings.lastSync')}: ${formatDateTime(account.last_sync)}` : ''} -
-
-
- - ${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: ` -
-
- - -
-
- - - ${t('settings.caldavUrlHint')} -
-
- - -
-
- - - ${t('settings.caldavPasswordHint')} -
- -
- `, - 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: ` -
-
- ${avatarEditorHtml(member, 'edit-member')} -
-
- - -
-
-
- - -
-
- - -
-
-
-
-
- - -
- -
- - -

${t('settings.memberContactBirthdayHint')}

-
- -

${t('settings.systemAdminHint')}

- -
- - -
-
- `, - 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; -}