diff --git a/public/api.js b/public/api.js
index 4185d07..b41817d 100644
--- a/public/api.js
+++ b/public/api.js
@@ -138,6 +138,8 @@ const auth = {
me: () => api.get('/auth/me'),
getUsers: () => api.get('/auth/users'),
createUser: (data) => api.post('/auth/users', data),
+ updateUser: (id, data) => api.patch(`/auth/users/${id}`, data),
+ updateProfile: (data) => api.patch('/auth/me/profile', data),
deleteUser: (id) => api.delete(`/auth/users/${id}`),
};
diff --git a/public/locales/ar.json b/public/locales/ar.json
index 8bcf591..e965958 100644
--- a/public/locales/ar.json
+++ b/public/locales/ar.json
@@ -625,6 +625,30 @@
"displayNameLabel": "الاسم المعروض",
"memberPasswordLabel": "كلمة المرور",
"colorLabel": "اللون",
+ "profilePictureTitle": "صورة الملف الشخصي",
+ "profilePictureLabel": "تحميل صورة",
+ "profilePictureHint": "PNG أو JPEG أو WebP. يتم تصغير الصور الكبيرة قبل الرفع.",
+ "profilePictureRemove": "إزالة الصورة",
+ "profilePictureTypeError": "استخدم صورة PNG أو JPEG أو WebP.",
+ "profilePictureFileTooLarge": "ملف الصورة كبير جدًا.",
+ "profilePictureTooLarge": "ما زالت صورة الملف الشخصي كبيرة جدًا بعد التصغير.",
+ "profilePictureReadError": "تعذرت قراءة الصورة المحددة.",
+ "profileSavedToast": "تم تحديث الملف الشخصي.",
+ "editMemberLabel": "تعديل",
+ "editMemberTitle": "تعديل فرد العائلة",
+ "saveMember": "حفظ الفرد",
+ "memberUpdatedToast": "تم تحديث {{name}}.",
+ "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 e12c37b..c7ab779 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -650,6 +650,30 @@
"displayNameLabel": "Anzeigename",
"memberPasswordLabel": "Passwort",
"colorLabel": "Farbe",
+ "profilePictureTitle": "Profilbild",
+ "profilePictureLabel": "Bild hochladen",
+ "profilePictureHint": "PNG, JPEG oder WebP. Große Bilder werden vor dem Hochladen verkleinert.",
+ "profilePictureRemove": "Bild entfernen",
+ "profilePictureTypeError": "Bitte ein PNG-, JPEG- oder WebP-Bild verwenden.",
+ "profilePictureFileTooLarge": "Die Bilddatei ist zu groß.",
+ "profilePictureTooLarge": "Das Profilbild ist nach dem Verkleinern noch zu groß.",
+ "profilePictureReadError": "Das ausgewählte Bild konnte nicht gelesen werden.",
+ "profileSavedToast": "Profil aktualisiert.",
+ "editMemberLabel": "Bearbeiten",
+ "editMemberTitle": "Familienmitglied bearbeiten",
+ "saveMember": "Mitglied speichern",
+ "memberUpdatedToast": "{{name}} aktualisiert.",
+ "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 e1c2687..320751b 100644
--- a/public/locales/el.json
+++ b/public/locales/el.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Εμφανιζόμενο όνομα",
"memberPasswordLabel": "Κωδικός",
"colorLabel": "Χρώμα",
+ "profilePictureTitle": "Εικόνα προφίλ",
+ "profilePictureLabel": "Μεταφόρτωση εικόνας",
+ "profilePictureHint": "PNG, JPEG ή WebP. Οι μεγάλες εικόνες αλλάζουν μέγεθος πριν τη μεταφόρτωση.",
+ "profilePictureRemove": "Αφαίρεση εικόνας",
+ "profilePictureTypeError": "Χρησιμοποιήστε εικόνα PNG, JPEG ή WebP.",
+ "profilePictureFileTooLarge": "Το αρχείο εικόνας είναι πολύ μεγάλο.",
+ "profilePictureTooLarge": "Η εικόνα προφίλ παραμένει πολύ μεγάλη μετά την αλλαγή μεγέθους.",
+ "profilePictureReadError": "Δεν ήταν δυνατή η ανάγνωση της επιλεγμένης εικόνας.",
+ "profileSavedToast": "Το προφίλ ενημερώθηκε.",
+ "editMemberLabel": "Επεξεργασία",
+ "editMemberTitle": "Επεξεργασία μέλους οικογένειας",
+ "saveMember": "Αποθήκευση μέλους",
+ "memberUpdatedToast": "Το {{name}} ενημερώθηκε.",
+ "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 2d1210d..d258257 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Display name",
"memberPasswordLabel": "Password",
"colorLabel": "Color",
+ "profilePictureTitle": "Profile picture",
+ "profilePictureLabel": "Upload picture",
+ "profilePictureHint": "PNG, JPEG or WebP. Large images are resized before upload.",
+ "profilePictureRemove": "Remove picture",
+ "profilePictureTypeError": "Use a PNG, JPEG or WebP image.",
+ "profilePictureFileTooLarge": "Image file is too large.",
+ "profilePictureTooLarge": "Profile picture is still too large after resizing.",
+ "profilePictureReadError": "Could not read the selected image.",
+ "profileSavedToast": "Profile updated.",
+ "editMemberLabel": "Edit",
+ "editMemberTitle": "Edit family member",
+ "saveMember": "Save member",
+ "memberUpdatedToast": "{{name}} updated.",
+ "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 bb47d35..550dead 100644
--- a/public/locales/es.json
+++ b/public/locales/es.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Nombre para mostrar",
"memberPasswordLabel": "Contraseña",
"colorLabel": "Color",
+ "profilePictureTitle": "Foto de perfil",
+ "profilePictureLabel": "Subir foto",
+ "profilePictureHint": "PNG, JPEG o WebP. Las imágenes grandes se redimensionan antes de subirlas.",
+ "profilePictureRemove": "Eliminar foto",
+ "profilePictureTypeError": "Usa una imagen PNG, JPEG o WebP.",
+ "profilePictureFileTooLarge": "El archivo de imagen es demasiado grande.",
+ "profilePictureTooLarge": "La foto sigue siendo demasiado grande después de redimensionarla.",
+ "profilePictureReadError": "No se pudo leer la imagen seleccionada.",
+ "profileSavedToast": "Perfil actualizado.",
+ "editMemberLabel": "Editar",
+ "editMemberTitle": "Editar miembro de la familia",
+ "saveMember": "Guardar miembro",
+ "memberUpdatedToast": "{{name}} actualizado.",
+ "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 8c9c827..eda86aa 100644
--- a/public/locales/fr.json
+++ b/public/locales/fr.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Nom affiché",
"memberPasswordLabel": "Mot de passe",
"colorLabel": "Couleur",
+ "profilePictureTitle": "Photo de profil",
+ "profilePictureLabel": "Importer une photo",
+ "profilePictureHint": "PNG, JPEG ou WebP. Les grandes images sont redimensionnées avant l'envoi.",
+ "profilePictureRemove": "Supprimer la photo",
+ "profilePictureTypeError": "Utilisez une image PNG, JPEG ou WebP.",
+ "profilePictureFileTooLarge": "Le fichier image est trop volumineux.",
+ "profilePictureTooLarge": "La photo reste trop volumineuse après redimensionnement.",
+ "profilePictureReadError": "Impossible de lire l'image sélectionnée.",
+ "profileSavedToast": "Profil mis à jour.",
+ "editMemberLabel": "Modifier",
+ "editMemberTitle": "Modifier le membre de la famille",
+ "saveMember": "Enregistrer le membre",
+ "memberUpdatedToast": "{{name}} mis à jour.",
+ "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 9d89235..9581739 100644
--- a/public/locales/hi.json
+++ b/public/locales/hi.json
@@ -625,6 +625,30 @@
"displayNameLabel": "प्रदर्शन नाम",
"memberPasswordLabel": "पासवर्ड",
"colorLabel": "रंग",
+ "profilePictureTitle": "प्रोफ़ाइल चित्र",
+ "profilePictureLabel": "चित्र अपलोड करें",
+ "profilePictureHint": "PNG, JPEG या WebP. बड़े चित्र अपलोड से पहले छोटे किए जाते हैं।",
+ "profilePictureRemove": "चित्र हटाएँ",
+ "profilePictureTypeError": "PNG, JPEG या WebP चित्र इस्तेमाल करें।",
+ "profilePictureFileTooLarge": "चित्र फ़ाइल बहुत बड़ी है।",
+ "profilePictureTooLarge": "आकार बदलने के बाद भी प्रोफ़ाइल चित्र बहुत बड़ा है।",
+ "profilePictureReadError": "चुना गया चित्र पढ़ा नहीं जा सका।",
+ "profileSavedToast": "प्रोफ़ाइल अपडेट हुई।",
+ "editMemberLabel": "संपादित करें",
+ "editMemberTitle": "परिवार सदस्य संपादित करें",
+ "saveMember": "सदस्य सहेजें",
+ "memberUpdatedToast": "{{name}} अपडेट हुआ।",
+ "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 a403b53..045e49e 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Nome visualizzato",
"memberPasswordLabel": "Password",
"colorLabel": "Colore",
+ "profilePictureTitle": "Foto profilo",
+ "profilePictureLabel": "Carica foto",
+ "profilePictureHint": "PNG, JPEG o WebP. Le immagini grandi vengono ridimensionate prima del caricamento.",
+ "profilePictureRemove": "Rimuovi foto",
+ "profilePictureTypeError": "Usa un'immagine PNG, JPEG o WebP.",
+ "profilePictureFileTooLarge": "Il file immagine è troppo grande.",
+ "profilePictureTooLarge": "La foto è ancora troppo grande dopo il ridimensionamento.",
+ "profilePictureReadError": "Impossibile leggere l'immagine selezionata.",
+ "profileSavedToast": "Profilo aggiornato.",
+ "editMemberLabel": "Modifica",
+ "editMemberTitle": "Modifica membro della famiglia",
+ "saveMember": "Salva membro",
+ "memberUpdatedToast": "{{name}} aggiornato.",
+ "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 3924b0f..64aabbe 100644
--- a/public/locales/ja.json
+++ b/public/locales/ja.json
@@ -625,6 +625,30 @@
"displayNameLabel": "表示名",
"memberPasswordLabel": "パスワード",
"colorLabel": "色",
+ "profilePictureTitle": "プロフィール画像",
+ "profilePictureLabel": "画像をアップロード",
+ "profilePictureHint": "PNG、JPEG、WebP。大きな画像はアップロード前に縮小されます。",
+ "profilePictureRemove": "画像を削除",
+ "profilePictureTypeError": "PNG、JPEG、WebP画像を使用してください。",
+ "profilePictureFileTooLarge": "画像ファイルが大きすぎます。",
+ "profilePictureTooLarge": "縮小後もプロフィール画像が大きすぎます。",
+ "profilePictureReadError": "選択した画像を読み込めませんでした。",
+ "profileSavedToast": "プロフィールを更新しました。",
+ "editMemberLabel": "編集",
+ "editMemberTitle": "家族メンバーを編集",
+ "saveMember": "メンバーを保存",
+ "memberUpdatedToast": "{{name}} を更新しました。",
+ "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 ca2eb34..f41b9d7 100644
--- a/public/locales/pt.json
+++ b/public/locales/pt.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Nome de exibição",
"memberPasswordLabel": "Senha",
"colorLabel": "Cor",
+ "profilePictureTitle": "Foto de perfil",
+ "profilePictureLabel": "Enviar foto",
+ "profilePictureHint": "PNG, JPEG ou WebP. Imagens grandes são redimensionadas antes do envio.",
+ "profilePictureRemove": "Remover foto",
+ "profilePictureTypeError": "Use uma imagem PNG, JPEG ou WebP.",
+ "profilePictureFileTooLarge": "O arquivo de imagem é muito grande.",
+ "profilePictureTooLarge": "A foto ainda está muito grande após o redimensionamento.",
+ "profilePictureReadError": "Não foi possível ler a imagem selecionada.",
+ "profileSavedToast": "Perfil atualizado.",
+ "editMemberLabel": "Editar",
+ "editMemberTitle": "Editar membro da família",
+ "saveMember": "Salvar membro",
+ "memberUpdatedToast": "{{name}} atualizado.",
+ "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 abf9e2e..bba83d1 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Отображаемое имя",
"memberPasswordLabel": "Пароль",
"colorLabel": "Цвет",
+ "profilePictureTitle": "Фото профиля",
+ "profilePictureLabel": "Загрузить фото",
+ "profilePictureHint": "PNG, JPEG или WebP. Большие изображения уменьшаются перед загрузкой.",
+ "profilePictureRemove": "Удалить фото",
+ "profilePictureTypeError": "Используйте изображение PNG, JPEG или WebP.",
+ "profilePictureFileTooLarge": "Файл изображения слишком большой.",
+ "profilePictureTooLarge": "Фото профиля всё ещё слишком большое после изменения размера.",
+ "profilePictureReadError": "Не удалось прочитать выбранное изображение.",
+ "profileSavedToast": "Профиль обновлён.",
+ "editMemberLabel": "Изменить",
+ "editMemberTitle": "Изменить члена семьи",
+ "saveMember": "Сохранить участника",
+ "memberUpdatedToast": "{{name}} обновлён.",
+ "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 78816a9..6c351c9 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Visningsnamn",
"memberPasswordLabel": "Lösenord",
"colorLabel": "Färg",
+ "profilePictureTitle": "Profilbild",
+ "profilePictureLabel": "Ladda upp bild",
+ "profilePictureHint": "PNG, JPEG eller WebP. Stora bilder storleksändras före uppladdning.",
+ "profilePictureRemove": "Ta bort bild",
+ "profilePictureTypeError": "Använd en PNG-, JPEG- eller WebP-bild.",
+ "profilePictureFileTooLarge": "Bildfilen är för stor.",
+ "profilePictureTooLarge": "Profilbilden är fortfarande för stor efter storleksändring.",
+ "profilePictureReadError": "Kunde inte läsa den valda bilden.",
+ "profileSavedToast": "Profilen uppdaterades.",
+ "editMemberLabel": "Redigera",
+ "editMemberTitle": "Redigera familjemedlem",
+ "saveMember": "Spara medlem",
+ "memberUpdatedToast": "{{name}} uppdaterad.",
+ "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 dd0a825..7b817f2 100644
--- a/public/locales/tr.json
+++ b/public/locales/tr.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Görünen ad",
"memberPasswordLabel": "Şifre",
"colorLabel": "Renk",
+ "profilePictureTitle": "Profil resmi",
+ "profilePictureLabel": "Resim yükle",
+ "profilePictureHint": "PNG, JPEG veya WebP. Büyük resimler yüklemeden önce yeniden boyutlandırılır.",
+ "profilePictureRemove": "Resmi kaldır",
+ "profilePictureTypeError": "PNG, JPEG veya WebP resmi kullanın.",
+ "profilePictureFileTooLarge": "Resim dosyası çok büyük.",
+ "profilePictureTooLarge": "Profil resmi yeniden boyutlandırmadan sonra hâlâ çok büyük.",
+ "profilePictureReadError": "Seçilen resim okunamadı.",
+ "profileSavedToast": "Profil güncellendi.",
+ "editMemberLabel": "Düzenle",
+ "editMemberTitle": "Aile üyesini düzenle",
+ "saveMember": "Üyeyi kaydet",
+ "memberUpdatedToast": "{{name}} güncellendi.",
+ "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 8e4f123..755fa6d 100644
--- a/public/locales/uk.json
+++ b/public/locales/uk.json
@@ -625,6 +625,30 @@
"displayNameLabel": "Відображуване ім'я",
"memberPasswordLabel": "Пароль",
"colorLabel": "Колір",
+ "profilePictureTitle": "Фото профілю",
+ "profilePictureLabel": "Завантажити фото",
+ "profilePictureHint": "PNG, JPEG або WebP. Великі зображення змінюються в розмірі перед завантаженням.",
+ "profilePictureRemove": "Видалити фото",
+ "profilePictureTypeError": "Використайте зображення PNG, JPEG або WebP.",
+ "profilePictureFileTooLarge": "Файл зображення завеликий.",
+ "profilePictureTooLarge": "Фото профілю все ще завелике після зміни розміру.",
+ "profilePictureReadError": "Не вдалося прочитати вибране зображення.",
+ "profileSavedToast": "Профіль оновлено.",
+ "editMemberLabel": "Редагувати",
+ "editMemberTitle": "Редагувати члена родини",
+ "saveMember": "Зберегти члена",
+ "memberUpdatedToast": "{{name}} оновлено.",
+ "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 cf4d784..0daccb1 100644
--- a/public/locales/zh.json
+++ b/public/locales/zh.json
@@ -625,6 +625,30 @@
"displayNameLabel": "显示名称",
"memberPasswordLabel": "密码",
"colorLabel": "颜色",
+ "profilePictureTitle": "个人头像",
+ "profilePictureLabel": "上传图片",
+ "profilePictureHint": "PNG、JPEG 或 WebP。大图片会在上传前自动缩放。",
+ "profilePictureRemove": "移除图片",
+ "profilePictureTypeError": "请使用 PNG、JPEG 或 WebP 图片。",
+ "profilePictureFileTooLarge": "图片文件太大。",
+ "profilePictureTooLarge": "缩放后头像仍然太大。",
+ "profilePictureReadError": "无法读取所选图片。",
+ "profileSavedToast": "个人资料已更新。",
+ "editMemberLabel": "编辑",
+ "editMemberTitle": "编辑家庭成员",
+ "saveMember": "保存成员",
+ "memberUpdatedToast": "{{name}} 已更新。",
+ "familyRoleLabel": "家庭角色",
+ "familyRoleDad": "爸爸",
+ "familyRoleMom": "妈妈",
+ "familyRoleParent": "父母",
+ "familyRoleChild": "孩子",
+ "familyRoleGrandparent": "祖父母",
+ "familyRoleRelative": "亲属",
+ "familyRoleOther": "家庭成员",
+ "systemAdminLabel": "系统管理员",
+ "systemAdminHint": "系统管理员可以管理应用设置、集成、API 令牌和家庭账户。",
+ "systemAdminBadge": "系统管理员",
"roleLabel": "角色",
"roleMember": "成员",
"roleAdmin": "管理员",
diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js
index 984b6d4..1bdef1b 100644
--- a/public/pages/dashboard.js
+++ b/public/pages/dashboard.js
@@ -456,7 +456,7 @@ function renderFamilyWidget(users) {
const visible = users.slice(0, 6);
const avatars = visible.map((u) => `
- ${esc(initials(u.display_name))}
+ ${u.avatar_data ? `
` : esc(initials(u.display_name))}
`).join('');
diff --git a/public/pages/settings.js b/public/pages/settings.js
index 7d1f6bd..b1ba929 100644
--- a/public/pages/settings.js
+++ b/public/pages/settings.js
@@ -5,7 +5,7 @@
*/
import { api, auth } from '/api.js';
-import { confirmModal } from '/components/modal.js';
+import { openModal, closeModal, confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
@@ -14,6 +14,8 @@ 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 MAX_AVATAR_DATA_LENGTH = 768 * 1024;
const CATEGORY_I18N = {
'Obst & Gemüse': 'shopping.catFruitVeg',
@@ -44,6 +46,92 @@ 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('');
+}
+
+function avatarHtml(user, className = 'settings-avatar') {
+ const safeName = esc(user?.display_name || '');
+ const fallback = esc(initials(user?.display_name || ''));
+ const bg = esc(user?.avatar_color || '#007AFF');
+ return `
+
+ ${user?.avatar_data ? `
})
` : fallback}
+
+ `;
+}
+
+function avatarEditorHtml(user, prefix) {
+ return `
+
+
+ ${avatarHtml(user, 'settings-avatar settings-avatar--lg')}
+
+
+
+
+
${t('settings.profilePictureHint')}
+
+
+
+ `;
+}
+
+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 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
@@ -386,9 +474,7 @@ export async function render(container, { user }) {
-
- ${esc(initials(user?.display_name))}
-
+ ${avatarHtml(user)}
${esc(user?.display_name)}
@${esc(user?.username)}
@@ -396,6 +482,25 @@ export async function render(container, { user }) {
+
+
${t('settings.profilePictureTitle')}
+
+
+
${t('settings.changePassword')}
-
-
+
+
${t('settings.systemAdminHint')}
@@ -507,14 +616,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);
@@ -617,6 +726,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) {
@@ -773,7 +940,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]');
@@ -781,12 +949,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 {
@@ -796,6 +966,7 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
}
bindDeleteButtons(container, user);
+ bindEditButtons(container, user, users);
// Abmelden
const logoutBtn = container.querySelector('#logout-btn');
@@ -858,6 +1029,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: `
+
+ `,
+ 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({ 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')
@@ -1103,13 +1387,18 @@ 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)}
+ ${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
${esc(u.display_name)}
- @${esc(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}
+ @${esc(u.username)} · ${esc(familyRole)}${systemRole}
+
diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css
index 6029bda..61f5649 100644
--- a/public/styles/dashboard.css
+++ b/public/styles/dashboard.css
@@ -2374,12 +2374,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 3d5713a..6129097 100644
--- a/server/auth.js
+++ b/server/auth.js
@@ -16,6 +16,9 @@ import { createLogger } from './logger.js';
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)
@@ -150,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
+ 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 = ?
@@ -175,7 +226,9 @@ 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,
};
return row;
}
@@ -225,7 +278,7 @@ const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#F
/**
* POST /api/v1/auth/login
* Body: { username: string, password: string }
- * Response: { user: { id, username, display_name, avatar_color, role } }
+ * Response: { user: { id, username, display_name, avatar_color, role, family_role } }
*/
router.post('/login', loginLimiter, async (req, res) => {
try {
@@ -276,7 +329,9 @@ 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,
},
csrfToken: req.session.csrfToken,
});
@@ -344,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' },
+ 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')) {
@@ -362,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 FROM users WHERE id = ?')
+ .prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`)
.get(req.authUserId);
if (!user) {
@@ -373,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:
@@ -389,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 });
@@ -404,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, 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 });
@@ -488,12 +543,21 @@ router.delete('/api-tokens/:id', requireAuth, requireAdmin, csrfMiddleware, (req
/**
* POST /api/v1/auth/users
* Admin only. Erstellt neues Familienmitglied.
- * Body: { username, display_name, password, avatar_color?, role? }
+ * Body: { username, display_name, password, avatar_color?, family_role?, system_admin? }
* Response: { user: { id, username, display_name, avatar_color, role } }
*/
router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res) => {
try {
- const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body;
+ const {
+ username,
+ display_name,
+ password,
+ avatar_color = '#007AFF',
+ avatar_data,
+ family_role = 'other',
+ system_admin = req.body.role === 'admin',
+ } = req.body;
+ const role = system_admin === true || system_admin === 'true' ? 'admin' : 'member';
if (!username || !display_name || !password) {
return res.status(400).json({ error: 'Username, display name, and password are required.', code: 400 });
@@ -511,21 +575,28 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 });
}
- if (!['admin', 'member'].includes(role)) {
- return res.status(400).json({ error: 'Invalid role.', code: 400 });
+ if (!FAMILY_ROLES.includes(family_role)) {
+ 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)
- 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);
+ .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 },
+ user: publicUser(createdUser),
});
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
@@ -536,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 ec9386e..4a7aba6 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -15,8 +15,11 @@ 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'
+ CHECK(family_role IN ('dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
diff --git a/server/db.js b/server/db.js
index 17a0295..f6bec60 100644
--- a/server/db.js
+++ b/server/db.js
@@ -717,6 +717,23 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
`,
},
+ {
+ version: 19,
+ description: 'Separate family member role from system access role',
+ up: `
+ ALTER TABLE users ADD COLUMN family_role TEXT NOT NULL DEFAULT 'other'
+ CHECK(family_role IN ('dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'));
+
+ 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/index.js b/server/index.js
index 6b87d77..83f3b66 100644
--- a/server/index.js
+++ b/server/index.js
@@ -31,6 +31,7 @@ import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js';
import remindersRouter from './routes/reminders.js';
import searchRouter from './routes/search.js';
+import familyRouter from './routes/family.js';
const log = createLogger('Server');
const logSync = createLogger('Sync');
@@ -203,6 +204,7 @@ app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter);
app.use('/api/v1/reminders', remindersRouter);
app.use('/api/v1/search', searchRouter);
+app.use('/api/v1/family', familyRouter);
// --------------------------------------------------------
// Health-Check (für Docker)
diff --git a/server/openapi.js b/server/openapi.js
index 6d51f94..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',
@@ -241,6 +257,21 @@ function buildPaths() {
params: [idParam('id', 'API token ID')],
}),
},
+ '/api/v1/family/members': {
+ get: op({
+ summary: 'List family members',
+ tag: 'Family',
+ description: 'Read-only endpoint for family-member profiles. It does not expose usernames or system access roles and does not support create/update/delete operations.',
+ responses: {
+ 200: {
+ description: 'Family members',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/FamilyMembersResponse' } } },
+ },
+ 401: { $ref: '#/components/responses/Unauthorized' },
+ 500: { $ref: '#/components/responses/InternalServerError' },
+ },
+ }),
+ },
'/api/v1/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) },
'/api/v1/tasks': {
get: op({ summary: 'List tasks', tag: 'Tasks' }),
@@ -443,6 +474,7 @@ function buildOpenApiSpec(req, appVersion) {
tags: [
{ name: 'System' },
{ name: 'Auth' },
+ { name: 'Family' },
{ name: 'Dashboard' },
{ name: 'Tasks' },
{ name: 'Shopping' },
@@ -527,9 +559,33 @@ 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'] },
},
- required: ['id', 'username', 'display_name', 'avatar_color', 'role'],
+ required: ['id', 'username', 'display_name', 'avatar_color', 'role', 'family_role'],
+ },
+ FamilyMember: {
+ type: 'object',
+ properties: {
+ 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' },
+ },
+ required: ['id', 'display_name', 'avatar_color', 'family_role'],
+ },
+ FamilyMembersResponse: {
+ type: 'object',
+ properties: {
+ data: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/FamilyMember' },
+ },
+ },
+ required: ['data'],
},
LoginRequest: {
type: 'object',
@@ -579,10 +635,31 @@ function buildOpenApiSpec(req, appVersion) {
display_name: { type: 'string' },
password: { type: 'string' },
avatar_color: { type: 'string' },
- role: { type: 'string', enum: ['admin', 'member'] },
+ 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 4d12b46..e43e9fb 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
new file mode 100644
index 0000000..a5e1018
--- /dev/null
+++ b/server/routes/family.js
@@ -0,0 +1,28 @@
+/**
+ * Module: Family
+ * Purpose: Read-only family member API.
+ * Dependencies: express, server/db.js
+ */
+
+import express from 'express';
+import * as db from '../db.js';
+import { createLogger } from '../logger.js';
+
+const log = createLogger('Family');
+const router = express.Router();
+
+router.get('/members', (req, res) => {
+ try {
+ const members = db.get().prepare(`
+ SELECT id, display_name, avatar_color, avatar_data, family_role, created_at
+ FROM users
+ ORDER BY display_name COLLATE NOCASE ASC
+ `).all();
+ res.json({ data: members });
+ } catch (err) {
+ log.error('GET /members error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
+export default router;