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(u.display_name)}` : 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 ? `${safeName}` : 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)}
@@ -396,6 +482,25 @@ export async function render(container, { user }) {
+
+

${t('settings.profilePictureTitle')}

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

${t('settings.changePassword')}

@@ -476,12 +581,16 @@ export async function render(container, { user }) {
- - + ${buildFamilyRoleOptions()}
+ +

${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: ` + + ${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({ 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;