/** * Modul: Einstellungen (Settings) * Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder * Abhängigkeiten: /api.js */ import { api, auth } from '/api.js'; import { confirmModal } from '/components/modal.js'; import { t, formatDate, formatTime } from '/i18n.js'; import { esc } from '/utils/html.js'; import '/components/oikos-locale-picker.js'; const SUPPORTED_CURRENCIES = ['AUD', 'CAD', 'CHF', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'JPY', 'NOK', 'PLN', 'SEK', 'USD']; 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(''); } /** * @param {HTMLElement} container * @param {{ user: object }} context */ export async function render(container, { user }) { // 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' }; let categories = []; try { const [usersRes, gStatus, aStatus, prefsRes, catsRes] = 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'), ]); 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 ?? []; } catch (_) { /* non-critical */ } 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'); 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')}

${t('settings.languageTitle')}

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

${t('settings.changePassword')}

${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')}`}
${user?.role === 'admin' ? `

${t('settings.sectionFamily')}

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

${t('settings.newMemberTitle')}

` : ''}
`; // 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, categories); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- function bindEvents(container, user, categories) { bindCategoryEvents(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'); }); } // 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'); } }); } // 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'); } }); } // 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 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, role: container.querySelector('#new-role').value, }; const btn = addMemberForm.querySelector('[type=submit]'); btn.disabled = true; try { const res = await auth.createUser(data); const list = container.querySelector('#members-list'); 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); } catch (err) { showError(errorEl, err.message); } finally { btn.disabled = false; } }); } bindDeleteButtons(container, user); // Abmelden const logoutBtn = container.querySelector('#logout-btn'); if (logoutBtn) { logoutBtn.addEventListener('click', async () => { try { await auth.logout(); } finally { window.location.href = '/login'; } }); } } 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'); } }); }); } // -------------------------------------------------------- // Kategorie-Verwaltung // -------------------------------------------------------- function categoryRowHtml(cat, isFirst, isLast) { return `
  • ${esc(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'), 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: 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) { return `
  • ${initials(u.display_name)}
    ${esc(u.display_name)} @${esc(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}
  • `; } 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) { localStorage.setItem('oikos-theme', value); if (value === 'light' || value === 'dark') { document.documentElement.setAttribute('data-theme', value); } else { document.documentElement.removeAttribute('data-theme'); } } function showError(el, msg) { el.textContent = msg; el.hidden = false; }