Merge pull request #99 from rafaelfoster/main
This PR improves family/account management and calendar event customization.
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ data/
|
||||
*.swp
|
||||
*.swo
|
||||
.codex
|
||||
|
||||
AGENTS.md
|
||||
|
||||
# Claude Code — share skills/agents/rules/hooks/settings; keep local permissions and worktrees out
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else if (stored === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
// System/null: tokens.css @media (prefers-color-scheme: dark) übernimmt
|
||||
})();
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "الكل",
|
||||
"unknownError": "خطأ غير معروف",
|
||||
"confirm": "تأكيد",
|
||||
"undo": "تراجع"
|
||||
"undo": "تراجع",
|
||||
"reset": "إعادة التعيين للأصل"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "لوحة التحكم",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "الميزانية",
|
||||
"tabShopping": "التسوق",
|
||||
"tabCalendar": "التقويم",
|
||||
"tabFamily": "إدارة العائلة",
|
||||
"tabApiTokens": "رموز API",
|
||||
"tabAccount": "الحساب",
|
||||
"tabsAriaLabel": "أقسام الإعدادات",
|
||||
"sectionDesign": "التصميم",
|
||||
@@ -730,7 +733,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": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Alle",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"confirm": "Bestätigen",
|
||||
"undo": "Rückgängig"
|
||||
"undo": "Rückgängig",
|
||||
"reset": "Auf Original zurücksetzen"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Übersicht",
|
||||
@@ -575,6 +576,8 @@
|
||||
"tabBudget": "Budget",
|
||||
"tabShopping": "Einkauf",
|
||||
"tabCalendar": "Kalender",
|
||||
"tabFamily": "Familienverwaltung",
|
||||
"tabApiTokens": "API-Tokens",
|
||||
"tabAccount": "Konto",
|
||||
"tabsAriaLabel": "Einstellungsbereiche",
|
||||
"sectionDesign": "Design",
|
||||
@@ -755,7 +758,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Όλα",
|
||||
"unknownError": "Άγνωστο σφάλμα",
|
||||
"confirm": "Επιβεβαίωση",
|
||||
"undo": "Αναίρεση"
|
||||
"undo": "Αναίρεση",
|
||||
"reset": "Επαναφορά στο αρχικό"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Επισκόπηση",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Προϋπολογισμός",
|
||||
"tabShopping": "Αγορές",
|
||||
"tabCalendar": "Ημερολόγιο",
|
||||
"tabFamily": "Διαχείριση οικογένειας",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Λογαριασμός",
|
||||
"tabsAriaLabel": "Τμήματα ρυθμίσεων",
|
||||
"sectionDesign": "Εμφάνιση",
|
||||
@@ -730,7 +733,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": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "All",
|
||||
"unknownError": "Unknown error",
|
||||
"confirm": "Confirm",
|
||||
"undo": "Undo"
|
||||
"undo": "Undo",
|
||||
"reset": "Reset to original"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Overview",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Budget",
|
||||
"tabShopping": "Shopping",
|
||||
"tabCalendar": "Calendar",
|
||||
"tabFamily": "Family Management",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Account",
|
||||
"tabsAriaLabel": "Settings sections",
|
||||
"sectionDesign": "Appearance",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Todo",
|
||||
"unknownError": "Error desconocido",
|
||||
"confirm": "Confirmar",
|
||||
"undo": "Deshacer"
|
||||
"undo": "Deshacer",
|
||||
"reset": "Restaurar original"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Inicio",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Presupuesto",
|
||||
"tabShopping": "Compras",
|
||||
"tabCalendar": "Calendario",
|
||||
"tabFamily": "Gestión familiar",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Cuenta",
|
||||
"tabsAriaLabel": "Secciones de configuración",
|
||||
"sectionDesign": "Diseño",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Tout",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"confirm": "Confirmer",
|
||||
"undo": "Annuler"
|
||||
"undo": "Annuler",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Accueil",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Budget",
|
||||
"tabShopping": "Courses",
|
||||
"tabCalendar": "Calendrier",
|
||||
"tabFamily": "Gestion familiale",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Compte",
|
||||
"tabsAriaLabel": "Sections des paramètres",
|
||||
"sectionDesign": "Apparence",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "सभी",
|
||||
"unknownError": "अज्ञात त्रुटि",
|
||||
"confirm": "पुष्टि करें",
|
||||
"undo": "पूर्ववत करें"
|
||||
"undo": "पूर्ववत करें",
|
||||
"reset": "मूल पर वापस जाएं"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "डैशबोर्ड",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "बजट",
|
||||
"tabShopping": "खरीदारी",
|
||||
"tabCalendar": "कैलेंडर",
|
||||
"tabFamily": "परिवार प्रबंधन",
|
||||
"tabApiTokens": "API टोकन",
|
||||
"tabAccount": "खाता",
|
||||
"tabsAriaLabel": "सेटिंग्स अनुभाग",
|
||||
"sectionDesign": "डिज़ाइन",
|
||||
@@ -730,7 +733,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": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Tutto",
|
||||
"unknownError": "Errore sconosciuto",
|
||||
"confirm": "Conferma",
|
||||
"undo": "Annulla"
|
||||
"undo": "Annulla",
|
||||
"reset": "Ripristina originale"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panoramica",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Budget",
|
||||
"tabShopping": "Spesa",
|
||||
"tabCalendar": "Calendario",
|
||||
"tabFamily": "Gestione famiglia",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Account",
|
||||
"tabsAriaLabel": "Sezioni impostazioni",
|
||||
"sectionDesign": "Aspetto",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "すべて",
|
||||
"unknownError": "不明なエラー",
|
||||
"confirm": "確認",
|
||||
"undo": "元に戻す"
|
||||
"undo": "元に戻す",
|
||||
"reset": "元に戻す"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "ダッシュボード",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "家計",
|
||||
"tabShopping": "買い物",
|
||||
"tabCalendar": "カレンダー",
|
||||
"tabFamily": "家族管理",
|
||||
"tabApiTokens": "APIトークン",
|
||||
"tabAccount": "アカウント",
|
||||
"tabsAriaLabel": "設定カテゴリー",
|
||||
"sectionDesign": "デザイン",
|
||||
@@ -730,7 +733,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": "家族計画。安全。プライバシー重視。オープンソース。",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Todos",
|
||||
"unknownError": "Erro desconhecido",
|
||||
"confirm": "Confirmar",
|
||||
"undo": "Desfazer"
|
||||
"undo": "Desfazer",
|
||||
"reset": "Restaurar original"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Painel",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Orçamento",
|
||||
"tabShopping": "Compras",
|
||||
"tabCalendar": "Calendário",
|
||||
"tabFamily": "Gestão da família",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Conta",
|
||||
"tabsAriaLabel": "Seções de configurações",
|
||||
"sectionDesign": "Design",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Все",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"confirm": "Подтвердить",
|
||||
"undo": "Отменить"
|
||||
"undo": "Отменить",
|
||||
"reset": "Сбросить к исходному"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Обзор",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Бюджет",
|
||||
"tabShopping": "Покупки",
|
||||
"tabCalendar": "Календарь",
|
||||
"tabFamily": "Управление семьей",
|
||||
"tabApiTokens": "API-токены",
|
||||
"tabAccount": "Аккаунт",
|
||||
"tabsAriaLabel": "Разделы настроек",
|
||||
"sectionDesign": "Внешний вид",
|
||||
@@ -730,7 +733,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": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Alla",
|
||||
"unknownError": "Okänt fel",
|
||||
"confirm": "Bekräfta",
|
||||
"undo": "Ångra"
|
||||
"undo": "Ångra",
|
||||
"reset": "Återställ till original"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Översikt",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Budget",
|
||||
"tabShopping": "Inköp",
|
||||
"tabCalendar": "Kalender",
|
||||
"tabFamily": "Familjehantering",
|
||||
"tabApiTokens": "API Tokens",
|
||||
"tabAccount": "Konto",
|
||||
"tabsAriaLabel": "Inställningsavsnitt",
|
||||
"sectionDesign": "Utseende",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Tümü",
|
||||
"unknownError": "Bilinmeyen hata",
|
||||
"confirm": "Onayla",
|
||||
"undo": "Geri al"
|
||||
"undo": "Geri al",
|
||||
"reset": "Orijinale sıfırla"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Genel Bakış",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Bütçe",
|
||||
"tabShopping": "Alışveriş",
|
||||
"tabCalendar": "Takvim",
|
||||
"tabFamily": "Aile Yönetimi",
|
||||
"tabApiTokens": "API Tokenları",
|
||||
"tabAccount": "Hesap",
|
||||
"tabsAriaLabel": "Ayar bölümleri",
|
||||
"sectionDesign": "Görünüm",
|
||||
@@ -730,7 +733,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.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "Усі",
|
||||
"unknownError": "Невідома помилка",
|
||||
"confirm": "Підтвердити",
|
||||
"undo": "Скасувати"
|
||||
"undo": "Скасувати",
|
||||
"reset": "Скинути до оригіналу"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Огляд",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "Бюджет",
|
||||
"tabShopping": "Покупки",
|
||||
"tabCalendar": "Календар",
|
||||
"tabFamily": "Керування родиною",
|
||||
"tabApiTokens": "API-токени",
|
||||
"tabAccount": "Обліковий запис",
|
||||
"tabsAriaLabel": "Розділи налаштувань",
|
||||
"sectionDesign": "Зовнішній вигляд",
|
||||
@@ -730,7 +733,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": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
|
||||
|
||||
+12
-2
@@ -28,7 +28,8 @@
|
||||
"all": "全部",
|
||||
"unknownError": "未知错误",
|
||||
"confirm": "确认",
|
||||
"undo": "撤销"
|
||||
"undo": "撤销",
|
||||
"reset": "重置为原始"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "概览",
|
||||
@@ -550,6 +551,8 @@
|
||||
"tabBudget": "预算",
|
||||
"tabShopping": "购物",
|
||||
"tabCalendar": "日历",
|
||||
"tabFamily": "家庭管理",
|
||||
"tabApiTokens": "API 令牌",
|
||||
"tabAccount": "账户",
|
||||
"tabsAriaLabel": "设置类别",
|
||||
"sectionDesign": "外观",
|
||||
@@ -730,7 +733,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": "家庭规划。安全。注重隐私。开源。",
|
||||
|
||||
@@ -60,12 +60,12 @@ const EVENT_COLOR_NAMES = () => ({
|
||||
});
|
||||
|
||||
const EVENT_ICON_ALIASES = {
|
||||
tooth: 'drill',
|
||||
drill: 'tooth',
|
||||
};
|
||||
|
||||
const EVENT_ICONS = [
|
||||
{ value: 'calendar', label: 'Calendar' },
|
||||
{ value: 'drill', label: 'Dentist' },
|
||||
{ value: 'tooth', label: 'Dentist' },
|
||||
{ value: 'alarm-clock', label: 'Alarm' },
|
||||
{ value: 'clock', label: 'Time' },
|
||||
{ value: 'bell', label: 'Reminder' },
|
||||
@@ -168,6 +168,8 @@ const EVENT_ICONS = [
|
||||
{ value: 'cloud-sun', label: 'Weather' },
|
||||
];
|
||||
|
||||
const CUSTOM_EVENT_ICONS = new Set(['tooth']);
|
||||
|
||||
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
|
||||
|
||||
/**
|
||||
@@ -266,8 +268,48 @@ function eventIconName(icon) {
|
||||
return EVENT_ICONS.some((item) => item.value === normalized) ? normalized : 'calendar';
|
||||
}
|
||||
|
||||
function customEventIconHtml(icon, className) {
|
||||
if (icon !== 'tooth') return '';
|
||||
return `<svg class="${className} event-icon--custom" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8.5 3.5c1.2 0 2.1.5 3.5.5s2.3-.5 3.5-.5c2.4 0 4 1.8 4 4.4 0 2.2-1 4.2-1.7 5.7-.7 1.6-.8 3.1-1.1 4.7-.3 1.7-1.1 3.2-2.4 3.2-1.1 0-1.5-1.1-1.8-2.7-.2-1.2-.4-2.1-.5-2.1s-.3.9-.5 2.1c-.3 1.6-.7 2.7-1.8 2.7-1.3 0-2.1-1.5-2.4-3.2-.3-1.6-.4-3.1-1.1-4.7C5.5 12.1 4.5 10.1 4.5 7.9c0-2.6 1.6-4.4 4-4.4Z"/>
|
||||
<path d="M10 6.2c.7.3 1.3.5 2 .5s1.3-.2 2-.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function eventIconHtml(icon, className = 'event-icon') {
|
||||
return `<i class="${className}" data-lucide="${eventIconName(icon)}" aria-hidden="true"></i>`;
|
||||
const name = eventIconName(icon);
|
||||
if (CUSTOM_EVENT_ICONS.has(name)) return customEventIconHtml(name, className);
|
||||
return `<i class="${className}" data-lucide="${name}" aria-hidden="true"></i>`;
|
||||
}
|
||||
|
||||
function eventIconElement(icon, className = 'event-icon') {
|
||||
const name = eventIconName(icon);
|
||||
if (name === 'tooth') {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', `${className} event-icon--custom`);
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const outline = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
outline.setAttribute('d', 'M8.5 3.5c1.2 0 2.1.5 3.5.5s2.3-.5 3.5-.5c2.4 0 4 1.8 4 4.4 0 2.2-1 4.2-1.7 5.7-.7 1.6-.8 3.1-1.1 4.7-.3 1.7-1.1 3.2-2.4 3.2-1.1 0-1.5-1.1-1.8-2.7-.2-1.2-.4-2.1-.5-2.1s-.3.9-.5 2.1c-.3 1.6-.7 2.7-1.8 2.7-1.3 0-2.1-1.5-2.4-3.2-.3-1.6-.4-3.1-1.1-4.7C5.5 12.1 4.5 10.1 4.5 7.9c0-2.6 1.6-4.4 4-4.4Z');
|
||||
|
||||
const ridge = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
ridge.setAttribute('d', 'M10 6.2c.7.3 1.3.5 2 .5s1.3-.2 2-.5');
|
||||
|
||||
svg.append(outline, ridge);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const el = document.createElement('i');
|
||||
el.className = className;
|
||||
el.dataset.lucide = name;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
return el;
|
||||
}
|
||||
|
||||
function bindDateInputs(root) {
|
||||
@@ -1086,8 +1128,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
if (iconInput) iconInput.value = nextIcon;
|
||||
if (iconTrigger) {
|
||||
iconTrigger.dataset.icon = nextIcon;
|
||||
const iconEl = iconTrigger.querySelector('[data-lucide]');
|
||||
iconEl?.setAttribute('data-lucide', nextIcon);
|
||||
iconTrigger.replaceChildren(eventIconElement(nextIcon, 'event-icon-picker__trigger-icon'));
|
||||
}
|
||||
iconGrid?.querySelectorAll('.event-icon-picker__option').forEach((btn) => {
|
||||
const active = btn.dataset.icon === nextIcon;
|
||||
@@ -1158,7 +1199,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
aria-checked="${selectedIcon === icon.value ? 'true' : 'false'}"
|
||||
aria-label="${esc(icon.label)}"
|
||||
title="${esc(icon.label)}">
|
||||
<i data-lucide="${icon.value}" aria-hidden="true"></i>
|
||||
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
@@ -1181,7 +1222,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="${t('calendar.iconLabel')}">
|
||||
<i data-lucide="${selectedIcon}" aria-hidden="true"></i>
|
||||
${eventIconHtml(selectedIcon, 'event-icon-picker__trigger-icon')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group event-title-picker__title">
|
||||
|
||||
@@ -240,9 +240,11 @@ function renderContactItem(c) {
|
||||
class="contact-action-btn" aria-label="${t('contacts.exportLabel')}" title="${t('contacts.exportTooltip')}">
|
||||
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</a>
|
||||
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="${t('contacts.deleteLabel')}">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
${!c.family_user_id ? `
|
||||
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="${t('contacts.deleteLabel')}">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -288,7 +290,7 @@ function openContactModal({ mode, contact = null }) {
|
||||
</div>
|
||||
|
||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="${t('contacts.deleteLabel')}">
|
||||
${isEdit && !contact.family_user_id ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="${t('contacts.deleteLabel')}">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</button>` : '<div></div>'}
|
||||
<div style="display:flex;gap:var(--space-3);">
|
||||
|
||||
+271
-115
@@ -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, getDateFormat } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
import '/components/oikos-locale-picker.js';
|
||||
|
||||
@@ -56,6 +56,37 @@ function buildFamilyRoleOptions(selected = 'other') {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function maskDateInputValue(value) {
|
||||
const digits = String(value || '').replace(/\D/g, '').slice(0, 8);
|
||||
if (!digits) return '';
|
||||
|
||||
if (getDateFormat() === 'ymd') {
|
||||
return [
|
||||
digits.slice(0, 4),
|
||||
digits.slice(4, 6),
|
||||
digits.slice(6, 8),
|
||||
].filter(Boolean).join('-');
|
||||
}
|
||||
|
||||
return [
|
||||
digits.slice(0, 2),
|
||||
digits.slice(2, 4),
|
||||
digits.slice(4, 8),
|
||||
].filter(Boolean).join('/');
|
||||
}
|
||||
|
||||
function bindSettingsDateInputs(root) {
|
||||
root.querySelectorAll('.js-date-input').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
input.value = maskDateInputValue(input.value);
|
||||
});
|
||||
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 || ''));
|
||||
@@ -70,14 +101,17 @@ function avatarHtml(user, className = 'settings-avatar') {
|
||||
function avatarEditorHtml(user, prefix) {
|
||||
return `
|
||||
<div class="settings-avatar-editor">
|
||||
<div class="settings-avatar-preview" id="${prefix}-avatar-preview">
|
||||
<button type="button" class="settings-avatar-button" id="${prefix}-avatar-preview" aria-label="${t('settings.profilePictureLabel')}">
|
||||
${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>
|
||||
</button>
|
||||
<input class="sr-only" type="file" id="${prefix}-avatar-file" accept="image/png,image/jpeg,image/webp" />
|
||||
<div class="settings-avatar-actions">
|
||||
<button type="button" class="settings-avatar-action" id="${prefix}-avatar-edit" aria-label="${t('settings.profilePictureLabel')}" title="${t('settings.profilePictureLabel')}">
|
||||
<i data-lucide="edit-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="settings-avatar-action settings-avatar-action--danger" id="${prefix}-avatar-remove" aria-label="${t('settings.profilePictureRemove')}" title="${t('settings.profilePictureRemove')}">
|
||||
<i data-lucide="trash-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -90,6 +124,17 @@ function setAvatarPreview(container, selector, user) {
|
||||
preview.insertAdjacentHTML('beforeend', avatarHtml(user, 'settings-avatar settings-avatar--lg'));
|
||||
}
|
||||
|
||||
function bindAvatarPicker(container, prefix) {
|
||||
const fileInput = container.querySelector(`#${prefix}-avatar-file`);
|
||||
const pickers = [
|
||||
container.querySelector(`#${prefix}-avatar-preview`),
|
||||
container.querySelector(`#${prefix}-avatar-edit`),
|
||||
];
|
||||
pickers.forEach((picker) => {
|
||||
picker?.addEventListener('click', () => fileInput?.click());
|
||||
});
|
||||
}
|
||||
|
||||
function readImageAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file) return resolve(undefined);
|
||||
@@ -137,6 +182,14 @@ function readImageAsDataUrl(file) {
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
try {
|
||||
const me = await auth.me();
|
||||
if (me?.user && user) Object.assign(user, me.user);
|
||||
else if (me?.user) user = me.user;
|
||||
} catch {
|
||||
// Non-critical: render with the user object provided by the router.
|
||||
}
|
||||
|
||||
// URL-Parameter auswerten (z.B. nach OAuth-Callback)
|
||||
const params = new URLSearchParams(location.search);
|
||||
const syncOk = params.get('sync_ok');
|
||||
@@ -187,9 +240,15 @@ export async function render(container, { user }) {
|
||||
? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.configured'))
|
||||
: t('settings.notConnected');
|
||||
|
||||
const allowedTabs = [
|
||||
'general', 'meals', 'budget', 'shopping', 'calendar',
|
||||
...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
|
||||
'account',
|
||||
];
|
||||
const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
|
||||
const activeTab = (syncOk || syncErr)
|
||||
? 'calendar'
|
||||
: (sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general');
|
||||
: (allowedTabs.includes(storedTab) ? storedTab : 'general');
|
||||
|
||||
const panelHidden = (id) => id === activeTab ? '' : ' hidden';
|
||||
const btnClass = (id) => `settings-tab-btn${id === activeTab ? ' settings-tab-btn--active' : ''}`;
|
||||
@@ -210,6 +269,8 @@ export async function render(container, { user }) {
|
||||
<button class="${btnClass('budget')}" role="tab" data-tab="budget" aria-selected="${btnAria('budget')}">${t('settings.tabBudget')}</button>
|
||||
<button class="${btnClass('shopping')}" role="tab" data-tab="shopping" aria-selected="${btnAria('shopping')}">${t('settings.tabShopping')}</button>
|
||||
<button class="${btnClass('calendar')}" role="tab" data-tab="calendar" aria-selected="${btnAria('calendar')}">${t('settings.tabCalendar')}</button>
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('family')}" role="tab" data-tab="family" aria-selected="${btnAria('family')}">${t('settings.tabFamily')}</button>` : ''}
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('api-tokens')}" role="tab" data-tab="api-tokens" aria-selected="${btnAria('api-tokens')}">${t('settings.tabApiTokens')}</button>` : ''}
|
||||
<button class="${btnClass('account')}" role="tab" data-tab="account" aria-selected="${btnAria('account')}">${t('settings.tabAccount')}</button>
|
||||
</nav>
|
||||
|
||||
@@ -467,6 +528,110 @@ export async function render(container, { user }) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
${user?.role === 'admin' ? `
|
||||
<!-- Panel: Family Management -->
|
||||
<div class="settings-tab-panel" data-panel="family" role="tabpanel"${panelHidden('family')}>
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
|
||||
<div class="settings-card" id="members-card">
|
||||
<ul class="settings-members" id="members-list">
|
||||
${users.map(memberHtml).join('')}
|
||||
</ul>
|
||||
<button class="btn btn--primary settings-add-btn" id="add-member-btn">${t('settings.addMember')}</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card settings-card--hidden" id="add-member-form-card">
|
||||
<h3 class="settings-card__title">${t('settings.newMemberTitle')}</h3>
|
||||
<form id="add-member-form" class="settings-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-username">${t('settings.usernameLabel')}</label>
|
||||
<input class="form-input" type="text" id="new-username" required autocomplete="off" />
|
||||
</div>
|
||||
<div class="settings-name-color-row">
|
||||
<div class="form-group settings-name-color-row__name">
|
||||
<label class="form-label" for="new-display-name">${t('settings.displayNameLabel')}</label>
|
||||
<input class="form-input" type="text" id="new-display-name" required />
|
||||
</div>
|
||||
<div class="form-group settings-color-field">
|
||||
<label class="form-label" for="new-avatar-color">${t('settings.colorLabel')}</label>
|
||||
<input class="settings-color-button" type="color" id="new-avatar-color" value="#007AFF" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-member-password">${t('settings.memberPasswordLabel')}</label>
|
||||
<input class="form-input" type="password" id="new-member-password" minlength="8" required autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-family-role">${t('settings.familyRoleLabel')}</label>
|
||||
<select class="form-input" id="new-family-role">
|
||||
${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>
|
||||
</label>
|
||||
<p class="form-hint">${t('settings.systemAdminHint')}</p>
|
||||
<div id="member-error" class="form-error" hidden></div>
|
||||
<div class="settings-form-actions">
|
||||
<button type="submit" class="btn btn--primary">${t('settings.createMember')}</button>
|
||||
<button type="button" class="btn btn--secondary" id="cancel-add-member">${t('settings.cancelAddMember')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${user?.role === 'admin' ? `
|
||||
<!-- Panel: API Tokens -->
|
||||
<div class="settings-tab-panel" data-panel="api-tokens" role="tabpanel"${panelHidden('api-tokens')}>
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.apiTokensTitle')}</h2>
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card__title">${t('settings.apiTokensCardTitle')}</h3>
|
||||
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.apiTokensHint')}</p>
|
||||
<ul class="settings-members" id="api-token-list">
|
||||
${apiTokens.map(apiTokenHtml).join('')}
|
||||
</ul>
|
||||
<form id="api-token-form" class="settings-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="api-token-name">${t('settings.apiTokenNameLabel')}</label>
|
||||
<input class="form-input" type="text" id="api-token-name" maxlength="100" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="api-token-expires">${t('settings.apiTokenExpiresLabel')}</label>
|
||||
<input class="form-input" type="datetime-local" id="api-token-expires" />
|
||||
<p class="form-hint">${t('settings.apiTokenExpiresHint')}</p>
|
||||
</div>
|
||||
<div id="api-token-created" class="settings-token-output" hidden>
|
||||
<label class="form-label" for="api-token-created-value">${t('settings.apiTokenCreatedLabel')}</label>
|
||||
<input class="form-input" id="api-token-created-value" type="text" readonly />
|
||||
<p class="form-hint">${t('settings.apiTokenCreatedHint')}</p>
|
||||
</div>
|
||||
<div id="api-token-error" class="form-error" hidden></div>
|
||||
<button type="submit" class="btn btn--primary">${t('settings.apiTokenCreate')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Panel: Konto -->
|
||||
<div class="settings-tab-panel" data-panel="account" role="tabpanel"${panelHidden('account')}>
|
||||
<section class="settings-section">
|
||||
@@ -485,14 +650,35 @@ export async function render(container, { user }) {
|
||||
<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 class="settings-profile-editor">
|
||||
${avatarEditorHtml(user, 'profile')}
|
||||
<div class="settings-profile-editor__fields">
|
||||
<div class="settings-name-color-row">
|
||||
<div class="form-group settings-name-color-row__name">
|
||||
<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 settings-color-field">
|
||||
<label class="form-label" for="profile-avatar-color">${t('settings.colorLabel')}</label>
|
||||
<input class="settings-color-button" type="color" id="profile-avatar-color" value="${esc(user?.avatar_color || '#007AFF')}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-grid modal-grid--2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="profile-phone">${t('settings.memberPhoneLabel')}</label>
|
||||
<input class="form-input" type="tel" id="profile-phone" value="${esc(user?.phone || '')}" autocomplete="tel" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="profile-email">${t('settings.memberEmailLabel')}</label>
|
||||
<input class="form-input" type="email" id="profile-email" value="${esc(user?.email || '')}" autocomplete="email" />
|
||||
</div>
|
||||
</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')}" />
|
||||
<label class="form-label" for="profile-birth-date">${t('settings.memberBirthDateLabel')}</label>
|
||||
<input class="form-input js-date-input" type="text" id="profile-birth-date" value="${esc(formatDateInput(user?.birth_date))}" placeholder="${dateInputPlaceholder()}" inputmode="numeric" />
|
||||
<p class="form-hint">${t('settings.memberContactBirthdayHint')}</p>
|
||||
</div>
|
||||
<div id="profile-error" class="form-error" hidden></div>
|
||||
<div class="settings-form-actions">
|
||||
@@ -522,85 +708,6 @@ export async function render(container, { user }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${user?.role === 'admin' ? `
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.apiTokensTitle')}</h2>
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card__title">${t('settings.apiTokensCardTitle')}</h3>
|
||||
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.apiTokensHint')}</p>
|
||||
<ul class="settings-members" id="api-token-list">
|
||||
${apiTokens.map(apiTokenHtml).join('')}
|
||||
</ul>
|
||||
<form id="api-token-form" class="settings-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="api-token-name">${t('settings.apiTokenNameLabel')}</label>
|
||||
<input class="form-input" type="text" id="api-token-name" maxlength="100" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="api-token-expires">${t('settings.apiTokenExpiresLabel')}</label>
|
||||
<input class="form-input" type="datetime-local" id="api-token-expires" />
|
||||
<p class="form-hint">${t('settings.apiTokenExpiresHint')}</p>
|
||||
</div>
|
||||
<div id="api-token-created" class="settings-token-output" hidden>
|
||||
<label class="form-label" for="api-token-created-value">${t('settings.apiTokenCreatedLabel')}</label>
|
||||
<input class="form-input" id="api-token-created-value" type="text" readonly />
|
||||
<p class="form-hint">${t('settings.apiTokenCreatedHint')}</p>
|
||||
</div>
|
||||
<div id="api-token-error" class="form-error" hidden></div>
|
||||
<button type="submit" class="btn btn--primary">${t('settings.apiTokenCreate')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
|
||||
<div class="settings-card" id="members-card">
|
||||
<ul class="settings-members" id="members-list">
|
||||
${users.map(memberHtml).join('')}
|
||||
</ul>
|
||||
<button class="btn btn--primary settings-add-btn" id="add-member-btn">${t('settings.addMember')}</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card settings-card--hidden" id="add-member-form-card">
|
||||
<h3 class="settings-card__title">${t('settings.newMemberTitle')}</h3>
|
||||
<form id="add-member-form" class="settings-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-username">${t('settings.usernameLabel')}</label>
|
||||
<input class="form-input" type="text" id="new-username" required autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-display-name">${t('settings.displayNameLabel')}</label>
|
||||
<input class="form-input" type="text" id="new-display-name" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-member-password">${t('settings.memberPasswordLabel')}</label>
|
||||
<input class="form-input" type="password" id="new-member-password" minlength="8" required autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-avatar-color">${t('settings.colorLabel')}</label>
|
||||
<input class="form-input form-input--color" type="color" id="new-avatar-color" value="#007AFF" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="new-family-role">${t('settings.familyRoleLabel')}</label>
|
||||
<select class="form-input" id="new-family-role">
|
||||
${buildFamilyRoleOptions()}
|
||||
</select>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="new-system-admin" />
|
||||
<span>${t('settings.systemAdminLabel')}</span>
|
||||
</label>
|
||||
<p class="form-hint">${t('settings.systemAdminHint')}</p>
|
||||
<div id="member-error" class="form-error" hidden></div>
|
||||
<div class="settings-form-actions">
|
||||
<button type="submit" class="btn btn--primary">${t('settings.createMember')}</button>
|
||||
<button type="button" class="btn btn--secondary" id="cancel-add-member">${t('settings.cancelAddMember')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
` : ''}
|
||||
|
||||
<section class="settings-section">
|
||||
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">${t('settings.logout')}</button>
|
||||
</section>
|
||||
@@ -617,6 +724,7 @@ export async function render(container, { user }) {
|
||||
}
|
||||
|
||||
bindEvents(container, user, users, categories, icsSubscriptions, apiTokens);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -625,6 +733,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);
|
||||
@@ -728,6 +837,7 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
||||
|
||||
const profileState = { avatarData: user?.avatar_data ?? null };
|
||||
const profileAvatarFile = container.querySelector('#profile-avatar-file');
|
||||
bindAvatarPicker(container, 'profile');
|
||||
if (profileAvatarFile) {
|
||||
profileAvatarFile.addEventListener('change', async () => {
|
||||
const errorEl = container.querySelector('#profile-error');
|
||||
@@ -765,13 +875,21 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
||||
e.preventDefault();
|
||||
const errorEl = container.querySelector('#profile-error');
|
||||
const btn = profileForm.querySelector('[type=submit]');
|
||||
const birthDateRaw = container.querySelector('#profile-birth-date')?.value || '';
|
||||
errorEl.hidden = true;
|
||||
if (!isDateInputValid(birthDateRaw)) {
|
||||
showError(errorEl, t('settings.memberBirthDateInvalid'));
|
||||
return;
|
||||
}
|
||||
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,
|
||||
phone: container.querySelector('#profile-phone')?.value.trim() || null,
|
||||
email: container.querySelector('#profile-email')?.value.trim() || null,
|
||||
birth_date: parseDateInput(birthDateRaw) || null,
|
||||
});
|
||||
Object.assign(user, res.user);
|
||||
window.oikos?.showToast(t('settings.profileSavedToast'), 'success');
|
||||
@@ -934,6 +1052,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 +1065,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]');
|
||||
@@ -1049,18 +1175,24 @@ function openEditMemberModal(member, currentUser, users, container) {
|
||||
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 class="settings-profile-editor">
|
||||
${avatarEditorHtml(member, 'edit-member')}
|
||||
<div class="settings-profile-editor__fields">
|
||||
<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="settings-name-color-row">
|
||||
<div class="form-group settings-name-color-row__name">
|
||||
<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 settings-color-field">
|
||||
<label class="form-label" for="edit-member-avatar-color">${t('settings.colorLabel')}</label>
|
||||
<input class="settings-color-button" type="color" id="edit-member-avatar-color" value="${esc(member.avatar_color || '#007AFF')}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="edit-member-family-role">${t('settings.familyRoleLabel')}</label>
|
||||
@@ -1068,6 +1200,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 +1230,8 @@ 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);
|
||||
bindAvatarPicker(panel, 'edit-member');
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
errorEl.hidden = true;
|
||||
try {
|
||||
@@ -1116,6 +1265,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 +1280,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 +1547,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>
|
||||
@@ -1624,15 +1788,7 @@ function currentTheme() {
|
||||
}
|
||||
|
||||
function applyTheme(value) {
|
||||
localStorage.setItem('oikos-theme', value);
|
||||
if (value === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else if (value === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
// tokens.css @media (prefers-color-scheme: dark) übernimmt sofort
|
||||
}
|
||||
window.oikos?.applyTheme(value);
|
||||
}
|
||||
|
||||
function showError(el, msg) {
|
||||
|
||||
+60
-3
@@ -134,6 +134,7 @@ const PRIMARY_NAV = 4;
|
||||
|
||||
const DEFAULT_APP_NAME = 'Oikos';
|
||||
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
|
||||
const APP_VERSION_STORAGE_KEY = 'oikos-app-version';
|
||||
|
||||
function getDirection(fromPath, toPath) {
|
||||
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
|
||||
@@ -146,6 +147,10 @@ function getAppName() {
|
||||
return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME;
|
||||
}
|
||||
|
||||
function getAppVersion() {
|
||||
return localStorage.getItem(APP_VERSION_STORAGE_KEY) || '';
|
||||
}
|
||||
|
||||
function setAppName(name) {
|
||||
const next = String(name || '').trim();
|
||||
if (next) {
|
||||
@@ -155,6 +160,15 @@ function setAppName(name) {
|
||||
}
|
||||
}
|
||||
|
||||
function setAppVersion(version) {
|
||||
const next = String(version || '').trim();
|
||||
if (next) {
|
||||
localStorage.setItem(APP_VERSION_STORAGE_KEY, next);
|
||||
} else {
|
||||
localStorage.removeItem(APP_VERSION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function routeTitle(path) {
|
||||
const map = {
|
||||
'/': t('dashboard.title'),
|
||||
@@ -174,8 +188,14 @@ function routeTitle(path) {
|
||||
|
||||
function updateBranding(path = currentPath) {
|
||||
const appName = getAppName();
|
||||
const sidebarLogoSpan = document.querySelector('.nav-sidebar__logo span');
|
||||
if (sidebarLogoSpan) sidebarLogoSpan.textContent = appName;
|
||||
const sidebarLogoName = document.querySelector('.nav-sidebar__brand-name');
|
||||
if (sidebarLogoName) sidebarLogoName.textContent = appName;
|
||||
const sidebarVersion = document.querySelector('.nav-sidebar__version');
|
||||
if (sidebarVersion) {
|
||||
const version = getAppVersion();
|
||||
sidebarVersion.textContent = version ? t('login.version', { version }) : '';
|
||||
sidebarVersion.hidden = !version;
|
||||
}
|
||||
|
||||
const loginTitle = document.querySelector('.login-hero__title');
|
||||
if (path === '/login' && loginTitle) loginTitle.textContent = appName;
|
||||
@@ -284,6 +304,14 @@ async function syncPreferencesOnce() {
|
||||
} catch {
|
||||
// Non-critical. The settings page can refresh this later.
|
||||
}
|
||||
try {
|
||||
const res = await api.get('/version');
|
||||
if (res?.version) setAppVersion(res.version);
|
||||
if (res?.app_name) setAppName(res.app_name);
|
||||
updateBranding();
|
||||
} catch {
|
||||
// Non-critical. The login page and settings page can refresh branding later.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -427,9 +455,18 @@ function renderAppShell(container) {
|
||||
logomark.appendChild(logoSvg);
|
||||
sidebarLogo.appendChild(logomark);
|
||||
|
||||
const sidebarBrandText = document.createElement('div');
|
||||
sidebarBrandText.className = 'nav-sidebar__brand-text';
|
||||
const sidebarLogoSpan = document.createElement('span');
|
||||
sidebarLogoSpan.className = 'nav-sidebar__brand-name';
|
||||
sidebarLogoSpan.textContent = getAppName();
|
||||
sidebarLogo.appendChild(sidebarLogoSpan);
|
||||
const sidebarVersion = document.createElement('small');
|
||||
sidebarVersion.className = 'nav-sidebar__version';
|
||||
const cachedVersion = getAppVersion();
|
||||
sidebarVersion.textContent = cachedVersion ? t('login.version', { version: cachedVersion }) : '';
|
||||
sidebarVersion.hidden = !cachedVersion;
|
||||
sidebarBrandText.append(sidebarLogoSpan, sidebarVersion);
|
||||
sidebarLogo.appendChild(sidebarBrandText);
|
||||
const sidebarItems = document.createElement('div');
|
||||
sidebarItems.className = 'nav-sidebar__items';
|
||||
sidebarItems.setAttribute('role', 'list');
|
||||
@@ -1162,6 +1199,16 @@ if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
|
||||
// --------------------------------------------------------
|
||||
(async () => {
|
||||
try {
|
||||
// Vorab-Theme-Anwendung ohne Abhängigkeit von window.oikos
|
||||
const stored = localStorage.getItem('oikos-theme');
|
||||
if (stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else if (stored === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
await initI18n();
|
||||
navigate(location.pathname, false);
|
||||
} catch (err) {
|
||||
@@ -1177,6 +1224,16 @@ window.oikos = {
|
||||
navigate,
|
||||
showToast,
|
||||
setThemeColor,
|
||||
applyTheme: (value) => {
|
||||
localStorage.setItem('oikos-theme', value);
|
||||
if (value === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else if (value === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
},
|
||||
restoreThemeColor: () => {
|
||||
const route = ROUTES.find((r) => r.path === currentPath);
|
||||
updateThemeColorForRoute(route);
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.event-icon-picker__trigger i {
|
||||
.event-icon-picker__trigger i,
|
||||
.event-icon-picker__trigger svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
@@ -629,7 +630,8 @@
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.event-icon-picker__option i {
|
||||
.event-icon-picker__option i,
|
||||
.event-icon-picker__option svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@
|
||||
}
|
||||
|
||||
/* Logo-Text verstecken im collapsed-Modus */
|
||||
.nav-sidebar__logo > span {
|
||||
.nav-sidebar__brand-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -665,12 +665,29 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-sidebar__logo > span {
|
||||
display: inline-block;
|
||||
.nav-sidebar__brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.nav-sidebar__brand-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-sidebar__version {
|
||||
margin-top: var(--space-0h);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.nav-sidebar__items {
|
||||
|
||||
+113
-8
@@ -10,11 +10,11 @@
|
||||
.settings-page { --module-accent: var(--module-settings); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Seiten-Layout - nutzt layout-center (max 720px)
|
||||
Seiten-Layout
|
||||
-------------------------------------------------------- */
|
||||
|
||||
.settings-page {
|
||||
max-width: var(--content-max-width-narrow);
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
@@ -67,7 +68,7 @@
|
||||
|
||||
.settings-tab-btn {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: var(--space-3) clamp(var(--space-2), 1.3vw, var(--space-4));
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
@@ -196,23 +197,127 @@
|
||||
}
|
||||
|
||||
.settings-avatar-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-avatar-button {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.settings-avatar-button:hover,
|
||||
.settings-avatar-button:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-avatar-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.settings-avatar-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.settings-avatar-action:hover,
|
||||
.settings-avatar-action:focus-visible {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.settings-avatar-action--danger:hover,
|
||||
.settings-avatar-action--danger:focus-visible {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
}
|
||||
|
||||
.settings-avatar-action i,
|
||||
.settings-avatar-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.settings-profile-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);
|
||||
.settings-profile-editor__fields {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-name-color-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.settings-name-color-row__name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-color-field {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-color-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-color-button::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-color-button::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.settings-color-button::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.settings-avatar-editor {
|
||||
.settings-profile-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-avatar-editor {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
|
||||
+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-v65';
|
||||
const PAGES_CACHE = 'oikos-pages-v60';
|
||||
const LOCALES_CACHE = 'oikos-locales-v9';
|
||||
const ASSETS_CACHE = 'oikos-assets-v60';
|
||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
|
||||
|
||||
|
||||
+183
-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);
|
||||
@@ -685,6 +828,7 @@ router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => {
|
||||
const avatarData = req.body.avatar_data !== undefined
|
||||
? normalizeAvatarData(req.body.avatar_data)
|
||||
: existing.avatar_data;
|
||||
const memberFields = validateMemberProfileFields(req.body);
|
||||
|
||||
if (!displayName) return res.status(400).json({ error: 'Display name is required.', code: 400 });
|
||||
if (displayName.length > 128) {
|
||||
@@ -693,12 +837,25 @@ router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => {
|
||||
if (avatarData?.error) {
|
||||
return res.status(400).json({ error: avatarData.error, code: 400 });
|
||||
}
|
||||
if (memberFields.errors.length) {
|
||||
return res.status(400).json({ error: memberFields.errors.join(' '), 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,
|
||||
phone: memberFields.values.phone,
|
||||
email: memberFields.values.email,
|
||||
birthDate: memberFields.values.birth_date,
|
||||
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 +924,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,25 @@ 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
|
||||
);
|
||||
`,
|
||||
17: `
|
||||
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill';
|
||||
`,
|
||||
};
|
||||
|
||||
export { MIGRATIONS_SQL };
|
||||
|
||||
@@ -748,6 +748,33 @@ 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
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 24,
|
||||
description: 'Use tooth icon for dentist calendar events',
|
||||
up: `
|
||||
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill';
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -22,7 +22,7 @@ const router = express.Router();
|
||||
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
|
||||
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
const VALID_EVENT_ICONS = new Set([
|
||||
'calendar', 'drill', 'alarm-clock', 'clock', 'bell', 'map-pin', 'home',
|
||||
'calendar', 'tooth', 'drill', 'alarm-clock', 'clock', 'bell', 'map-pin', 'home',
|
||||
'house', 'building', 'hospital', 'stethoscope', 'syringe', 'pill',
|
||||
'tablets', 'bandage', 'ambulance', 'heart-pulse', 'activity', 'cross',
|
||||
'scissors', 'shower-head', 'dumbbell', 'trophy', 'car', 'bus', 'train',
|
||||
@@ -55,7 +55,7 @@ function isAdminUser(req) {
|
||||
|
||||
function eventIcon(value) {
|
||||
const raw = typeof value === 'string' && value.trim() ? value.trim() : 'calendar';
|
||||
const icon = raw === 'tooth' ? 'drill' : raw;
|
||||
const icon = raw === 'drill' ? 'tooth' : raw;
|
||||
return VALID_EVENT_ICONS.has(icon) ? icon : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,13 @@ router.put('/:id', (req, res) => {
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const contact = db.get().prepare('SELECT family_user_id FROM contacts WHERE id = ?').get(id);
|
||||
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
|
||||
|
||||
if (contact.family_user_id) {
|
||||
return res.status(403).json({ error: 'Familienmitglieder können nicht aus der Kontaktliste gelöscht werden.', code: 403 });
|
||||
}
|
||||
|
||||
const result = db.get().prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { MIGRATIONS_SQL } from './server/db-schema-test.js';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
// Setup schema up to migration 23 (where family_user_id was added)
|
||||
// Since we don't have all migrations in SQL strings easily available,
|
||||
// we'll just mock the necessary tables for this specific test.
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
const userId = db.prepare("INSERT INTO users (display_name) VALUES ('Papa')").run().lastInsertRowid;
|
||||
const contactIdFamily = db.prepare("INSERT INTO contacts (name, family_user_id) VALUES ('Papa', ?)").run(userId).lastInsertRowid;
|
||||
const contactIdRegular = db.prepare("INSERT INTO contacts (name, family_user_id) VALUES ('Regular Contact', NULL)").run().lastInsertRowid;
|
||||
|
||||
console.log('\n[Family-Contacts-Test] Backend Deletion Prevention\n');
|
||||
|
||||
test('Regular contact can be deleted', () => {
|
||||
const result = db.prepare('DELETE FROM contacts WHERE id = ?').run(contactIdRegular);
|
||||
assert(result.changes === 1, 'Should have deleted 1 row');
|
||||
});
|
||||
|
||||
test('Family contact should NOT be deleted if we apply the logic from the route', () => {
|
||||
// Mocking the logic in server/routes/contacts.js
|
||||
const id = contactIdFamily;
|
||||
const contact = db.prepare('SELECT family_user_id FROM contacts WHERE id = ?').get(id);
|
||||
|
||||
let deleted = false;
|
||||
let forbidden = false;
|
||||
|
||||
if (contact.family_user_id) {
|
||||
forbidden = true;
|
||||
} else {
|
||||
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
assert(forbidden === true, 'Should be forbidden');
|
||||
assert(deleted === false, 'Should not have been deleted');
|
||||
});
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user