From b82a86c4b30494f9aaaa60291c109ab1816c5b10 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Mon, 27 Apr 2026 07:53:43 -0300 Subject: [PATCH 1/4] Add family roles to member management --- public/locales/ar.json | 11 ++++++++++ public/locales/de.json | 11 ++++++++++ public/locales/el.json | 11 ++++++++++ public/locales/en.json | 11 ++++++++++ public/locales/es.json | 11 ++++++++++ public/locales/fr.json | 11 ++++++++++ public/locales/hi.json | 11 ++++++++++ public/locales/it.json | 11 ++++++++++ public/locales/ja.json | 11 ++++++++++ public/locales/pt.json | 11 ++++++++++ public/locales/ru.json | 11 ++++++++++ public/locales/sv.json | 11 ++++++++++ public/locales/tr.json | 11 ++++++++++ public/locales/uk.json | 11 ++++++++++ public/locales/zh.json | 11 ++++++++++ public/pages/settings.js | 30 ++++++++++++++++++++++------ server/auth.js | 37 ++++++++++++++++++++++------------ server/db-schema-test.js | 2 ++ server/db.js | 10 ++++++++++ server/index.js | 2 ++ server/openapi.js | 43 ++++++++++++++++++++++++++++++++++++++-- server/routes/family.js | 28 ++++++++++++++++++++++++++ 22 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 server/routes/family.js diff --git a/public/locales/ar.json b/public/locales/ar.json index be87365..87a037a 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -624,6 +624,17 @@ "displayNameLabel": "الاسم المعروض", "memberPasswordLabel": "كلمة المرور", "colorLabel": "اللون", + "familyRoleLabel": "دور العائلة", + "familyRoleDad": "الأب", + "familyRoleMom": "الأم", + "familyRoleParent": "ولي الأمر", + "familyRoleChild": "طفل", + "familyRoleGrandparent": "جد/جدة", + "familyRoleRelative": "قريب", + "familyRoleOther": "فرد من العائلة", + "systemAdminLabel": "مسؤول النظام", + "systemAdminHint": "يمكن لمسؤولي النظام إدارة الإعدادات والتكاملات ورموز API وحسابات العائلة.", + "systemAdminBadge": "مسؤول النظام", "roleLabel": "الدور", "roleMember": "عضو", "roleAdmin": "مسؤول", diff --git a/public/locales/de.json b/public/locales/de.json index 291975f..d2e9d0f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -630,6 +630,17 @@ "displayNameLabel": "Anzeigename", "memberPasswordLabel": "Passwort", "colorLabel": "Farbe", + "familyRoleLabel": "Familienrolle", + "familyRoleDad": "Vater", + "familyRoleMom": "Mutter", + "familyRoleParent": "Elternteil", + "familyRoleChild": "Kind", + "familyRoleGrandparent": "Großelternteil", + "familyRoleRelative": "Verwandte/r", + "familyRoleOther": "Familienmitglied", + "systemAdminLabel": "Systemadministrator", + "systemAdminHint": "Systemadministratoren können App-Einstellungen, Integrationen, API-Tokens und Familienkonten verwalten.", + "systemAdminBadge": "Systemadministrator", "roleLabel": "Rolle", "roleMember": "Mitglied", "roleAdmin": "Admin", diff --git a/public/locales/el.json b/public/locales/el.json index b882d69..0c098e5 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -624,6 +624,17 @@ "displayNameLabel": "Εμφανιζόμενο όνομα", "memberPasswordLabel": "Κωδικός", "colorLabel": "Χρώμα", + "familyRoleLabel": "Ρόλος στην οικογένεια", + "familyRoleDad": "Μπαμπάς", + "familyRoleMom": "Μαμά", + "familyRoleParent": "Γονέας", + "familyRoleChild": "Παιδί", + "familyRoleGrandparent": "Παππούς/Γιαγιά", + "familyRoleRelative": "Συγγενής", + "familyRoleOther": "Μέλος οικογένειας", + "systemAdminLabel": "Διαχειριστής συστήματος", + "systemAdminHint": "Οι διαχειριστές συστήματος μπορούν να διαχειρίζονται ρυθμίσεις, ενσωματώσεις, API tokens και λογαριασμούς οικογένειας.", + "systemAdminBadge": "Διαχειριστής συστήματος", "roleLabel": "Ρόλος", "roleMember": "Μέλος", "roleAdmin": "Διαχειριστής", diff --git a/public/locales/en.json b/public/locales/en.json index 68aaba8..f805ef7 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -624,6 +624,17 @@ "displayNameLabel": "Display name", "memberPasswordLabel": "Password", "colorLabel": "Color", + "familyRoleLabel": "Family role", + "familyRoleDad": "Dad", + "familyRoleMom": "Mom", + "familyRoleParent": "Parent", + "familyRoleChild": "Child", + "familyRoleGrandparent": "Grandparent", + "familyRoleRelative": "Relative", + "familyRoleOther": "Family member", + "systemAdminLabel": "System admin", + "systemAdminHint": "System admins can manage application settings, integrations, API tokens and family accounts.", + "systemAdminBadge": "System admin", "roleLabel": "Role", "roleMember": "Member", "roleAdmin": "Admin", diff --git a/public/locales/es.json b/public/locales/es.json index fba9471..9180108 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -624,6 +624,17 @@ "displayNameLabel": "Nombre para mostrar", "memberPasswordLabel": "Contraseña", "colorLabel": "Color", + "familyRoleLabel": "Rol familiar", + "familyRoleDad": "Papá", + "familyRoleMom": "Mamá", + "familyRoleParent": "Progenitor", + "familyRoleChild": "Hijo/a", + "familyRoleGrandparent": "Abuelo/a", + "familyRoleRelative": "Familiar", + "familyRoleOther": "Miembro de la familia", + "systemAdminLabel": "Administrador del sistema", + "systemAdminHint": "Los administradores del sistema pueden gestionar ajustes, integraciones, tokens de API y cuentas familiares.", + "systemAdminBadge": "Admin del sistema", "roleLabel": "Rol", "roleMember": "Miembro", "roleAdmin": "Administrador", diff --git a/public/locales/fr.json b/public/locales/fr.json index c0e0b3f..1a9f6a9 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -624,6 +624,17 @@ "displayNameLabel": "Nom affiché", "memberPasswordLabel": "Mot de passe", "colorLabel": "Couleur", + "familyRoleLabel": "Rôle familial", + "familyRoleDad": "Papa", + "familyRoleMom": "Maman", + "familyRoleParent": "Parent", + "familyRoleChild": "Enfant", + "familyRoleGrandparent": "Grand-parent", + "familyRoleRelative": "Proche", + "familyRoleOther": "Membre de la famille", + "systemAdminLabel": "Administrateur système", + "systemAdminHint": "Les administrateurs système peuvent gérer les paramètres, intégrations, jetons API et comptes familiaux.", + "systemAdminBadge": "Admin système", "roleLabel": "Rôle", "roleMember": "Membre", "roleAdmin": "Admin", diff --git a/public/locales/hi.json b/public/locales/hi.json index 252117a..eabcb6d 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -624,6 +624,17 @@ "displayNameLabel": "प्रदर्शन नाम", "memberPasswordLabel": "पासवर्ड", "colorLabel": "रंग", + "familyRoleLabel": "परिवार भूमिका", + "familyRoleDad": "पिता", + "familyRoleMom": "माँ", + "familyRoleParent": "अभिभावक", + "familyRoleChild": "बच्चा", + "familyRoleGrandparent": "दादा-दादी/नाना-नानी", + "familyRoleRelative": "रिश्तेदार", + "familyRoleOther": "परिवार सदस्य", + "systemAdminLabel": "सिस्टम एडमिन", + "systemAdminHint": "सिस्टम एडमिन सेटिंग्स, इंटीग्रेशन, API टोकन और परिवार खातों को प्रबंधित कर सकते हैं।", + "systemAdminBadge": "सिस्टम एडमिन", "roleLabel": "भूमिका", "roleMember": "सदस्य", "roleAdmin": "एडमिन", diff --git a/public/locales/it.json b/public/locales/it.json index a790dfb..16d2916 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -624,6 +624,17 @@ "displayNameLabel": "Nome visualizzato", "memberPasswordLabel": "Password", "colorLabel": "Colore", + "familyRoleLabel": "Ruolo familiare", + "familyRoleDad": "Papà", + "familyRoleMom": "Mamma", + "familyRoleParent": "Genitore", + "familyRoleChild": "Figlio/a", + "familyRoleGrandparent": "Nonno/a", + "familyRoleRelative": "Parente", + "familyRoleOther": "Membro della famiglia", + "systemAdminLabel": "Amministratore di sistema", + "systemAdminHint": "Gli amministratori di sistema possono gestire impostazioni, integrazioni, token API e account familiari.", + "systemAdminBadge": "Admin sistema", "roleLabel": "Ruolo", "roleMember": "Membro", "roleAdmin": "Admin", diff --git a/public/locales/ja.json b/public/locales/ja.json index 68b4694..669c102 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -624,6 +624,17 @@ "displayNameLabel": "表示名", "memberPasswordLabel": "パスワード", "colorLabel": "色", + "familyRoleLabel": "家族内の役割", + "familyRoleDad": "父", + "familyRoleMom": "母", + "familyRoleParent": "保護者", + "familyRoleChild": "子ども", + "familyRoleGrandparent": "祖父母", + "familyRoleRelative": "親族", + "familyRoleOther": "家族メンバー", + "systemAdminLabel": "システム管理者", + "systemAdminHint": "システム管理者は設定、連携、APIトークン、家族アカウントを管理できます。", + "systemAdminBadge": "システム管理者", "roleLabel": "役割", "roleMember": "メンバー", "roleAdmin": "管理者", diff --git a/public/locales/pt.json b/public/locales/pt.json index 9ee9b9f..c4861c5 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -624,6 +624,17 @@ "displayNameLabel": "Nome de exibição", "memberPasswordLabel": "Senha", "colorLabel": "Cor", + "familyRoleLabel": "Papel na família", + "familyRoleDad": "Pai", + "familyRoleMom": "Mãe", + "familyRoleParent": "Responsável", + "familyRoleChild": "Filho(a)", + "familyRoleGrandparent": "Avô/Avó", + "familyRoleRelative": "Parente", + "familyRoleOther": "Membro da família", + "systemAdminLabel": "Administrador do sistema", + "systemAdminHint": "Administradores do sistema podem gerenciar configurações, integrações, tokens de API e contas da família.", + "systemAdminBadge": "Admin do sistema", "roleLabel": "Função", "roleMember": "Membro", "roleAdmin": "Admin", diff --git a/public/locales/ru.json b/public/locales/ru.json index 6e145f1..a419b12 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -624,6 +624,17 @@ "displayNameLabel": "Отображаемое имя", "memberPasswordLabel": "Пароль", "colorLabel": "Цвет", + "familyRoleLabel": "Роль в семье", + "familyRoleDad": "Папа", + "familyRoleMom": "Мама", + "familyRoleParent": "Родитель", + "familyRoleChild": "Ребёнок", + "familyRoleGrandparent": "Дедушка/бабушка", + "familyRoleRelative": "Родственник", + "familyRoleOther": "Член семьи", + "systemAdminLabel": "Системный администратор", + "systemAdminHint": "Системные администраторы могут управлять настройками, интеграциями, API-токенами и семейными аккаунтами.", + "systemAdminBadge": "Системный администратор", "roleLabel": "Роль", "roleMember": "Участник", "roleAdmin": "Администратор", diff --git a/public/locales/sv.json b/public/locales/sv.json index f546000..e88c2db 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -624,6 +624,17 @@ "displayNameLabel": "Visningsnamn", "memberPasswordLabel": "Lösenord", "colorLabel": "Färg", + "familyRoleLabel": "Familjeroll", + "familyRoleDad": "Pappa", + "familyRoleMom": "Mamma", + "familyRoleParent": "Förälder", + "familyRoleChild": "Barn", + "familyRoleGrandparent": "Far-/morförälder", + "familyRoleRelative": "Släkting", + "familyRoleOther": "Familjemedlem", + "systemAdminLabel": "Systemadministratör", + "systemAdminHint": "Systemadministratörer kan hantera inställningar, integrationer, API-token och familjekonton.", + "systemAdminBadge": "Systemadmin", "roleLabel": "Roll", "roleMember": "Medlem", "roleAdmin": "Admin", diff --git a/public/locales/tr.json b/public/locales/tr.json index faadd2d..27d4433 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -624,6 +624,17 @@ "displayNameLabel": "Görünen ad", "memberPasswordLabel": "Şifre", "colorLabel": "Renk", + "familyRoleLabel": "Aile rolü", + "familyRoleDad": "Baba", + "familyRoleMom": "Anne", + "familyRoleParent": "Ebeveyn", + "familyRoleChild": "Çocuk", + "familyRoleGrandparent": "Büyükanne/Büyükbaba", + "familyRoleRelative": "Akraba", + "familyRoleOther": "Aile üyesi", + "systemAdminLabel": "Sistem yöneticisi", + "systemAdminHint": "Sistem yöneticileri ayarları, entegrasyonları, API tokenlarını ve aile hesaplarını yönetebilir.", + "systemAdminBadge": "Sistem yöneticisi", "roleLabel": "Rol", "roleMember": "Üye", "roleAdmin": "Yönetici", diff --git a/public/locales/uk.json b/public/locales/uk.json index c035241..3d94b75 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -624,6 +624,17 @@ "displayNameLabel": "Відображуване ім'я", "memberPasswordLabel": "Пароль", "colorLabel": "Колір", + "familyRoleLabel": "Роль у родині", + "familyRoleDad": "Тато", + "familyRoleMom": "Мама", + "familyRoleParent": "Батько/мати", + "familyRoleChild": "Дитина", + "familyRoleGrandparent": "Дідусь/бабуся", + "familyRoleRelative": "Родич", + "familyRoleOther": "Член родини", + "systemAdminLabel": "Системний адміністратор", + "systemAdminHint": "Системні адміністратори можуть керувати налаштуваннями, інтеграціями, API-токенами та сімейними акаунтами.", + "systemAdminBadge": "Системний адміністратор", "roleLabel": "Роль", "roleMember": "Учасник", "roleAdmin": "Адміністратор", diff --git a/public/locales/zh.json b/public/locales/zh.json index b483620..88c1f44 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -624,6 +624,17 @@ "displayNameLabel": "显示名称", "memberPasswordLabel": "密码", "colorLabel": "颜色", + "familyRoleLabel": "家庭角色", + "familyRoleDad": "爸爸", + "familyRoleMom": "妈妈", + "familyRoleParent": "父母", + "familyRoleChild": "孩子", + "familyRoleGrandparent": "祖父母", + "familyRoleRelative": "亲属", + "familyRoleOther": "家庭成员", + "systemAdminLabel": "系统管理员", + "systemAdminHint": "系统管理员可以管理应用设置、集成、API 令牌和家庭账户。", + "systemAdminBadge": "系统管理员", "roleLabel": "角色", "roleMember": "成员", "roleAdmin": "管理员", diff --git a/public/pages/settings.js b/public/pages/settings.js index 7d1f6bd..7ffe88c 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -14,6 +14,7 @@ const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', ' 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 CATEGORY_I18N = { 'Obst & Gemüse': 'shopping.catFruitVeg', @@ -44,6 +45,16 @@ function buildCurrencyOptions(selected) { .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(''); +} + /** * @param {HTMLElement} container * @param {{ user: object }} context @@ -476,12 +487,16 @@ export async function render(container, { user }) {
- - + ${buildFamilyRoleOptions()}
+ +

${t('settings.systemAdminHint')}

@@ -773,7 +788,8 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { 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, + family_role: container.querySelector('#new-family-role').value, + system_admin: container.querySelector('#new-system-admin')?.checked === true, }; const btn = addMemberForm.querySelector('[type=submit]'); @@ -1103,12 +1119,14 @@ function bindCategoryEvents(container) { } function memberHtml(u) { + const familyRole = familyRoleLabel(u.family_role); + const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : ''; return `
  • ${initials(u.display_name)}
    ${esc(u.display_name)} - @${esc(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')} + @${esc(u.username)} · ${esc(familyRole)}${systemRole}
    +
  • + + `; +} + +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 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 img = new Image(); + const objectUrl = URL.createObjectURL(file); + 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); + URL.revokeObjectURL(objectUrl); + if (dataUrl.length > MAX_AVATAR_DATA_LENGTH) { + reject(new Error(t('settings.profilePictureTooLarge'))); + } else { + resolve(dataUrl); + } + } catch (err) { + URL.revokeObjectURL(objectUrl); + reject(err); + } + }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error(t('settings.profilePictureReadError'))); + }; + img.src = objectUrl; + }); +} + /** * @param {HTMLElement} container * @param {{ user: object }} context @@ -397,9 +475,7 @@ export async function render(container, { user }) {
    +
    +

    ${t('settings.profilePictureTitle')}

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

    ${t('settings.changePassword')}

    @@ -522,14 +617,14 @@ export async function render(container, { user }) { }); } - bindEvents(container, user, categories, icsSubscriptions, apiTokens); + bindEvents(container, user, users, categories, icsSubscriptions, apiTokens); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- -function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { +function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) { bindTabEvents(container); bindCategoryEvents(container); bindIcsEvents(container, user, icsSubscriptions); @@ -632,6 +727,64 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { }); } + const profileState = { avatarData: user?.avatar_data ?? null }; + const profileAvatarFile = container.querySelector('#profile-avatar-file'); + 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]'); + errorEl.hidden = true; + 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, + }); + 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) { @@ -797,12 +950,14 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { 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 { @@ -812,6 +967,7 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { } bindDeleteButtons(container, user); + bindEditButtons(container, user, users); // Abmelden const logoutBtn = container.querySelector('#logout-btn'); @@ -874,6 +1030,119 @@ function bindDeleteButtons(container, user) { }); } +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.systemAdminHint')}

    + +
    + + +
    +
    + `, + onSave(panel) { + const fileInput = panel.querySelector('#edit-member-avatar-file'); + const errorEl = panel.querySelector('#edit-member-error'); + 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; + 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, + }); + 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(); + 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') @@ -1123,11 +1392,14 @@ function memberHtml(u) { const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : ''; return `
  • -
    ${initials(u.display_name)}
    + ${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
    ${esc(u.display_name)} @${esc(u.username)} · ${esc(familyRole)}${systemRole}
    + diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css index 2f91b09..0dbd292 100644 --- a/public/styles/dashboard.css +++ b/public/styles/dashboard.css @@ -2262,12 +2262,19 @@ font-size: var(--text-xs); border: 2px solid var(--color-surface); box-shadow: var(--shadow-xs); + overflow: hidden; } .family-widget-avatar + .family-widget-avatar { margin-left: -8px; } +.family-widget-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + .budget-widget { padding: var(--space-4); } diff --git a/public/styles/settings.css b/public/styles/settings.css index 7bc7704..7433143 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -174,6 +174,7 @@ color: var(--color-text-on-accent); flex-shrink: 0; user-select: none; + overflow: hidden; } .settings-avatar--sm { @@ -182,6 +183,38 @@ font-size: var(--text-sm); } +.settings-avatar--lg { + width: 72px; + height: 72px; + font-size: var(--text-2xl); +} + +.settings-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.settings-avatar-editor { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +.settings-avatar-editor__controls { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +@media (max-width: 520px) { + .settings-avatar-editor { + grid-template-columns: 1fr; + } +} + /* -------------------------------------------------------- Formulare -------------------------------------------------------- */ diff --git a/server/auth.js b/server/auth.js index 0c41138..6129097 100644 --- a/server/auth.js +++ b/server/auth.js @@ -17,6 +17,8 @@ const log = createLogger('Auth'); const router = express.Router(); const API_TOKEN_PREFIX = 'oikos_'; const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other']; +const MAX_AVATAR_DATA_LENGTH = 768 * 1024; +const USER_PUBLIC_COLUMNS = 'id, username, display_name, avatar_color, avatar_data, role, family_role, created_at'; // -------------------------------------------------------- // Session-Store (better-sqlite3, gleiche DB-Instanz wie App) @@ -151,13 +153,61 @@ function publicApiToken(row) { }; } +function publicUser(row) { + return { + id: row.id, + username: row.username, + display_name: row.display_name, + avatar_color: row.avatar_color, + avatar_data: row.avatar_data ?? null, + role: row.role, + family_role: row.family_role, + created_at: row.created_at, + }; +} + +function normalizeAvatarData(value) { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + if (typeof value !== 'string') return { error: 'Avatar image must be a data URL string.' }; + if (value.length > MAX_AVATAR_DATA_LENGTH) { + return { error: 'Avatar image is too large.' }; + } + if (!/^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=]+$/i.test(value)) { + return { error: 'Avatar image must be PNG, JPEG, or WebP.' }; + } + return value; +} + +function assertAdminWouldRemain(targetUserId, nextRole) { + if (nextRole === 'admin') return null; + const current = db.get().prepare('SELECT role FROM users WHERE id = ?').get(targetUserId); + if (!current || current.role !== 'admin') return null; + const row = db.get().prepare('SELECT COUNT(*) AS count FROM users WHERE role = ? AND id != ?').get('admin', targetUserId); + return row.count > 0 ? null : 'At least one system admin must remain.'; +} + +function updateUserRoleSessions(userId, role) { + const allSessions = db.get().prepare('SELECT sid, sess FROM sessions').all(); + const updateSession = db.get().prepare('UPDATE sessions SET sess = ? WHERE sid = ?'); + for (const row of allSessions) { + try { + const sess = JSON.parse(row.sess); + if (sess.userId === userId) { + sess.role = role; + updateSession.run(JSON.stringify(sess), row.sid); + } + } catch { /* ignore malformed session */ } + } +} + function authenticateApiToken(req) { const token = extractApiToken(req); if (!token) return null; const tokenHash = hashApiToken(token); const row = db.get().prepare(` - SELECT t.*, u.role, u.username, u.display_name, u.avatar_color, u.family_role + SELECT t.*, u.role, u.username, u.display_name, u.avatar_color, u.avatar_data, u.family_role FROM api_tokens t JOIN users u ON u.id = t.created_by WHERE t.token_hash = ? @@ -176,6 +226,7 @@ function authenticateApiToken(req) { username: row.username, display_name: row.display_name, avatar_color: row.avatar_color, + avatar_data: row.avatar_data, role: row.role, family_role: row.family_role, }; @@ -278,6 +329,7 @@ router.post('/login', loginLimiter, async (req, res) => { username: user.username, display_name: user.display_name, avatar_color: user.avatar_color, + avatar_data: user.avatar_data, role: user.role, family_role: user.family_role, }, @@ -347,7 +399,7 @@ router.post('/setup', loginLimiter, async (req, res) => { .run(username, display_name, hash, avatarColor, 'admin'); res.status(201).json({ - user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, role: 'admin', family_role: 'other' }, + user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, avatar_data: null, role: 'admin', family_role: 'other' }, }); } catch (err) { if (err.message?.includes('UNIQUE constraint')) { @@ -365,7 +417,7 @@ router.post('/setup', loginLimiter, async (req, res) => { router.get('/me', requireAuth, (req, res) => { try { const user = db.get() - .prepare('SELECT id, username, display_name, avatar_color, role, family_role FROM users WHERE id = ?') + .prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`) .get(req.authUserId); if (!user) { @@ -376,7 +428,7 @@ router.get('/me', requireAuth, (req, res) => { } if (req.authMethod === 'api_token') { - return res.json({ user }); + return res.json({ user: publicUser(user) }); } // CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume: @@ -392,7 +444,7 @@ router.get('/me', requireAuth, (req, res) => { maxAge: 1000 * 60 * 60 * 24 * 7, }); - res.json({ user, csrfToken: req.session.csrfToken }); + res.json({ user: publicUser(user), csrfToken: req.session.csrfToken }); } catch (err) { log.error('/me error:', err); res.status(500).json({ error: 'Internal server error.', code: 500 }); @@ -407,9 +459,9 @@ router.get('/me', requireAuth, (req, res) => { router.get('/users', requireAuth, requireAdmin, (req, res) => { try { const users = db.get() - .prepare('SELECT id, username, display_name, avatar_color, role, family_role, created_at FROM users ORDER BY display_name') + .prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users ORDER BY display_name`) .all(); - res.json({ data: users }); + res.json({ data: users.map(publicUser) }); } catch (err) { log.error('Users error:', err); res.status(500).json({ error: 'Internal server error.', code: 500 }); @@ -501,6 +553,7 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res display_name, password, avatar_color = '#007AFF', + avatar_data, family_role = 'other', system_admin = req.body.role === 'admin', } = req.body; @@ -526,17 +579,24 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res return res.status(400).json({ error: 'Invalid family role.', code: 400 }); } + const normalizedAvatarData = normalizeAvatarData(avatar_data); + if (normalizedAvatarData?.error) { + return res.status(400).json({ error: normalizedAvatarData.error, code: 400 }); + } + const hash = await bcrypt.hash(password, 12); const result = db.get() .prepare(` - INSERT INTO users (username, display_name, password_hash, avatar_color, role, family_role) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (username, display_name, password_hash, avatar_color, avatar_data, role, family_role) + VALUES (?, ?, ?, ?, ?, ?, ?) `) - .run(username, display_name, hash, avatar_color, role, family_role); + .run(username, display_name, hash, avatar_color, normalizedAvatarData ?? null, role, family_role); + + const createdUser = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(result.lastInsertRowid); res.status(201).json({ - user: { id: result.lastInsertRowid, username, display_name, avatar_color, role, family_role }, + user: publicUser(createdUser), }); } catch (err) { if (err.message && err.message.includes('UNIQUE constraint')) { @@ -547,6 +607,107 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res } }); +/** + * PATCH /api/v1/auth/users/:id + * Admin only. Updates a family member profile and system-admin flag. + */ +router.patch('/users/:id', requireAuth, requireAdmin, csrfMiddleware, async (req, res) => { + try { + const userId = parseInt(req.params.id, 10); + if (!Number.isFinite(userId)) return res.status(400).json({ error: 'Invalid user ID.', code: 400 }); + + const existing = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(userId); + if (!existing) return res.status(404).json({ error: 'User not found.', code: 404 }); + + const username = req.body.username !== undefined ? String(req.body.username || '').trim() : existing.username; + const displayName = req.body.display_name !== undefined ? String(req.body.display_name || '').trim() : existing.display_name; + const avatarColor = req.body.avatar_color !== undefined ? String(req.body.avatar_color || '').trim() : existing.avatar_color; + const familyRole = req.body.family_role !== undefined ? String(req.body.family_role || '').trim() : existing.family_role; + const nextRole = req.body.system_admin !== undefined + ? (req.body.system_admin === true || req.body.system_admin === 'true' ? 'admin' : 'member') + : existing.role; + const avatarData = req.body.avatar_data !== undefined + ? normalizeAvatarData(req.body.avatar_data) + : existing.avatar_data; + + if (!username || !displayName) { + return res.status(400).json({ error: 'Username and display name are required.', code: 400 }); + } + if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) { + return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 }); + } + if (displayName.length > 128) { + return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 }); + } + if (!FAMILY_ROLES.includes(familyRole)) { + return res.status(400).json({ error: 'Invalid family role.', code: 400 }); + } + if (avatarData?.error) { + return res.status(400).json({ error: avatarData.error, code: 400 }); + } + + const adminError = assertAdminWouldRemain(userId, nextRole); + if (adminError) return res.status(400).json({ error: adminError, code: 400 }); + + db.get().prepare(` + UPDATE users + SET username = ?, display_name = ?, avatar_color = ?, avatar_data = ?, role = ?, family_role = ? + WHERE id = ? + `).run(username, displayName, avatarColor || '#007AFF', avatarData ?? null, nextRole, familyRole, userId); + + if (nextRole !== existing.role) { + updateUserRoleSessions(userId, nextRole); + if (userId === req.authUserId && req.session) req.session.role = nextRole; + } + + const updated = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(userId); + res.json({ user: publicUser(updated) }); + } catch (err) { + if (err.message && err.message.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'Username is already taken.', code: 409 }); + } + log.error('User update error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +/** + * PATCH /api/v1/auth/me/profile + * Updates the current user's profile picture and basic profile fields. + */ +router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => { + try { + const existing = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(req.authUserId); + if (!existing) return res.status(404).json({ error: 'User not found.', code: 404 }); + + const displayName = req.body.display_name !== undefined ? String(req.body.display_name || '').trim() : existing.display_name; + const avatarColor = req.body.avatar_color !== undefined ? String(req.body.avatar_color || '').trim() : existing.avatar_color; + const avatarData = req.body.avatar_data !== undefined + ? normalizeAvatarData(req.body.avatar_data) + : existing.avatar_data; + + if (!displayName) return res.status(400).json({ error: 'Display name is required.', code: 400 }); + if (displayName.length > 128) { + return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 }); + } + if (avatarData?.error) { + return res.status(400).json({ error: avatarData.error, code: 400 }); + } + + db.get().prepare(` + UPDATE users + SET display_name = ?, avatar_color = ?, avatar_data = ? + WHERE id = ? + `).run(displayName, avatarColor || '#007AFF', avatarData ?? null, req.authUserId); + + const updated = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(req.authUserId); + res.json({ user: publicUser(updated) }); + } catch (err) { + log.error('Profile update error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + /** * PATCH /api/v1/auth/me/password * Ändert das eigene Passwort. diff --git a/server/db-schema-test.js b/server/db-schema-test.js index f3b2699..4a7aba6 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -15,6 +15,7 @@ const MIGRATIONS_SQL = { display_name TEXT NOT NULL, password_hash TEXT NOT NULL, avatar_color TEXT NOT NULL DEFAULT '#007AFF', + avatar_data TEXT, role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin', 'member')), family_role TEXT NOT NULL DEFAULT 'other' diff --git a/server/db.js b/server/db.js index c5ef58c..f6bec60 100644 --- a/server/db.js +++ b/server/db.js @@ -727,6 +727,13 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_users_family_role ON users(family_role); `, }, + { + version: 20, + description: 'User profile pictures', + up: ` + ALTER TABLE users ADD COLUMN avatar_data TEXT; + `, + }, ]; /** diff --git a/server/openapi.js b/server/openapi.js index 21fe8be..7465379 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -187,6 +187,14 @@ function buildPaths() { requestBody: jsonBody('#/components/schemas/PasswordChangeRequest'), }), }, + '/api/v1/auth/me/profile': { + patch: op({ + summary: 'Update current user profile', + tag: 'Auth', + stateChanging: true, + requestBody: jsonBody('#/components/schemas/ProfileUpdateRequest'), + }), + }, '/api/v1/auth/users': { get: op({ summary: 'List users', tag: 'Auth', admin: true }), post: op({ @@ -205,6 +213,14 @@ function buildPaths() { }), }, '/api/v1/auth/users/{id}': { + patch: op({ + summary: 'Update user', + tag: 'Auth', + admin: true, + stateChanging: true, + params: [idParam('id', 'User ID')], + requestBody: jsonBody('#/components/schemas/UserUpdateRequest'), + }), delete: op({ summary: 'Delete user', tag: 'Auth', @@ -543,6 +559,7 @@ function buildOpenApiSpec(req, appVersion) { username: { type: 'string' }, display_name: { type: 'string' }, avatar_color: { type: 'string' }, + avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL.' }, role: { type: 'string', enum: ['admin', 'member'] }, family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] }, }, @@ -554,6 +571,7 @@ function buildOpenApiSpec(req, appVersion) { id: { type: 'integer' }, display_name: { type: 'string' }, avatar_color: { type: 'string' }, + avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL.' }, family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] }, created_at: { type: 'string', format: 'date-time' }, }, @@ -617,11 +635,31 @@ function buildOpenApiSpec(req, appVersion) { display_name: { type: 'string' }, password: { type: 'string' }, avatar_color: { type: 'string' }, + avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL.' }, family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] }, system_admin: { type: 'boolean' }, }, required: ['username', 'display_name', 'password'], }, + UserUpdateRequest: { + type: 'object', + properties: { + username: { type: 'string' }, + display_name: { type: 'string' }, + avatar_color: { type: 'string' }, + avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL. Use null to remove.' }, + family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] }, + system_admin: { type: 'boolean' }, + }, + }, + ProfileUpdateRequest: { + type: 'object', + properties: { + display_name: { type: 'string' }, + avatar_color: { type: 'string' }, + avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL. Use null to remove.' }, + }, + }, ApiToken: { type: 'object', properties: { diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 4e85d39..cfe1ea5 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -167,7 +167,7 @@ router.get('/', (req, res) => { // Alle User (für Avatar-Farben in Widgets) try { result.users = d.prepare( - 'SELECT id, display_name, avatar_color FROM users ORDER BY display_name' + 'SELECT id, display_name, avatar_color, avatar_data FROM users ORDER BY display_name' ).all(); } catch (err) { result.users = []; diff --git a/server/routes/family.js b/server/routes/family.js index 11d2e02..a5e1018 100644 --- a/server/routes/family.js +++ b/server/routes/family.js @@ -14,7 +14,7 @@ const router = express.Router(); router.get('/members', (req, res) => { try { const members = db.get().prepare(` - SELECT id, display_name, avatar_color, family_role, created_at + SELECT id, display_name, avatar_color, avatar_data, family_role, created_at FROM users ORDER BY display_name COLLATE NOCASE ASC `).all(); From 2dd38bfae3e7bc9aae187e5b9732768a8c836738 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Mon, 27 Apr 2026 09:04:54 -0300 Subject: [PATCH 3/4] Fix profile picture preview under CSP --- public/pages/settings.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/public/pages/settings.js b/public/pages/settings.js index c0b2838..06699ae 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -100,9 +100,10 @@ function readImageAsDataUrl(file) { return reject(new Error(t('settings.profilePictureFileTooLarge'))); } - const img = new Image(); - const objectUrl = URL.createObjectURL(file); - img.onload = () => { + 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)); @@ -114,22 +115,20 @@ function readImageAsDataUrl(file) { const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); const dataUrl = canvas.toDataURL('image/jpeg', 0.86); - URL.revokeObjectURL(objectUrl); if (dataUrl.length > MAX_AVATAR_DATA_LENGTH) { reject(new Error(t('settings.profilePictureTooLarge'))); } else { resolve(dataUrl); } } catch (err) { - URL.revokeObjectURL(objectUrl); reject(err); } + }; + img.onerror = () => reject(new Error(t('settings.profilePictureReadError'))); + img.src = reader.result; }; - img.onerror = () => { - URL.revokeObjectURL(objectUrl); - reject(new Error(t('settings.profilePictureReadError'))); - }; - img.src = objectUrl; + reader.onerror = () => reject(new Error(t('settings.profilePictureReadError'))); + reader.readAsDataURL(file); }); } From a1c839d4e7f76ee692aba4e93d28130f5682a8b5 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Mon, 27 Apr 2026 09:09:31 -0300 Subject: [PATCH 4/4] Skip discard prompt after saving member edits --- public/pages/settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/pages/settings.js b/public/pages/settings.js index 06699ae..b1ba929 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -1129,7 +1129,7 @@ function openEditMemberModal(member, currentUser, users, container) { 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(); + closeModal({ force: true }); window.oikos?.showToast(t('settings.memberUpdatedToast', { name: res.user.display_name }), 'success'); render(container, { user: currentUser }); } catch (err) {