Add member editing and profile pictures

This commit is contained in:
Rafael Foster
2026-04-27 08:09:00 -03:00
parent b82a86c4b3
commit 6e410cb671
26 changed files with 737 additions and 21 deletions
+2
View File
@@ -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}`),
};
+13
View File
@@ -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": "الأم",
+13
View File
@@ -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",
+13
View File
@@ -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": "Μαμά",
+13
View File
@@ -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",
+13
View File
@@ -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á",
+13
View File
@@ -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",
+13
View File
@@ -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": "माँ",
+13
View File
@@ -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",
+13
View File
@@ -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": "母",
+13
View File
@@ -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",
+13
View File
@@ -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": "Мама",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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": "Мама",
+13
View File
@@ -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": "妈妈",
+1 -1
View File
@@ -362,7 +362,7 @@ function renderFamilyWidget(users) {
const visible = users.slice(0, 6);
const avatars = visible.map((u) => `
<span class="family-widget-avatar" style="background:${esc(u.avatar_color || '#64748b')}" title="${esc(u.display_name)}">
${esc(initials(u.display_name))}
${u.avatar_data ? `<img src="${esc(u.avatar_data)}" alt="${esc(u.display_name)}" loading="lazy">` : esc(initials(u.display_name))}
</span>
`).join('');
+279 -7
View File
@@ -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 `
<div class="${className}" style="background:${bg}" title="${safeName}">
${user?.avatar_data ? `<img src="${esc(user.avatar_data)}" alt="${safeName}" loading="lazy">` : fallback}
</div>
`;
}
function avatarEditorHtml(user, prefix) {
return `
<div class="settings-avatar-editor">
<div class="settings-avatar-preview" id="${prefix}-avatar-preview">
${avatarHtml(user, 'settings-avatar settings-avatar--lg')}
</div>
<div class="settings-avatar-editor__controls">
<label class="form-label" for="${prefix}-avatar-file">${t('settings.profilePictureLabel')}</label>
<input class="form-input" type="file" id="${prefix}-avatar-file" accept="image/png,image/jpeg,image/webp" />
<p class="form-hint">${t('settings.profilePictureHint')}</p>
<button type="button" class="btn btn--secondary" id="${prefix}-avatar-remove">${t('settings.profilePictureRemove')}</button>
</div>
</div>
`;
}
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 }) {
<div class="settings-card">
<div class="settings-user-info">
<div class="settings-avatar" style="background:${esc(user?.avatar_color) || '#007AFF'}">
${esc(initials(user?.display_name))}
</div>
${avatarHtml(user)}
<div>
<div class="settings-user-info__name">${esc(user?.display_name)}</div>
<div class="settings-user-info__username">@${esc(user?.username)}</div>
@@ -407,6 +483,25 @@ export async function render(container, { user }) {
</div>
</div>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.profilePictureTitle')}</h3>
<form id="profile-form" class="settings-form">
${avatarEditorHtml(user, 'profile')}
<div class="form-group">
<label class="form-label" for="profile-display-name">${t('settings.displayNameLabel')}</label>
<input class="form-input" type="text" id="profile-display-name" maxlength="128" value="${esc(user?.display_name || '')}" required />
</div>
<div class="form-group">
<label class="form-label" for="profile-avatar-color">${t('settings.colorLabel')}</label>
<input class="form-input form-input--color" type="color" id="profile-avatar-color" value="${esc(user?.avatar_color || '#007AFF')}" />
</div>
<div id="profile-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="submit" class="btn btn--primary">${t('common.save')}</button>
</div>
</form>
</div>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.changePassword')}</h3>
<form id="password-form" class="settings-form">
@@ -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: `
<form id="edit-member-form" class="settings-form">
${avatarEditorHtml(member, 'edit-member')}
<div class="form-group">
<label class="form-label" for="edit-member-username">${t('settings.usernameLabel')}</label>
<input class="form-input" type="text" id="edit-member-username" value="${esc(member.username)}" required autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-display-name">${t('settings.displayNameLabel')}</label>
<input class="form-input" type="text" id="edit-member-display-name" value="${esc(member.display_name)}" required maxlength="128" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-avatar-color">${t('settings.colorLabel')}</label>
<input class="form-input form-input--color" type="color" id="edit-member-avatar-color" value="${esc(member.avatar_color || '#007AFF')}" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-family-role">${t('settings.familyRoleLabel')}</label>
<select class="form-input" id="edit-member-family-role">
${buildFamilyRoleOptions(member.family_role)}
</select>
</div>
<label class="toggle-row">
<input type="checkbox" id="edit-member-system-admin" ${member.role === 'admin' ? 'checked' : ''} />
<span>${t('settings.systemAdminLabel')}</span>
</label>
<p class="form-hint">${t('settings.systemAdminHint')}</p>
<div id="edit-member-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="button" class="btn btn--secondary" id="edit-member-cancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn--primary">${t('settings.saveMember')}</button>
</div>
</form>
`,
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 `
<li class="settings-member" data-id="${u.id}">
<div class="settings-avatar settings-avatar--sm" style="background:${esc(u.avatar_color)}">${initials(u.display_name)}</div>
${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
<div class="settings-member__info">
<span class="settings-member__name">${esc(u.display_name)}</span>
<span class="settings-member__meta">@${esc(u.username)} · ${esc(familyRole)}${systemRole}</span>
</div>
<button class="btn btn--icon btn--secondary" data-edit-user="${u.id}" aria-label="${esc(u.display_name)} ${t('settings.editMemberLabel')}" title="${t('settings.editMemberLabel')}">
<i data-lucide="edit-2" aria-hidden="true"></i>
</button>
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${esc(u.display_name)}" aria-label="${esc(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
<i data-lucide="trash-2" aria-hidden="true"></i>
</button>
+7
View File
@@ -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);
}
+33
View File
@@ -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
-------------------------------------------------------- */
+172 -11
View File
@@ -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.
+1
View File
@@ -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'
+7
View File
@@ -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;
`,
},
];
/**
+38
View File
@@ -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: {
+1 -1
View File
@@ -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 = [];
+1 -1
View File
@@ -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();