Sync family members with contacts and birthdays
This commit is contained in:
@@ -730,7 +730,14 @@
|
||||
"private": "خاص",
|
||||
"shared": "مشترك"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
|
||||
|
||||
@@ -755,7 +755,14 @@
|
||||
"addedToast": "Abonnement hinzugefügt.",
|
||||
"syncedToast": "Abonnement synchronisiert.",
|
||||
"deletedToast": "Abonnement gelöscht."
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Telefonnummer (optional)",
|
||||
"memberEmailLabel": "E-Mail (optional)",
|
||||
"memberBirthDateLabel": "Geburtstag (optional)",
|
||||
"memberContactBirthdayHint": "Dieses Mitglied wird automatisch mit Kontakten und Geburtstagen synchronisiert.",
|
||||
"memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.",
|
||||
"memberPhoneMeta": "Telefon: {{value}}",
|
||||
"memberBirthdayMeta": "Geburtstag: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Ιδιωτικό",
|
||||
"shared": "Κοινόχρηστο"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Private",
|
||||
"shared": "Shared"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Privado",
|
||||
"shared": "Compartido"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Planificación familiar. Segura. Privada. Código abierto.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Privé",
|
||||
"shared": "Partagé"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "निजी",
|
||||
"shared": "साझा"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Privato",
|
||||
"shared": "Condiviso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "プライベート",
|
||||
"shared": "共有"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "家族計画。安全。プライバシー重視。オープンソース。",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Privado",
|
||||
"shared": "Partilhado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Telefone (opcional)",
|
||||
"memberEmailLabel": "E-mail (opcional)",
|
||||
"memberBirthDateLabel": "Data de aniversário (opcional)",
|
||||
"memberContactBirthdayHint": "Este membro é sincronizado automaticamente com Contatos e Aniversários.",
|
||||
"memberBirthDateInvalid": "Use uma data de aniversário válida no formato selecionado.",
|
||||
"memberPhoneMeta": "Telefone: {{value}}",
|
||||
"memberBirthdayMeta": "Aniversário: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Planejamento familiar. Seguro. Privado. Código aberto.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Личное",
|
||||
"shared": "Общее"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Privat",
|
||||
"shared": "Delad"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Özel",
|
||||
"shared": "Paylaşımlı"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "Приватне",
|
||||
"shared": "Спільне"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
|
||||
|
||||
@@ -730,7 +730,14 @@
|
||||
"private": "私人",
|
||||
"shared": "共享"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberPhoneLabel": "Phone number (optional)",
|
||||
"memberEmailLabel": "Email (optional)",
|
||||
"memberBirthDateLabel": "Birthday date (optional)",
|
||||
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
|
||||
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
|
||||
"memberPhoneMeta": "Phone: {{value}}",
|
||||
"memberBirthdayMeta": "Birthday: {{date}}"
|
||||
},
|
||||
"login": {
|
||||
"tagline": "家庭规划。安全。注重隐私。开源。",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { api, auth } from '/api.js';
|
||||
import { openModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { t, formatDate, formatTime } from '/i18n.js';
|
||||
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
import '/components/oikos-locale-picker.js';
|
||||
|
||||
@@ -56,6 +56,15 @@ function buildFamilyRoleOptions(selected = 'other') {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function bindSettingsDateInputs(root) {
|
||||
root.querySelectorAll('.js-date-input').forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDateInput(input.value);
|
||||
if (parsed) input.value = formatDateInput(parsed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function avatarHtml(user, className = 'settings-avatar') {
|
||||
const safeName = esc(user?.display_name || '');
|
||||
const fallback = esc(initials(user?.display_name || ''));
|
||||
@@ -586,6 +595,21 @@ export async function render(container, { user }) {
|
||||
${buildFamilyRoleOptions()}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-member-phone">${t('settings.memberPhoneLabel')}</label>
|
||||
<input class="form-input" type="tel" id="new-member-phone" autocomplete="tel" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-member-email">${t('settings.memberEmailLabel')}</label>
|
||||
<input class="form-input" type="email" id="new-member-email" autocomplete="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-member-birth-date">${t('settings.memberBirthDateLabel')}</label>
|
||||
<input class="form-input js-date-input" type="text" id="new-member-birth-date" placeholder="${dateInputPlaceholder()}" inputmode="numeric" />
|
||||
<p class="form-hint">${t('settings.memberContactBirthdayHint')}</p>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="new-system-admin" />
|
||||
<span>${t('settings.systemAdminLabel')}</span>
|
||||
@@ -625,6 +649,7 @@ export async function render(container, { user }) {
|
||||
|
||||
function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
|
||||
bindTabEvents(container);
|
||||
bindSettingsDateInputs(container);
|
||||
bindCategoryEvents(container);
|
||||
bindIcsEvents(container, user, icsSubscriptions);
|
||||
bindApiTokenEvents(container, apiTokens);
|
||||
@@ -934,6 +959,11 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
||||
e.preventDefault();
|
||||
const errorEl = container.querySelector('#member-error');
|
||||
errorEl.hidden = true;
|
||||
const birthDateRaw = container.querySelector('#new-member-birth-date')?.value || '';
|
||||
if (!isDateInputValid(birthDateRaw)) {
|
||||
showError(errorEl, t('settings.memberBirthDateInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: container.querySelector('#new-username').value.trim(),
|
||||
@@ -942,6 +972,9 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
||||
avatar_color: container.querySelector('#new-avatar-color').value,
|
||||
family_role: container.querySelector('#new-family-role').value,
|
||||
system_admin: container.querySelector('#new-system-admin')?.checked === true,
|
||||
phone: container.querySelector('#new-member-phone')?.value.trim() || null,
|
||||
email: container.querySelector('#new-member-email')?.value.trim() || null,
|
||||
birth_date: parseDateInput(birthDateRaw) || null,
|
||||
};
|
||||
|
||||
const btn = addMemberForm.querySelector('[type=submit]');
|
||||
@@ -1068,6 +1101,21 @@ function openEditMemberModal(member, currentUser, users, container) {
|
||||
${buildFamilyRoleOptions(member.family_role)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="edit-member-phone">${t('settings.memberPhoneLabel')}</label>
|
||||
<input class="form-input" type="tel" id="edit-member-phone" value="${esc(member.phone || '')}" autocomplete="tel" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="edit-member-email">${t('settings.memberEmailLabel')}</label>
|
||||
<input class="form-input" type="email" id="edit-member-email" value="${esc(member.email || '')}" autocomplete="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="edit-member-birth-date">${t('settings.memberBirthDateLabel')}</label>
|
||||
<input class="form-input js-date-input" type="text" id="edit-member-birth-date" value="${esc(formatDateInput(member.birth_date))}" placeholder="${dateInputPlaceholder()}" inputmode="numeric" />
|
||||
<p class="form-hint">${t('settings.memberContactBirthdayHint')}</p>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="edit-member-system-admin" ${member.role === 'admin' ? 'checked' : ''} />
|
||||
<span>${t('settings.systemAdminLabel')}</span>
|
||||
@@ -1083,6 +1131,7 @@ function openEditMemberModal(member, currentUser, users, container) {
|
||||
onSave(panel) {
|
||||
const fileInput = panel.querySelector('#edit-member-avatar-file');
|
||||
const errorEl = panel.querySelector('#edit-member-error');
|
||||
bindSettingsDateInputs(panel);
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
errorEl.hidden = true;
|
||||
try {
|
||||
@@ -1116,6 +1165,12 @@ function openEditMemberModal(member, currentUser, users, container) {
|
||||
e.preventDefault();
|
||||
const submitBtn = panel.querySelector('[type=submit]');
|
||||
errorEl.hidden = true;
|
||||
const birthDateRaw = panel.querySelector('#edit-member-birth-date')?.value || '';
|
||||
if (!isDateInputValid(birthDateRaw)) {
|
||||
showError(errorEl, t('settings.memberBirthDateInvalid'));
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const res = await auth.updateUser(member.id, {
|
||||
@@ -1125,6 +1180,9 @@ function openEditMemberModal(member, currentUser, users, container) {
|
||||
avatar_data: state.avatarData,
|
||||
family_role: panel.querySelector('#edit-member-family-role').value,
|
||||
system_admin: panel.querySelector('#edit-member-system-admin').checked,
|
||||
phone: panel.querySelector('#edit-member-phone')?.value.trim() || null,
|
||||
email: panel.querySelector('#edit-member-email')?.value.trim() || null,
|
||||
birth_date: parseDateInput(birthDateRaw) || null,
|
||||
});
|
||||
const idx = users.findIndex((u) => u.id === member.id);
|
||||
if (idx !== -1) users[idx] = res.user;
|
||||
@@ -1389,12 +1447,18 @@ function bindCategoryEvents(container) {
|
||||
function memberHtml(u) {
|
||||
const familyRole = familyRoleLabel(u.family_role);
|
||||
const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : '';
|
||||
const profileMeta = [
|
||||
u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
|
||||
u.email || '',
|
||||
u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
|
||||
].filter(Boolean).map(esc).join(' · ');
|
||||
return `
|
||||
<li class="settings-member" data-id="${u.id}">
|
||||
${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>
|
||||
${profileMeta ? `<span class="settings-member__meta">${profileMeta}</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>
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@
|
||||
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
||||
*/
|
||||
|
||||
const SHELL_CACHE = 'oikos-shell-v59';
|
||||
const PAGES_CACHE = 'oikos-pages-v54';
|
||||
const LOCALES_CACHE = 'oikos-locales-v5';
|
||||
const ASSETS_CACHE = 'oikos-assets-v54';
|
||||
const SHELL_CACHE = 'oikos-shell-v60';
|
||||
const PAGES_CACHE = 'oikos-pages-v55';
|
||||
const LOCALES_CACHE = 'oikos-locales-v6';
|
||||
const ASSETS_CACHE = 'oikos-assets-v55';
|
||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
|
||||
|
||||
|
||||
+176
-22
@@ -11,14 +11,28 @@ import rateLimit from 'express-rate-limit';
|
||||
import crypto from 'node:crypto';
|
||||
import * as db from './db.js';
|
||||
import { generateToken, csrfMiddleware } from './middleware/csrf.js';
|
||||
import { collectErrors, date as validateDate, str, MAX_SHORT, MAX_TITLE } from './middleware/validate.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import { deleteBirthdayArtifacts, syncBirthdayArtifacts } from './services/birthdays.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';
|
||||
const USER_PUBLIC_COLUMNS = `
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
avatar_color,
|
||||
avatar_data,
|
||||
role,
|
||||
family_role,
|
||||
created_at,
|
||||
(SELECT phone FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS phone,
|
||||
(SELECT email FROM contacts WHERE contacts.family_user_id = users.id LIMIT 1) AS email,
|
||||
(SELECT birth_date FROM birthdays WHERE birthdays.family_user_id = users.id LIMIT 1) AS birth_date
|
||||
`;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
|
||||
@@ -162,10 +176,101 @@ function publicUser(row) {
|
||||
avatar_data: row.avatar_data ?? null,
|
||||
role: row.role,
|
||||
family_role: row.family_role,
|
||||
phone: row.phone ?? null,
|
||||
email: row.email ?? null,
|
||||
birth_date: row.birth_date ?? null,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMemberProfileFields(body) {
|
||||
const vPhone = body.phone !== undefined
|
||||
? str(body.phone, 'Phone number', { max: MAX_SHORT, required: false })
|
||||
: { value: undefined, error: null };
|
||||
const vEmail = body.email !== undefined
|
||||
? str(body.email, 'Email', { max: MAX_TITLE, required: false })
|
||||
: { value: undefined, error: null };
|
||||
const vBirthDate = body.birth_date !== undefined
|
||||
? validateDate(body.birth_date, 'Birthday date')
|
||||
: { value: undefined, error: null };
|
||||
return {
|
||||
values: {
|
||||
phone: vPhone.value,
|
||||
email: vEmail.value,
|
||||
birth_date: vBirthDate.value,
|
||||
},
|
||||
errors: collectErrors([vPhone, vEmail, vBirthDate]),
|
||||
};
|
||||
}
|
||||
|
||||
function syncFamilyMemberArtifacts(database, userId, {
|
||||
displayName,
|
||||
phone = undefined,
|
||||
email = undefined,
|
||||
birthDate = undefined,
|
||||
avatarData = undefined,
|
||||
actorUserId,
|
||||
} = {}) {
|
||||
const user = database.prepare('SELECT id, display_name, avatar_data FROM users WHERE id = ?').get(userId);
|
||||
if (!user) return;
|
||||
const name = displayName || user.display_name;
|
||||
const photo = avatarData !== undefined ? avatarData : user.avatar_data;
|
||||
|
||||
const contact = database.prepare('SELECT * FROM contacts WHERE family_user_id = ?').get(userId);
|
||||
if (contact) {
|
||||
database.prepare(`
|
||||
UPDATE contacts
|
||||
SET name = ?,
|
||||
category = COALESCE(category, 'Sonstiges'),
|
||||
phone = ?,
|
||||
email = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name,
|
||||
phone !== undefined ? phone : contact.phone,
|
||||
email !== undefined ? email : contact.email,
|
||||
contact.id,
|
||||
);
|
||||
} else {
|
||||
database.prepare(`
|
||||
INSERT INTO contacts (name, category, phone, email, family_user_id)
|
||||
VALUES (?, 'Sonstiges', ?, ?, ?)
|
||||
`).run(name, phone ?? null, email ?? null, userId);
|
||||
}
|
||||
|
||||
const birthday = database.prepare('SELECT * FROM birthdays WHERE family_user_id = ?').get(userId);
|
||||
if (birthDate === null) {
|
||||
if (birthday) {
|
||||
deleteBirthdayArtifacts(database, birthday);
|
||||
database.prepare('DELETE FROM birthdays WHERE id = ?').run(birthday.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (birthday) {
|
||||
database.prepare(`
|
||||
UPDATE birthdays
|
||||
SET name = ?,
|
||||
birth_date = COALESCE(?, birth_date),
|
||||
photo_data = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
`).run(name, birthDate ?? null, photo ?? null, birthday.id);
|
||||
const updated = database.prepare('SELECT * FROM birthdays WHERE id = ?').get(birthday.id);
|
||||
syncBirthdayArtifacts(database, updated);
|
||||
return;
|
||||
}
|
||||
|
||||
if (birthDate) {
|
||||
const result = database.prepare(`
|
||||
INSERT INTO birthdays (name, birth_date, photo_data, created_by, family_user_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(name, birthDate, photo ?? null, actorUserId || userId, userId);
|
||||
const created = database.prepare('SELECT * FROM birthdays WHERE id = ?').get(result.lastInsertRowid);
|
||||
syncBirthdayArtifacts(database, created);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAvatarData(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
@@ -394,12 +499,20 @@ router.post('/setup', loginLimiter, async (req, res) => {
|
||||
const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)];
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
const result = db.get()
|
||||
.prepare('INSERT INTO users (username, display_name, password_hash, avatar_color, role) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(username, display_name, hash, avatarColor, 'admin');
|
||||
const result = db.transaction(() => {
|
||||
const created = db.get()
|
||||
.prepare('INSERT INTO users (username, display_name, password_hash, avatar_color, role) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(username, display_name, hash, avatarColor, 'admin');
|
||||
syncFamilyMemberArtifacts(db.get(), created.lastInsertRowid, {
|
||||
displayName: display_name,
|
||||
actorUserId: created.lastInsertRowid,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
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: avatarColor, avatar_data: null, role: 'admin', family_role: 'other' },
|
||||
user: publicUser(createdUser),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message?.includes('UNIQUE constraint')) {
|
||||
@@ -583,15 +696,30 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
|
||||
if (normalizedAvatarData?.error) {
|
||||
return res.status(400).json({ error: normalizedAvatarData.error, code: 400 });
|
||||
}
|
||||
const memberFields = validateMemberProfileFields(req.body);
|
||||
if (memberFields.errors.length) {
|
||||
return res.status(400).json({ error: memberFields.errors.join(' '), code: 400 });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
const result = db.get()
|
||||
.prepare(`
|
||||
INSERT INTO users (username, display_name, password_hash, avatar_color, avatar_data, role, family_role)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(username, display_name, hash, avatar_color, normalizedAvatarData ?? null, role, family_role);
|
||||
const result = db.transaction(() => {
|
||||
const created = db.get()
|
||||
.prepare(`
|
||||
INSERT INTO users (username, display_name, password_hash, avatar_color, avatar_data, role, family_role)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(username, display_name, hash, avatar_color, normalizedAvatarData ?? null, role, family_role);
|
||||
syncFamilyMemberArtifacts(db.get(), created.lastInsertRowid, {
|
||||
displayName: display_name,
|
||||
phone: memberFields.values.phone,
|
||||
email: memberFields.values.email,
|
||||
birthDate: memberFields.values.birth_date,
|
||||
avatarData: normalizedAvatarData ?? null,
|
||||
actorUserId: req.authUserId,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
const createdUser = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(result.lastInsertRowid);
|
||||
|
||||
@@ -645,15 +773,30 @@ router.patch('/users/:id', requireAuth, requireAdmin, csrfMiddleware, async (req
|
||||
if (avatarData?.error) {
|
||||
return res.status(400).json({ error: avatarData.error, code: 400 });
|
||||
}
|
||||
const memberFields = validateMemberProfileFields(req.body);
|
||||
if (memberFields.errors.length) {
|
||||
return res.status(400).json({ error: memberFields.errors.join(' '), 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);
|
||||
db.transaction(() => {
|
||||
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);
|
||||
|
||||
syncFamilyMemberArtifacts(db.get(), userId, {
|
||||
displayName,
|
||||
phone: memberFields.values.phone,
|
||||
email: memberFields.values.email,
|
||||
birthDate: memberFields.values.birth_date,
|
||||
avatarData: avatarData ?? null,
|
||||
actorUserId: req.authUserId,
|
||||
});
|
||||
});
|
||||
|
||||
if (nextRole !== existing.role) {
|
||||
updateUserRoleSessions(userId, nextRole);
|
||||
@@ -694,11 +837,18 @@ router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => {
|
||||
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);
|
||||
db.transaction(() => {
|
||||
db.get().prepare(`
|
||||
UPDATE users
|
||||
SET display_name = ?, avatar_color = ?, avatar_data = ?
|
||||
WHERE id = ?
|
||||
`).run(displayName, avatarColor || '#007AFF', avatarData ?? null, req.authUserId);
|
||||
syncFamilyMemberArtifacts(db.get(), req.authUserId, {
|
||||
displayName,
|
||||
avatarData: avatarData ?? null,
|
||||
actorUserId: req.authUserId,
|
||||
});
|
||||
});
|
||||
|
||||
const updated = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(req.authUserId);
|
||||
res.json({ user: publicUser(updated) });
|
||||
@@ -767,7 +917,11 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
|
||||
return res.status(400).json({ error: 'You cannot delete your own account.', code: 400 });
|
||||
}
|
||||
|
||||
const result = db.get().prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
const result = db.transaction(() => {
|
||||
const birthday = db.get().prepare('SELECT * FROM birthdays WHERE family_user_id = ?').get(userId);
|
||||
if (birthday) deleteBirthdayArtifacts(db.get(), birthday);
|
||||
return db.get().prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
});
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'User not found.', code: 404 });
|
||||
|
||||
@@ -330,6 +330,22 @@ const MIGRATIONS_SQL = {
|
||||
15: `
|
||||
UPDATE calendar_events SET icon = 'drill' WHERE icon = 'tooth';
|
||||
`,
|
||||
16: `
|
||||
ALTER TABLE contacts ADD COLUMN family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_family_user
|
||||
ON contacts(family_user_id) WHERE family_user_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE birthdays ADD COLUMN family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birthdays_family_user
|
||||
ON birthdays(family_user_id) WHERE family_user_id IS NOT NULL;
|
||||
|
||||
INSERT INTO contacts (name, category, family_user_id)
|
||||
SELECT display_name, 'Sonstiges', id
|
||||
FROM users
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM contacts WHERE contacts.family_user_id = users.id
|
||||
);
|
||||
`,
|
||||
};
|
||||
|
||||
export { MIGRATIONS_SQL };
|
||||
|
||||
@@ -748,6 +748,26 @@ const MIGRATIONS = [
|
||||
UPDATE calendar_events SET icon = 'drill' WHERE icon = 'tooth';
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 23,
|
||||
description: 'Link family members with contacts and birthdays',
|
||||
up: `
|
||||
ALTER TABLE contacts ADD COLUMN family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_family_user
|
||||
ON contacts(family_user_id) WHERE family_user_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE birthdays ADD COLUMN family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birthdays_family_user
|
||||
ON birthdays(family_user_id) WHERE family_user_id IS NOT NULL;
|
||||
|
||||
INSERT INTO contacts (name, category, family_user_id)
|
||||
SELECT display_name, 'Sonstiges', id
|
||||
FROM users
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM contacts WHERE contacts.family_user_id = users.id
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -562,6 +562,9 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
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'] },
|
||||
phone: { type: ['string', 'null'] },
|
||||
email: { type: ['string', 'null'] },
|
||||
birth_date: { type: ['string', 'null'], format: 'date' },
|
||||
},
|
||||
required: ['id', 'username', 'display_name', 'avatar_color', 'role', 'family_role'],
|
||||
},
|
||||
@@ -573,6 +576,9 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
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'] },
|
||||
phone: { type: ['string', 'null'] },
|
||||
email: { type: ['string', 'null'] },
|
||||
birth_date: { type: ['string', 'null'], format: 'date' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'display_name', 'avatar_color', 'family_role'],
|
||||
@@ -638,6 +644,9 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
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' },
|
||||
phone: { type: ['string', 'null'] },
|
||||
email: { type: ['string', 'null'] },
|
||||
birth_date: { type: ['string', 'null'], format: 'date' },
|
||||
},
|
||||
required: ['username', 'display_name', 'password'],
|
||||
},
|
||||
@@ -650,6 +659,9 @@ function buildOpenApiSpec(req, appVersion) {
|
||||
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' },
|
||||
phone: { type: ['string', 'null'] },
|
||||
email: { type: ['string', 'null'] },
|
||||
birth_date: { type: ['string', 'null'], format: 'date' },
|
||||
},
|
||||
},
|
||||
ProfileUpdateRequest: {
|
||||
|
||||
+13
-3
@@ -14,9 +14,19 @@ 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
|
||||
SELECT u.id,
|
||||
u.display_name,
|
||||
u.avatar_color,
|
||||
u.avatar_data,
|
||||
u.family_role,
|
||||
c.phone,
|
||||
c.email,
|
||||
b.birth_date,
|
||||
u.created_at
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.family_user_id = u.id
|
||||
LEFT JOIN birthdays b ON b.family_user_id = u.id
|
||||
ORDER BY u.display_name COLLATE NOCASE ASC
|
||||
`).all();
|
||||
res.json({ data: members });
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user