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 87a037a..e1ea71b 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -624,6 +624,19 @@ "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": "الأم", diff --git a/public/locales/de.json b/public/locales/de.json index d2e9d0f..9e2bab0 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -630,6 +630,19 @@ "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", diff --git a/public/locales/el.json b/public/locales/el.json index 0c098e5..de33412 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -624,6 +624,19 @@ "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": "Μαμά", diff --git a/public/locales/en.json b/public/locales/en.json index f805ef7..971e582 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -624,6 +624,19 @@ "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", diff --git a/public/locales/es.json b/public/locales/es.json index 9180108..41b71bb 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -624,6 +624,19 @@ "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á", diff --git a/public/locales/fr.json b/public/locales/fr.json index 1a9f6a9..0a1b9a5 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -624,6 +624,19 @@ "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", diff --git a/public/locales/hi.json b/public/locales/hi.json index eabcb6d..6a9a1a5 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -624,6 +624,19 @@ "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": "माँ", diff --git a/public/locales/it.json b/public/locales/it.json index 16d2916..dd5afb9 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -624,6 +624,19 @@ "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", diff --git a/public/locales/ja.json b/public/locales/ja.json index 669c102..47556fa 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -624,6 +624,19 @@ "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": "母", diff --git a/public/locales/pt.json b/public/locales/pt.json index c4861c5..38c6b8b 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -624,6 +624,19 @@ "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", diff --git a/public/locales/ru.json b/public/locales/ru.json index a419b12..2551cd2 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -624,6 +624,19 @@ "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": "Мама", diff --git a/public/locales/sv.json b/public/locales/sv.json index e88c2db..f089563 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -624,6 +624,19 @@ "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", diff --git a/public/locales/tr.json b/public/locales/tr.json index 27d4433..a056580 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -624,6 +624,19 @@ "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", diff --git a/public/locales/uk.json b/public/locales/uk.json index 3d94b75..4529cc6 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -624,6 +624,19 @@ "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": "Мама", diff --git a/public/locales/zh.json b/public/locales/zh.json index 88c1f44..393e4a9 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -624,6 +624,19 @@ "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": "妈妈", diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 8328ddc..e3342b6 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -362,7 +362,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 7ffe88c..c0b2838 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'; @@ -15,6 +15,7 @@ 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', @@ -55,6 +56,83 @@ function buildFamilyRoleOptions(selected = 'other') { `).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 img = new Image(); + const objectUrl = URL.createObjectURL(file); + img.onload = () => { + try { + const maxSize = 512; + const scale = Math.min(1, maxSize / Math.max(img.width, img.height)); + const width = Math.max(1, Math.round(img.width * scale)); + const height = Math.max(1, Math.round(img.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.86); + URL.revokeObjectURL(objectUrl); + if (dataUrl.length > MAX_AVATAR_DATA_LENGTH) { + reject(new Error(t('settings.profilePictureTooLarge'))); + } else { + resolve(dataUrl); + } + } catch (err) { + URL.revokeObjectURL(objectUrl); + reject(err); + } + }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error(t('settings.profilePictureReadError'))); + }; + img.src = objectUrl; + }); +} + /** * @param {HTMLElement} container * @param {{ user: object }} context @@ -397,9 +475,7 @@ export async function render(container, { user }) {
-
- ${esc(initials(user?.display_name))} -
+ ${avatarHtml(user)}
@@ -407,6 +483,25 @@ export async function render(container, { user }) {
+
+

${t('settings.profilePictureTitle')}

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

${t('settings.changePassword')}

@@ -522,14 +617,14 @@ export async function render(container, { user }) { }); } - bindEvents(container, user, categories, icsSubscriptions, apiTokens); + bindEvents(container, user, users, categories, icsSubscriptions, apiTokens); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- -function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { +function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) { bindTabEvents(container); bindCategoryEvents(container); bindIcsEvents(container, user, icsSubscriptions); @@ -632,6 +727,64 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { }); } + const profileState = { avatarData: user?.avatar_data ?? null }; + const profileAvatarFile = container.querySelector('#profile-avatar-file'); + if (profileAvatarFile) { + profileAvatarFile.addEventListener('change', async () => { + const errorEl = container.querySelector('#profile-error'); + errorEl.hidden = true; + try { + const avatarData = await readImageAsDataUrl(profileAvatarFile.files?.[0]); + if (avatarData !== undefined) { + profileState.avatarData = avatarData; + setAvatarPreview(container, '#profile-avatar-preview', { + display_name: container.querySelector('#profile-display-name')?.value || user?.display_name, + avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color, + avatar_data: avatarData, + }); + } + } catch (err) { + profileAvatarFile.value = ''; + showError(errorEl, err.message ?? t('common.errorGeneric')); + } + }); + } + + container.querySelector('#profile-avatar-remove')?.addEventListener('click', () => { + profileState.avatarData = null; + if (profileAvatarFile) profileAvatarFile.value = ''; + setAvatarPreview(container, '#profile-avatar-preview', { + display_name: container.querySelector('#profile-display-name')?.value || user?.display_name, + avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color, + avatar_data: null, + }); + }); + + const profileForm = container.querySelector('#profile-form'); + if (profileForm) { + profileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = container.querySelector('#profile-error'); + const btn = profileForm.querySelector('[type=submit]'); + errorEl.hidden = true; + btn.disabled = true; + try { + const res = await auth.updateProfile({ + display_name: container.querySelector('#profile-display-name').value.trim(), + avatar_color: container.querySelector('#profile-avatar-color').value, + avatar_data: profileState.avatarData, + }); + Object.assign(user, res.user); + window.oikos?.showToast(t('settings.profileSavedToast'), 'success'); + render(container, { user }); + } catch (err) { + showError(errorEl, err.message ?? t('common.errorGeneric')); + } finally { + btn.disabled = false; + } + }); + } + // Passwort ändern const passwordForm = container.querySelector('#password-form'); if (passwordForm) { @@ -797,12 +950,14 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { try { const res = await auth.createUser(data); const list = container.querySelector('#members-list'); + users.push(res.user); list.insertAdjacentHTML('beforeend', memberHtml(res.user)); addMemberForm.reset(); container.querySelector('#add-member-form-card').classList.add('settings-card--hidden'); container.querySelector('#add-member-btn').hidden = false; window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success'); bindDeleteButtons(container, user); + bindEditButtons(container, user, users); } catch (err) { showError(errorEl, err.message); } finally { @@ -812,6 +967,7 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { } bindDeleteButtons(container, user); + bindEditButtons(container, user, users); // Abmelden const logoutBtn = container.querySelector('#logout-btn'); @@ -874,6 +1030,119 @@ function bindDeleteButtons(container, user) { }); } +function bindEditButtons(container, currentUser, users) { + container.querySelectorAll('[data-edit-user]').forEach((btn) => { + btn.replaceWith(btn.cloneNode(true)); + }); + container.querySelectorAll('[data-edit-user]').forEach((btn) => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.editUser, 10); + const member = users.find((u) => u.id === id); + if (member) openEditMemberModal(member, currentUser, users, container); + }); + }); +} + +function openEditMemberModal(member, currentUser, users, container) { + const state = { avatarData: member.avatar_data ?? null }; + openModal({ + title: t('settings.editMemberTitle'), + size: 'md', + content: ` + + ${avatarEditorHtml(member, 'edit-member')} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

${t('settings.systemAdminHint')}

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