feat: configurable trip reminders, admin full access, and enhanced audit logging
- Add configurable trip reminder days (1, 3, 9 or custom up to 30) settable by trip owner - Grant administrators full access to edit, archive, delete, view and list all trips - Show trip owner email in audit logs and docker logs when admin edits/deletes another user's trip - Show target user email in audit logs when admin edits or deletes a user account - Use email instead of username in all notifications (Discord/Slack/email) to avoid ambiguity - Grey out notification event toggles when no SMTP/webhook is configured - Grey out trip reminder selector when notifications are disabled - Skip local admin account creation when OIDC_ONLY=true with OIDC configured - Conditional scheduler logging: show disabled reason or active reminder count - Log per-owner reminder creation/update in docker logs - Demote 401/403 HTTP errors to DEBUG log level to reduce noise - Hide edit/archive/delete buttons for non-owner invited users on trip cards - Fix literal "0" rendering on trip cards from SQLite numeric is_owner field - Add missing translation keys across all 14 language files Made-with: Cursor
This commit is contained in:
@@ -71,18 +71,19 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa } = useAuthStore()
|
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
if (config?.timezone) setServerTimezone(config.timezone)
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||||
|
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||||
|
|
||||||
if (config?.version) {
|
if (config?.version) {
|
||||||
const storedVersion = localStorage.getItem('trek_app_version')
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
|
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
||||||
import { tripsApi, authApi } from '../../api/client'
|
import { tripsApi, authApi } from '../../api/client'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
@@ -23,13 +23,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const currentUser = useAuthStore(s => s.user)
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
|
||||||
|
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
|
reminder_days: 0 as number,
|
||||||
})
|
})
|
||||||
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [coverPreview, setCoverPreview] = useState(null)
|
const [coverPreview, setCoverPreview] = useState(null)
|
||||||
@@ -41,25 +45,40 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trip) {
|
if (trip) {
|
||||||
|
const rd = trip.reminder_days ?? 3
|
||||||
setFormData({
|
setFormData({
|
||||||
title: trip.title || '',
|
title: trip.title || '',
|
||||||
description: trip.description || '',
|
description: trip.description || '',
|
||||||
start_date: trip.start_date || '',
|
start_date: trip.start_date || '',
|
||||||
end_date: trip.end_date || '',
|
end_date: trip.end_date || '',
|
||||||
|
reminder_days: rd,
|
||||||
})
|
})
|
||||||
|
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||||
setCoverPreview(trip.cover_image || null)
|
setCoverPreview(trip.cover_image || null)
|
||||||
} else {
|
} else {
|
||||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
|
||||||
|
setCustomReminder(false)
|
||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
}
|
}
|
||||||
setPendingCoverFile(null)
|
setPendingCoverFile(null)
|
||||||
setSelectedMembers([])
|
setSelectedMembers([])
|
||||||
setError('')
|
setError('')
|
||||||
|
if (isOpen) {
|
||||||
|
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||||
|
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [trip, isOpen])
|
}, [trip, isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trip && isOpen) {
|
||||||
|
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
|
||||||
|
}
|
||||||
|
}, [tripRemindersEnabled])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -74,6 +93,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
description: formData.description.trim() || null,
|
description: formData.description.trim() || null,
|
||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
|
reminder_days: formData.reminder_days,
|
||||||
})
|
})
|
||||||
// Add selected members for newly created trips
|
// Add selected members for newly created trips
|
||||||
if (selectedMembers.length > 0 && result?.trip?.id) {
|
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||||
@@ -272,6 +292,59 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reminder — only visible to owner (or when creating) */}
|
||||||
|
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
|
||||||
|
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
<Bell className="inline w-4 h-4 mr-1" />{t('trips.reminder')}
|
||||||
|
</label>
|
||||||
|
{!tripRemindersEnabled ? (
|
||||||
|
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||||
|
{t('trips.reminderDisabledHint')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 0, label: t('trips.reminderNone') },
|
||||||
|
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
|
||||||
|
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
|
||||||
|
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={opt.value} type="button"
|
||||||
|
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||||
|
!customReminder && formData.reminder_days === opt.value
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||||
|
}`}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||||
|
customReminder
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||||
|
}`}>
|
||||||
|
{t('trips.reminderCustom')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customReminder && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input type="number" min={1} max={30}
|
||||||
|
value={formData.reminder_days}
|
||||||
|
onChange={e => update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||||
|
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
|
<span className="text-xs text-slate-500">{t('trips.reminderDaysBefore')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Members — only for new trips */}
|
{/* Members — only for new trips */}
|
||||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -312,11 +385,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!formData.start_date && !formData.end_date && (
|
|
||||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
|
||||||
{t('dashboard.noDateHint')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'كلمة المرور',
|
'common.password': 'كلمة المرور',
|
||||||
'common.saving': 'جارٍ الحفظ...',
|
'common.saving': 'جارٍ الحفظ...',
|
||||||
'common.saved': 'تم الحفظ',
|
'common.saved': 'تم الحفظ',
|
||||||
|
'trips.reminder': 'تذكير',
|
||||||
|
'trips.reminderNone': 'بدون',
|
||||||
|
'trips.reminderDay': 'يوم',
|
||||||
|
'trips.reminderDays': 'أيام',
|
||||||
|
'trips.reminderCustom': 'مخصص',
|
||||||
|
'trips.reminderDaysBefore': 'أيام قبل المغادرة',
|
||||||
|
'trips.reminderDisabledHint': 'تذكيرات الرحلة معطلة. قم بتفعيلها من الإدارة > الإعدادات > الإشعارات.',
|
||||||
'common.update': 'تحديث',
|
'common.update': 'تحديث',
|
||||||
'common.change': 'تغيير',
|
'common.change': 'تغيير',
|
||||||
'common.uploading': 'جارٍ الرفع...',
|
'common.uploading': 'جارٍ الرفع...',
|
||||||
@@ -165,6 +172,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'أحداث الإشعارات',
|
'admin.notifications.events': 'أحداث الإشعارات',
|
||||||
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
|
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
|
||||||
|
'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
|
||||||
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
|
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
|
||||||
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
|
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
|
||||||
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
|
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
|
||||||
@@ -173,6 +181,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'البريد والإشعارات',
|
'admin.smtp.title': 'البريد والإشعارات',
|
||||||
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
|
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
|
||||||
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
||||||
|
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
|
||||||
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
||||||
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
||||||
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'Senha',
|
'common.password': 'Senha',
|
||||||
'common.saving': 'Salvando...',
|
'common.saving': 'Salvando...',
|
||||||
'common.saved': 'Salvo',
|
'common.saved': 'Salvo',
|
||||||
|
'trips.reminder': 'Lembrete',
|
||||||
|
'trips.reminderNone': 'Nenhum',
|
||||||
|
'trips.reminderDay': 'dia',
|
||||||
|
'trips.reminderDays': 'dias',
|
||||||
|
'trips.reminderCustom': 'Personalizado',
|
||||||
|
'trips.reminderDaysBefore': 'dias antes da partida',
|
||||||
|
'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
|
||||||
'common.update': 'Atualizar',
|
'common.update': 'Atualizar',
|
||||||
'common.change': 'Alterar',
|
'common.change': 'Alterar',
|
||||||
'common.uploading': 'Enviando…',
|
'common.uploading': 'Enviando…',
|
||||||
@@ -160,6 +167,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Eventos de notificação',
|
'admin.notifications.events': 'Eventos de notificação',
|
||||||
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
|
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
|
||||||
|
'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
|
||||||
'admin.notifications.save': 'Salvar configurações de notificação',
|
'admin.notifications.save': 'Salvar configurações de notificação',
|
||||||
'admin.notifications.saved': 'Configurações de notificação salvas',
|
'admin.notifications.saved': 'Configurações de notificação salvas',
|
||||||
'admin.notifications.testWebhook': 'Enviar webhook de teste',
|
'admin.notifications.testWebhook': 'Enviar webhook de teste',
|
||||||
@@ -168,6 +176,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'E-mail e notificações',
|
'admin.smtp.title': 'E-mail e notificações',
|
||||||
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
|
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
|
||||||
'admin.smtp.testButton': 'Enviar e-mail de teste',
|
'admin.smtp.testButton': 'Enviar e-mail de teste',
|
||||||
|
'admin.webhook.hint': 'Enviar notificações para um webhook externo (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
|
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
|
||||||
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
|
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
|
||||||
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
|
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'Heslo',
|
'common.password': 'Heslo',
|
||||||
'common.saving': 'Ukládání...',
|
'common.saving': 'Ukládání...',
|
||||||
'common.saved': 'Uloženo',
|
'common.saved': 'Uloženo',
|
||||||
|
'trips.reminder': 'Připomínka',
|
||||||
|
'trips.reminderNone': 'Žádná',
|
||||||
|
'trips.reminderDay': 'den',
|
||||||
|
'trips.reminderDays': 'dní',
|
||||||
|
'trips.reminderCustom': 'Vlastní',
|
||||||
|
'trips.reminderDaysBefore': 'dní před odjezdem',
|
||||||
|
'trips.reminderDisabledHint': 'Připomínky výletů jsou zakázány. Povolte je v Správa > Nastavení > Oznámení.',
|
||||||
'common.update': 'Aktualizovat',
|
'common.update': 'Aktualizovat',
|
||||||
'common.change': 'Změnit',
|
'common.change': 'Změnit',
|
||||||
'common.uploading': 'Nahrávání…',
|
'common.uploading': 'Nahrávání…',
|
||||||
@@ -246,6 +253,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Události oznámení',
|
'admin.notifications.events': 'Události oznámení',
|
||||||
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
|
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
|
||||||
|
'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
|
||||||
'admin.notifications.save': 'Uložit nastavení oznámení',
|
'admin.notifications.save': 'Uložit nastavení oznámení',
|
||||||
'admin.notifications.saved': 'Nastavení oznámení uloženo',
|
'admin.notifications.saved': 'Nastavení oznámení uloženo',
|
||||||
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
|
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
|
||||||
@@ -254,6 +262,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'E-mail a oznámení',
|
'admin.smtp.title': 'E-mail a oznámení',
|
||||||
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
|
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
|
||||||
'admin.smtp.testButton': 'Odeslat testovací e-mail',
|
'admin.smtp.testButton': 'Odeslat testovací e-mail',
|
||||||
|
'admin.webhook.hint': 'Odesílat oznámení na externí webhook (Discord, Slack atd.).',
|
||||||
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
|
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
|
||||||
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
|
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
|
||||||
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
|
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'Passwort',
|
'common.password': 'Passwort',
|
||||||
'common.saving': 'Speichern...',
|
'common.saving': 'Speichern...',
|
||||||
'common.saved': 'Gespeichert',
|
'common.saved': 'Gespeichert',
|
||||||
|
'trips.reminder': 'Erinnerung',
|
||||||
|
'trips.reminderNone': 'Keine',
|
||||||
|
'trips.reminderDay': 'Tag',
|
||||||
|
'trips.reminderDays': 'Tage',
|
||||||
|
'trips.reminderCustom': 'Benutzerdefiniert',
|
||||||
|
'trips.reminderDaysBefore': 'Tage vor Abreise',
|
||||||
|
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
|
||||||
'common.update': 'Aktualisieren',
|
'common.update': 'Aktualisieren',
|
||||||
'common.change': 'Ändern',
|
'common.change': 'Ändern',
|
||||||
'common.uploading': 'Hochladen…',
|
'common.uploading': 'Hochladen…',
|
||||||
@@ -160,6 +167,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Benachrichtigungsereignisse',
|
'admin.notifications.events': 'Benachrichtigungsereignisse',
|
||||||
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
|
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
|
||||||
|
'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
|
||||||
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
|
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
|
||||||
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
|
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
|
||||||
'admin.notifications.testWebhook': 'Test-Webhook senden',
|
'admin.notifications.testWebhook': 'Test-Webhook senden',
|
||||||
@@ -168,6 +176,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
||||||
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
|
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
|
||||||
'admin.smtp.testButton': 'Test-E-Mail senden',
|
'admin.smtp.testButton': 'Test-E-Mail senden',
|
||||||
|
'admin.webhook.hint': 'Benachrichtigungen an einen externen Webhook senden (Discord, Slack usw.).',
|
||||||
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
||||||
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
||||||
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
'common.saving': 'Saving...',
|
'common.saving': 'Saving...',
|
||||||
'common.saved': 'Saved',
|
'common.saved': 'Saved',
|
||||||
|
'trips.reminder': 'Reminder',
|
||||||
|
'trips.reminderNone': 'None',
|
||||||
|
'trips.reminderDay': 'day',
|
||||||
|
'trips.reminderDays': 'days',
|
||||||
|
'trips.reminderCustom': 'Custom',
|
||||||
|
'trips.reminderDaysBefore': 'days before departure',
|
||||||
|
'trips.reminderDisabledHint': 'Trip reminders are disabled. Enable them in Admin > Settings > Notifications.',
|
||||||
'common.update': 'Update',
|
'common.update': 'Update',
|
||||||
'common.change': 'Change',
|
'common.change': 'Change',
|
||||||
'common.uploading': 'Uploading…',
|
'common.uploading': 'Uploading…',
|
||||||
@@ -157,6 +164,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Notification Events',
|
'admin.notifications.events': 'Notification Events',
|
||||||
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
||||||
|
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
||||||
'admin.notifications.save': 'Save notification settings',
|
'admin.notifications.save': 'Save notification settings',
|
||||||
'admin.notifications.saved': 'Notification settings saved',
|
'admin.notifications.saved': 'Notification settings saved',
|
||||||
'admin.notifications.testWebhook': 'Send test webhook',
|
'admin.notifications.testWebhook': 'Send test webhook',
|
||||||
@@ -165,6 +173,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'Email & Notifications',
|
'admin.smtp.title': 'Email & Notifications',
|
||||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||||
'admin.smtp.testButton': 'Send test email',
|
'admin.smtp.testButton': 'Send test email',
|
||||||
|
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||||
'admin.smtp.testFailed': 'Test email failed',
|
'admin.smtp.testFailed': 'Test email failed',
|
||||||
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const es: Record<string, string> = {
|
|||||||
'common.password': 'Contraseña',
|
'common.password': 'Contraseña',
|
||||||
'common.saving': 'Guardando...',
|
'common.saving': 'Guardando...',
|
||||||
'common.saved': 'Guardado',
|
'common.saved': 'Guardado',
|
||||||
|
'trips.reminder': 'Recordatorio',
|
||||||
|
'trips.reminderNone': 'Ninguno',
|
||||||
|
'trips.reminderDay': 'día',
|
||||||
|
'trips.reminderDays': 'días',
|
||||||
|
'trips.reminderCustom': 'Personalizado',
|
||||||
|
'trips.reminderDaysBefore': 'días antes de la salida',
|
||||||
|
'trips.reminderDisabledHint': 'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.',
|
||||||
'common.update': 'Actualizar',
|
'common.update': 'Actualizar',
|
||||||
'common.change': 'Cambiar',
|
'common.change': 'Cambiar',
|
||||||
'common.uploading': 'Subiendo…',
|
'common.uploading': 'Subiendo…',
|
||||||
@@ -161,6 +168,7 @@ const es: Record<string, string> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Eventos de notificación',
|
'admin.notifications.events': 'Eventos de notificación',
|
||||||
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
|
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
|
||||||
|
'admin.notifications.configureFirst': 'Configura primero los ajustes SMTP o webhook a continuación, luego activa los eventos.',
|
||||||
'admin.notifications.save': 'Guardar configuración de notificaciones',
|
'admin.notifications.save': 'Guardar configuración de notificaciones',
|
||||||
'admin.notifications.saved': 'Configuración de notificaciones guardada',
|
'admin.notifications.saved': 'Configuración de notificaciones guardada',
|
||||||
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
|
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
|
||||||
@@ -169,6 +177,7 @@ const es: Record<string, string> = {
|
|||||||
'admin.smtp.title': 'Correo y notificaciones',
|
'admin.smtp.title': 'Correo y notificaciones',
|
||||||
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
|
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
|
||||||
'admin.smtp.testButton': 'Enviar correo de prueba',
|
'admin.smtp.testButton': 'Enviar correo de prueba',
|
||||||
|
'admin.webhook.hint': 'Enviar notificaciones a un webhook externo (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
||||||
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
||||||
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const fr: Record<string, string> = {
|
|||||||
'common.password': 'Mot de passe',
|
'common.password': 'Mot de passe',
|
||||||
'common.saving': 'Enregistrement…',
|
'common.saving': 'Enregistrement…',
|
||||||
'common.saved': 'Enregistré',
|
'common.saved': 'Enregistré',
|
||||||
|
'trips.reminder': 'Rappel',
|
||||||
|
'trips.reminderNone': 'Aucun',
|
||||||
|
'trips.reminderDay': 'jour',
|
||||||
|
'trips.reminderDays': 'jours',
|
||||||
|
'trips.reminderCustom': 'Personnalisé',
|
||||||
|
'trips.reminderDaysBefore': 'jours avant le départ',
|
||||||
|
'trips.reminderDisabledHint': 'Les rappels de voyage sont désactivés. Activez-les dans Admin > Paramètres > Notifications.',
|
||||||
'common.update': 'Mettre à jour',
|
'common.update': 'Mettre à jour',
|
||||||
'common.change': 'Modifier',
|
'common.change': 'Modifier',
|
||||||
'common.uploading': 'Import en cours…',
|
'common.uploading': 'Import en cours…',
|
||||||
@@ -160,6 +167,7 @@ const fr: Record<string, string> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Événements de notification',
|
'admin.notifications.events': 'Événements de notification',
|
||||||
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
|
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
|
||||||
|
'admin.notifications.configureFirst': 'Configurez d\'abord les paramètres SMTP ou webhook ci-dessous, puis activez les événements.',
|
||||||
'admin.notifications.save': 'Enregistrer les paramètres de notification',
|
'admin.notifications.save': 'Enregistrer les paramètres de notification',
|
||||||
'admin.notifications.saved': 'Paramètres de notification enregistrés',
|
'admin.notifications.saved': 'Paramètres de notification enregistrés',
|
||||||
'admin.notifications.testWebhook': 'Envoyer un webhook de test',
|
'admin.notifications.testWebhook': 'Envoyer un webhook de test',
|
||||||
@@ -168,6 +176,7 @@ const fr: Record<string, string> = {
|
|||||||
'admin.smtp.title': 'E-mail et notifications',
|
'admin.smtp.title': 'E-mail et notifications',
|
||||||
'admin.smtp.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
|
'admin.smtp.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
|
||||||
'admin.smtp.testButton': 'Envoyer un e-mail de test',
|
'admin.smtp.testButton': 'Envoyer un e-mail de test',
|
||||||
|
'admin.webhook.hint': 'Envoyer des notifications vers un webhook externe (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
|
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
|
||||||
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
|
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
|
||||||
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
|
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'Jelszó',
|
'common.password': 'Jelszó',
|
||||||
'common.saving': 'Mentés...',
|
'common.saving': 'Mentés...',
|
||||||
'common.saved': 'Mentve',
|
'common.saved': 'Mentve',
|
||||||
|
'trips.reminder': 'Emlékeztető',
|
||||||
|
'trips.reminderNone': 'Nincs',
|
||||||
|
'trips.reminderDay': 'nap',
|
||||||
|
'trips.reminderDays': 'nap',
|
||||||
|
'trips.reminderCustom': 'Egyéni',
|
||||||
|
'trips.reminderDaysBefore': 'nappal indulás előtt',
|
||||||
|
'trips.reminderDisabledHint': 'Az utazási emlékeztetők ki vannak kapcsolva. Kapcsold be az Admin > Beállítások > Értesítések menüben.',
|
||||||
'common.update': 'Frissítés',
|
'common.update': 'Frissítés',
|
||||||
'common.change': 'Módosítás',
|
'common.change': 'Módosítás',
|
||||||
'common.uploading': 'Feltöltés…',
|
'common.uploading': 'Feltöltés…',
|
||||||
@@ -245,6 +252,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Értesítési események',
|
'admin.notifications.events': 'Értesítési események',
|
||||||
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
|
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
|
||||||
|
'admin.notifications.configureFirst': 'Először konfiguráld az SMTP vagy webhook beállításokat lent, majd engedélyezd az eseményeket.',
|
||||||
'admin.notifications.save': 'Értesítési beállítások mentése',
|
'admin.notifications.save': 'Értesítési beállítások mentése',
|
||||||
'admin.notifications.saved': 'Értesítési beállítások mentve',
|
'admin.notifications.saved': 'Értesítési beállítások mentve',
|
||||||
'admin.notifications.testWebhook': 'Teszt webhook küldése',
|
'admin.notifications.testWebhook': 'Teszt webhook küldése',
|
||||||
@@ -253,6 +261,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'E-mail és értesítések',
|
'admin.smtp.title': 'E-mail és értesítések',
|
||||||
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
|
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
|
||||||
'admin.smtp.testButton': 'Teszt e-mail küldése',
|
'admin.smtp.testButton': 'Teszt e-mail küldése',
|
||||||
|
'admin.webhook.hint': 'Értesítések küldése külső webhookra (Discord, Slack stb.).',
|
||||||
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
|
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
|
||||||
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
|
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
|
||||||
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
|
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
'common.saving': 'Salvataggio...',
|
'common.saving': 'Salvataggio...',
|
||||||
'common.saved': 'Salvato',
|
'common.saved': 'Salvato',
|
||||||
|
'trips.reminder': 'Promemoria',
|
||||||
|
'trips.reminderNone': 'Nessuno',
|
||||||
|
'trips.reminderDay': 'giorno',
|
||||||
|
'trips.reminderDays': 'giorni',
|
||||||
|
'trips.reminderCustom': 'Personalizzato',
|
||||||
|
'trips.reminderDaysBefore': 'giorni prima della partenza',
|
||||||
|
'trips.reminderDisabledHint': 'I promemoria dei viaggi sono disabilitati. Abilitali in Admin > Impostazioni > Notifiche.',
|
||||||
'common.update': 'Aggiorna',
|
'common.update': 'Aggiorna',
|
||||||
'common.change': 'Cambia',
|
'common.change': 'Cambia',
|
||||||
'common.uploading': 'Caricamento…',
|
'common.uploading': 'Caricamento…',
|
||||||
@@ -245,6 +252,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Eventi di notifica',
|
'admin.notifications.events': 'Eventi di notifica',
|
||||||
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
|
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
|
||||||
|
'admin.notifications.configureFirst': 'Configura prima le impostazioni SMTP o webhook qui sotto, poi abilita gli eventi.',
|
||||||
'admin.notifications.save': 'Salva impostazioni notifiche',
|
'admin.notifications.save': 'Salva impostazioni notifiche',
|
||||||
'admin.notifications.saved': 'Impostazioni notifiche salvate',
|
'admin.notifications.saved': 'Impostazioni notifiche salvate',
|
||||||
'admin.notifications.testWebhook': 'Invia webhook di test',
|
'admin.notifications.testWebhook': 'Invia webhook di test',
|
||||||
@@ -253,6 +261,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.smtp.title': 'Email e notifiche',
|
'admin.smtp.title': 'Email e notifiche',
|
||||||
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
|
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
|
||||||
'admin.smtp.testButton': 'Invia email di prova',
|
'admin.smtp.testButton': 'Invia email di prova',
|
||||||
|
'admin.webhook.hint': 'Invia notifiche a un webhook esterno (Discord, Slack, ecc.).',
|
||||||
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
|
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
|
||||||
'admin.smtp.testFailed': 'Invio email di prova fallito',
|
'admin.smtp.testFailed': 'Invio email di prova fallito',
|
||||||
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
|
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const nl: Record<string, string> = {
|
|||||||
'common.password': 'Wachtwoord',
|
'common.password': 'Wachtwoord',
|
||||||
'common.saving': 'Opslaan...',
|
'common.saving': 'Opslaan...',
|
||||||
'common.saved': 'Opgeslagen',
|
'common.saved': 'Opgeslagen',
|
||||||
|
'trips.reminder': 'Herinnering',
|
||||||
|
'trips.reminderNone': 'Geen',
|
||||||
|
'trips.reminderDay': 'dag',
|
||||||
|
'trips.reminderDays': 'dagen',
|
||||||
|
'trips.reminderCustom': 'Aangepast',
|
||||||
|
'trips.reminderDaysBefore': 'dagen voor vertrek',
|
||||||
|
'trips.reminderDisabledHint': 'Reisherinneringen zijn uitgeschakeld. Schakel ze in via Admin > Instellingen > Meldingen.',
|
||||||
'common.update': 'Bijwerken',
|
'common.update': 'Bijwerken',
|
||||||
'common.change': 'Wijzigen',
|
'common.change': 'Wijzigen',
|
||||||
'common.uploading': 'Uploaden…',
|
'common.uploading': 'Uploaden…',
|
||||||
@@ -160,6 +167,7 @@ const nl: Record<string, string> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Meldingsgebeurtenissen',
|
'admin.notifications.events': 'Meldingsgebeurtenissen',
|
||||||
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
|
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
|
||||||
|
'admin.notifications.configureFirst': 'Configureer eerst de SMTP- of webhook-instellingen hieronder en schakel dan de events in.',
|
||||||
'admin.notifications.save': 'Meldingsinstellingen opslaan',
|
'admin.notifications.save': 'Meldingsinstellingen opslaan',
|
||||||
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
|
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
|
||||||
'admin.notifications.testWebhook': 'Testwebhook verzenden',
|
'admin.notifications.testWebhook': 'Testwebhook verzenden',
|
||||||
@@ -168,6 +176,7 @@ const nl: Record<string, string> = {
|
|||||||
'admin.smtp.title': 'E-mail en meldingen',
|
'admin.smtp.title': 'E-mail en meldingen',
|
||||||
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
|
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
|
||||||
'admin.smtp.testButton': 'Test-e-mail verzenden',
|
'admin.smtp.testButton': 'Test-e-mail verzenden',
|
||||||
|
'admin.webhook.hint': 'Meldingen verzenden naar een externe webhook (Discord, Slack, enz.).',
|
||||||
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
|
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
|
||||||
'admin.smtp.testFailed': 'Test-e-mail mislukt',
|
'admin.smtp.testFailed': 'Test-e-mail mislukt',
|
||||||
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
|
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const ru: Record<string, string> = {
|
|||||||
'common.password': 'Пароль',
|
'common.password': 'Пароль',
|
||||||
'common.saving': 'Сохранение...',
|
'common.saving': 'Сохранение...',
|
||||||
'common.saved': 'Сохранено',
|
'common.saved': 'Сохранено',
|
||||||
|
'trips.reminder': 'Напоминание',
|
||||||
|
'trips.reminderNone': 'Нет',
|
||||||
|
'trips.reminderDay': 'день',
|
||||||
|
'trips.reminderDays': 'дней',
|
||||||
|
'trips.reminderCustom': 'Другое',
|
||||||
|
'trips.reminderDaysBefore': 'дней до отъезда',
|
||||||
|
'trips.reminderDisabledHint': 'Напоминания о поездках отключены. Включите их в Админ > Настройки > Уведомления.',
|
||||||
'common.update': 'Обновить',
|
'common.update': 'Обновить',
|
||||||
'common.change': 'Изменить',
|
'common.change': 'Изменить',
|
||||||
'common.uploading': 'Загрузка…',
|
'common.uploading': 'Загрузка…',
|
||||||
@@ -160,6 +167,7 @@ const ru: Record<string, string> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'События уведомлений',
|
'admin.notifications.events': 'События уведомлений',
|
||||||
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
|
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
|
||||||
|
'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.',
|
||||||
'admin.notifications.save': 'Сохранить настройки уведомлений',
|
'admin.notifications.save': 'Сохранить настройки уведомлений',
|
||||||
'admin.notifications.saved': 'Настройки уведомлений сохранены',
|
'admin.notifications.saved': 'Настройки уведомлений сохранены',
|
||||||
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
|
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
|
||||||
@@ -168,6 +176,7 @@ const ru: Record<string, string> = {
|
|||||||
'admin.smtp.title': 'Почта и уведомления',
|
'admin.smtp.title': 'Почта и уведомления',
|
||||||
'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
|
'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
|
||||||
'admin.smtp.testButton': 'Отправить тестовое письмо',
|
'admin.smtp.testButton': 'Отправить тестовое письмо',
|
||||||
|
'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).',
|
||||||
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
|
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
|
||||||
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
|
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
|
||||||
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
|
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const zh: Record<string, string> = {
|
|||||||
'common.password': '密码',
|
'common.password': '密码',
|
||||||
'common.saving': '保存中...',
|
'common.saving': '保存中...',
|
||||||
'common.saved': '已保存',
|
'common.saved': '已保存',
|
||||||
|
'trips.reminder': '提醒',
|
||||||
|
'trips.reminderNone': '无',
|
||||||
|
'trips.reminderDay': '天',
|
||||||
|
'trips.reminderDays': '天',
|
||||||
|
'trips.reminderCustom': '自定义',
|
||||||
|
'trips.reminderDaysBefore': '天前提醒',
|
||||||
|
'trips.reminderDisabledHint': '旅行提醒已禁用。请在管理 > 设置 > 通知中启用。',
|
||||||
'common.update': '更新',
|
'common.update': '更新',
|
||||||
'common.change': '修改',
|
'common.change': '修改',
|
||||||
'common.uploading': '上传中…',
|
'common.uploading': '上传中…',
|
||||||
@@ -160,6 +167,7 @@ const zh: Record<string, string> = {
|
|||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': '通知事件',
|
'admin.notifications.events': '通知事件',
|
||||||
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
|
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
|
||||||
|
'admin.notifications.configureFirst': '请先在下方配置 SMTP 或 Webhook,然后启用事件。',
|
||||||
'admin.notifications.save': '保存通知设置',
|
'admin.notifications.save': '保存通知设置',
|
||||||
'admin.notifications.saved': '通知设置已保存',
|
'admin.notifications.saved': '通知设置已保存',
|
||||||
'admin.notifications.testWebhook': '发送测试 Webhook',
|
'admin.notifications.testWebhook': '发送测试 Webhook',
|
||||||
@@ -168,6 +176,7 @@ const zh: Record<string, string> = {
|
|||||||
'admin.smtp.title': '邮件与通知',
|
'admin.smtp.title': '邮件与通知',
|
||||||
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
|
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
|
||||||
'admin.smtp.testButton': '发送测试邮件',
|
'admin.smtp.testButton': '发送测试邮件',
|
||||||
|
'admin.webhook.hint': '向外部 Webhook 发送通知(Discord、Slack 等)。',
|
||||||
'admin.smtp.testSuccess': '测试邮件发送成功',
|
'admin.smtp.testSuccess': '测试邮件发送成功',
|
||||||
'admin.smtp.testFailed': '测试邮件发送失败',
|
'admin.smtp.testFailed': '测试邮件发送失败',
|
||||||
'dayplan.icsTooltip': '导出日历 (ICS)',
|
'dayplan.icsTooltip': '导出日历 (ICS)',
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const [updating, setUpdating] = useState<boolean>(false)
|
const [updating, setUpdating] = useState<boolean>(false)
|
||||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
||||||
|
|
||||||
const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore()
|
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -999,9 +999,15 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification event toggles — shown when any channel is active */}
|
{/* Notification event toggles — shown when any channel is active */}
|
||||||
{(smtpValues.notification_channel || 'none') !== 'none' && (
|
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
|
||||||
<div className="space-y-2 pt-2 border-t border-slate-100">
|
const ch = smtpValues.notification_channel || 'none'
|
||||||
|
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
||||||
|
{!configValid && (
|
||||||
|
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
|
||||||
|
)}
|
||||||
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
||||||
{[
|
{[
|
||||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||||
@@ -1029,7 +1035,8 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Email (SMTP) settings — shown when email channel is active */}
|
{/* Email (SMTP) settings — shown when email channel is active */}
|
||||||
{(smtpValues.notification_channel || 'none') === 'email' && (
|
{(smtpValues.notification_channel || 'none') === 'email' && (
|
||||||
@@ -1072,7 +1079,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
{/* Webhook settings — shown when webhook channel is active */}
|
{/* Webhook settings — shown when webhook channel is active */}
|
||||||
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
||||||
<div className="space-y-3 pt-2 border-t border-slate-100">
|
<div className="space-y-3 pt-2 border-t border-slate-100">
|
||||||
<p className="text-xs text-slate-400">{t('admin.webhook.hint') || 'Send notifications to an external webhook (Discord, Slack, etc.).'}</p>
|
<p className="text-xs text-slate-400">{t('admin.webhook.hint')}</p>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -1097,6 +1104,9 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await authApi.updateAppSettings(payload)
|
await authApi.updateAppSettings(payload)
|
||||||
toast.success(t('admin.notifications.saved'))
|
toast.success(t('admin.notifications.saved'))
|
||||||
|
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||||
|
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||||
|
}).catch(() => {})
|
||||||
} catch { toast.error(t('common.error')) }
|
} catch { toast.error(t('common.error')) }
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||||
|
|||||||
@@ -145,9 +145,10 @@ interface TripCardProps {
|
|||||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||||
locale: string
|
locale: string
|
||||||
dark?: boolean
|
dark?: boolean
|
||||||
|
isAdmin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark, isAdmin }: TripCardProps): React.ReactElement {
|
||||||
const status = getTripStatus(trip)
|
const status = getTripStatus(trip)
|
||||||
|
|
||||||
const coverBg = trip.cover_image
|
const coverBg = trip.cover_image
|
||||||
@@ -186,12 +187,14 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right actions */}
|
{/* Top-right actions */}
|
||||||
|
{(!!trip.is_owner || isAdmin) && (
|
||||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
||||||
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
||||||
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom content */}
|
{/* Bottom content */}
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||||
@@ -228,7 +231,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||||
const status = getTripStatus(trip)
|
const status = getTripStatus(trip)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
@@ -305,19 +308,21 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
|||||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(!!trip.is_owner || isAdmin) && (
|
||||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
||||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
||||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List View Item ──────────────────────────────────────────────────────────
|
// ── List View Item ──────────────────────────────────────────────────────────
|
||||||
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||||
const status = getTripStatus(trip)
|
const status = getTripStatus(trip)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
@@ -403,11 +408,13 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{(!!trip.is_owner || isAdmin) && (
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
||||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
||||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -421,9 +428,10 @@ interface ArchivedRowProps {
|
|||||||
onClick: (trip: DashboardTrip) => void
|
onClick: (trip: DashboardTrip) => void
|
||||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||||
locale: string
|
locale: string
|
||||||
|
isAdmin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale, isAdmin }: ArchivedRowProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div onClick={() => onClick(trip)} style={{
|
<div onClick={() => onClick(trip)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||||
@@ -449,6 +457,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(!!trip.is_owner || isAdmin) && (
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
@@ -461,6 +470,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
|||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -539,7 +549,8 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode, user } = useAuthStore()
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const dm = settings.dark_mode
|
const dm = settings.dark_mode
|
||||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
@@ -781,7 +792,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
{!isLoading && spotlight && viewMode === 'grid' && (
|
{!isLoading && spotlight && viewMode === 'grid' && (
|
||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
t={t} locale={locale} dark={dark}
|
t={t} locale={locale} dark={dark} isAdmin={isAdmin}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
@@ -797,7 +808,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<TripCard
|
<TripCard
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
trip={trip}
|
trip={trip}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale} isAdmin={isAdmin}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
@@ -811,7 +822,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<TripListItem
|
<TripListItem
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
trip={trip}
|
trip={trip}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale} isAdmin={isAdmin}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
@@ -841,7 +852,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<ArchivedRow
|
<ArchivedRow
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
trip={trip}
|
trip={trip}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale} isAdmin={isAdmin}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onUnarchive={handleUnarchive}
|
onUnarchive={handleUnarchive}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface AuthState {
|
|||||||
serverTimezone: string
|
serverTimezone: string
|
||||||
/** Server policy: all users must enable MFA */
|
/** Server policy: all users must enable MFA */
|
||||||
appRequireMfa: boolean
|
appRequireMfa: boolean
|
||||||
|
tripRemindersEnabled: boolean
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<LoginResult>
|
login: (email: string, password: string) => Promise<LoginResult>
|
||||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||||
@@ -42,6 +43,7 @@ interface AuthState {
|
|||||||
setHasMapsKey: (val: boolean) => void
|
setHasMapsKey: (val: boolean) => void
|
||||||
setServerTimezone: (tz: string) => void
|
setServerTimezone: (tz: string) => void
|
||||||
setAppRequireMfa: (val: boolean) => void
|
setAppRequireMfa: (val: boolean) => void
|
||||||
|
setTripRemindersEnabled: (val: boolean) => void
|
||||||
demoLogin: () => Promise<AuthResponse>
|
demoLogin: () => Promise<AuthResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
hasMapsKey: false,
|
hasMapsKey: false,
|
||||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
appRequireMfa: false,
|
appRequireMfa: false,
|
||||||
|
tripRemindersEnabled: false,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
@@ -224,6 +227,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||||
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||||
|
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface Trip {
|
|||||||
end_date: string
|
end_date: string
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
is_archived: boolean
|
is_archived: boolean
|
||||||
|
reminder_days: number
|
||||||
owner_id: number
|
owner_id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
@@ -433,6 +433,9 @@ function runMigrations(db: Database.Database): void {
|
|||||||
() => {
|
() => {
|
||||||
try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {}
|
try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {}
|
||||||
},
|
},
|
||||||
|
() => {
|
||||||
|
try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function createTables(db: Database.Database): void {
|
|||||||
currency TEXT DEFAULT 'EUR',
|
currency TEXT DEFAULT 'EUR',
|
||||||
cover_image TEXT,
|
cover_image TEXT,
|
||||||
is_archived INTEGER DEFAULT 0,
|
is_archived INTEGER DEFAULT 0,
|
||||||
|
reminder_days INTEGER DEFAULT 3,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function isOidcOnlyConfigured(): boolean {
|
||||||
|
if (process.env.OIDC_ONLY !== 'true') return false;
|
||||||
|
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
function seedAdminAccount(db: Database.Database): void {
|
function seedAdminAccount(db: Database.Database): void {
|
||||||
try {
|
try {
|
||||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||||
if (userCount > 0) return;
|
if (userCount > 0) return;
|
||||||
|
|
||||||
|
if (isOidcOnlyConfigured()) {
|
||||||
|
console.log('');
|
||||||
|
console.log('╔══════════════════════════════════════════════╗');
|
||||||
|
console.log('║ TREK — OIDC-Only Mode ║');
|
||||||
|
console.log('║ First SSO login will become admin. ║');
|
||||||
|
console.log('╚══════════════════════════════════════════════╝');
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const password = crypto.randomBytes(12).toString('base64url');
|
const password = crypto.randomBytes(12).toString('base64url');
|
||||||
const hash = bcrypt.hashSync(password, 12);
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ app.use(enforceGlobalMfaPolicy);
|
|||||||
|
|
||||||
if (res.statusCode >= 500) {
|
if (res.statusCode >= 500) {
|
||||||
_logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
_logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||||
|
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||||
|
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||||
} else if (res.statusCode >= 400) {
|
} else if (res.statusCode >= 400) {
|
||||||
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import fs from 'fs';
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { authenticate, adminOnly } from '../middleware/auth';
|
import { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import { AuthRequest, User, Addon } from '../types';
|
import { AuthRequest, User, Addon } from '../types';
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||||
import { revokeUserSessions } from '../mcp';
|
import { revokeUserSessions } from '../mcp';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -122,8 +122,9 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||||||
action: 'admin.user_update',
|
action: 'admin.user_update',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
details: { fields: changed },
|
details: { targetUser: user.email, fields: changed },
|
||||||
});
|
});
|
||||||
|
logInfo(`Admin ${authReq.user.email} edited user ${user.email} (fields: ${changed.join(', ')})`);
|
||||||
res.json({ user: updated });
|
res.json({ user: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,8 +134,8 @@ router.delete('/users/:id', (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Cannot delete own account' });
|
return res.status(400).json({ error: 'Cannot delete own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id) as { id: number; email: string } | undefined;
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!userToDel) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||||
writeAudit({
|
writeAudit({
|
||||||
@@ -142,7 +143,9 @@ router.delete('/users/:id', (req: Request, res: Response) => {
|
|||||||
action: 'admin.user_delete',
|
action: 'admin.user_delete',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
|
details: { targetUser: userToDel.email },
|
||||||
});
|
});
|
||||||
|
logInfo(`Admin ${authReq.user.email} deleted user ${userToDel.email}`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { revokeUserSessions } from '../mcp';
|
|||||||
import { AuthRequest, User } from '../types';
|
import { AuthRequest, User } from '../types';
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
|
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
|
||||||
|
import { startTripReminders } from '../scheduler';
|
||||||
|
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
|
|
||||||
@@ -185,6 +186,11 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||||
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||||
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
||||||
|
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
||||||
|
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
|
||||||
|
const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
|
||||||
|
const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
|
||||||
|
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
||||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||||
res.json({
|
res.json({
|
||||||
allow_registration: isDemo ? false : allowRegistration,
|
allow_registration: isDemo ? false : allowRegistration,
|
||||||
@@ -202,6 +208,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
demo_password: isDemo ? 'demo12345' : undefined,
|
demo_password: isDemo ? 'demo12345' : undefined,
|
||||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
notification_channel: notifChannel,
|
notification_channel: notifChannel,
|
||||||
|
trip_reminders_enabled: tripRemindersEnabled,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -684,6 +691,12 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
|||||||
details: summary,
|
details: summary,
|
||||||
debugDetails,
|
debugDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
|
||||||
|
if (changedKeys.some(k => notifRelated.includes(k))) {
|
||||||
|
startTripReminders();
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username }).catch(() => {});
|
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,7 +430,7 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
|||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
|
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
|||||||
if (shared && added > 0) {
|
if (shared && added > 0) {
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
|
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
|||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
for (const uid of user_ids) {
|
for (const uid of user_ids) {
|
||||||
if (uid !== authReq.user.id) {
|
if (uid !== authReq.user.id) {
|
||||||
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
|
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat } }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
// Notify trip members about new booking
|
// Notify trip members about new booking
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
|
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking' }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
|
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
|
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { db, canAccessTrip, isOwner } from '../db/database';
|
|||||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest, Trip, User } from '../types';
|
import { AuthRequest, Trip, User } from '../types';
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -125,30 +125,42 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const archived = req.query.archived === '1' ? 1 : 0;
|
const archived = req.query.archived === '1' ? 1 : 0;
|
||||||
const userId = authReq.user.id;
|
const userId = authReq.user.id;
|
||||||
const trips = db.prepare(`
|
const isAdminUser = authReq.user.role === 'admin';
|
||||||
${TRIP_SELECT}
|
const trips = isAdminUser
|
||||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
? db.prepare(`
|
||||||
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
|
${TRIP_SELECT}
|
||||||
ORDER BY t.created_at DESC
|
WHERE t.is_archived = :archived
|
||||||
`).all({ userId, archived });
|
ORDER BY t.created_at DESC
|
||||||
|
`).all({ userId, archived })
|
||||||
|
: db.prepare(`
|
||||||
|
${TRIP_SELECT}
|
||||||
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||||
|
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`).all({ userId, archived });
|
||||||
res.json({ trips });
|
res.json({ trips });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { title, description, start_date, end_date, currency } = req.body;
|
const { title, description, start_date, end_date, currency, reminder_days } = req.body;
|
||||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||||
return res.status(400).json({ error: 'End date must be after start date' });
|
return res.status(400).json({ error: 'End date must be after start date' });
|
||||||
|
|
||||||
|
const rd = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : 3) : 3;
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
|
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
|
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR', rd);
|
||||||
|
|
||||||
const tripId = result.lastInsertRowid;
|
const tripId = result.lastInsertRowid;
|
||||||
generateDays(tripId, start_date, end_date);
|
generateDays(tripId, start_date, end_date);
|
||||||
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title } });
|
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title, reminder_days: rd === 0 ? 'none' : `${rd} days` } });
|
||||||
|
if (rd > 0) {
|
||||||
|
logInfo(`${authReq.user.email} set ${rd}-day reminder for trip "${title}"`);
|
||||||
|
}
|
||||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
|
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
|
||||||
res.status(201).json({ trip });
|
res.status(201).json({ trip });
|
||||||
});
|
});
|
||||||
@@ -156,27 +168,26 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const userId = authReq.user.id;
|
const userId = authReq.user.id;
|
||||||
const trip = db.prepare(`
|
const isAdminUser = authReq.user.role === 'admin';
|
||||||
${TRIP_SELECT}
|
const trip = isAdminUser
|
||||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
? db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: req.params.id })
|
||||||
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
|
: db.prepare(`
|
||||||
`).get({ userId, tripId: req.params.id });
|
${TRIP_SELECT}
|
||||||
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||||
|
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||||
|
`).get({ userId, tripId: req.params.id });
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
res.json({ trip });
|
res.json({ trip });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
|
||||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
return res.status(403).json({ error: 'Only the trip owner can edit trip details' });
|
||||||
|
|
||||||
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
|
|
||||||
if (ownerOnly && !isOwner(req.params.id, authReq.user.id))
|
|
||||||
return res.status(403).json({ error: 'Only the owner can change this setting' });
|
|
||||||
|
|
||||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
|
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
|
const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = req.body;
|
||||||
|
|
||||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||||
return res.status(400).json({ error: 'End date must be after start date' });
|
return res.status(400).json({ error: 'End date must be after start date' });
|
||||||
@@ -188,16 +199,41 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
const newCurrency = currency || trip.currency;
|
const newCurrency = currency || trip.currency;
|
||||||
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
|
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
|
||||||
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
|
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
|
||||||
|
const newReminder = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : (trip as any).reminder_days) : (trip as any).reminder_days;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
|
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
|
||||||
currency=?, is_archived=?, cover_image=?, updated_at=CURRENT_TIMESTAMP
|
currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, req.params.id);
|
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, req.params.id);
|
||||||
|
|
||||||
if (newStart !== trip.start_date || newEnd !== trip.end_date)
|
if (newStart !== trip.start_date || newEnd !== trip.end_date)
|
||||||
generateDays(req.params.id, newStart, newEnd);
|
generateDays(req.params.id, newStart, newEnd);
|
||||||
|
|
||||||
|
const changes: Record<string, unknown> = {};
|
||||||
|
if (title && title !== trip.title) changes.title = title;
|
||||||
|
if (newStart !== trip.start_date) changes.start_date = newStart;
|
||||||
|
if (newEnd !== trip.end_date) changes.end_date = newEnd;
|
||||||
|
if (newReminder !== (trip as any).reminder_days) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`;
|
||||||
|
if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived;
|
||||||
|
|
||||||
|
const isAdminEdit = authReq.user.role === 'admin' && trip.user_id !== authReq.user.id;
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
const ownerEmail = isAdminEdit ? (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email : undefined;
|
||||||
|
writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: newTitle, ...(ownerEmail ? { owner: ownerEmail } : {}), ...changes } });
|
||||||
|
if (isAdminEdit && ownerEmail) {
|
||||||
|
logInfo(`Admin ${authReq.user.email} edited trip "${newTitle}" owned by ${ownerEmail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newReminder !== (trip as any).reminder_days) {
|
||||||
|
if (newReminder > 0) {
|
||||||
|
logInfo(`${authReq.user.email} set ${newReminder}-day reminder for trip "${newTitle}"`);
|
||||||
|
} else {
|
||||||
|
logInfo(`${authReq.user.email} removed reminder for trip "${newTitle}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: req.params.id });
|
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: req.params.id });
|
||||||
res.json({ trip: updatedTrip });
|
res.json({ trip: updatedTrip });
|
||||||
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id'] as string);
|
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id'] as string);
|
||||||
@@ -228,10 +264,16 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
|
|||||||
|
|
||||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (!isOwner(req.params.id, authReq.user.id))
|
if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
|
||||||
return res.status(403).json({ error: 'Only the owner can delete the trip' });
|
return res.status(403).json({ error: 'Only the owner can delete the trip' });
|
||||||
const deletedTripId = Number(req.params.id);
|
const deletedTripId = Number(req.params.id);
|
||||||
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId } });
|
const delTrip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(req.params.id) as { title: string; user_id: number } | undefined;
|
||||||
|
const isAdminDel = authReq.user.role === 'admin' && delTrip && delTrip.user_id !== authReq.user.id;
|
||||||
|
const ownerEmail = isAdminDel ? (db.prepare('SELECT email FROM users WHERE id = ?').get(delTrip!.user_id) as { email: string } | undefined)?.email : undefined;
|
||||||
|
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId, trip: delTrip?.title, ...(ownerEmail ? { owner: ownerEmail } : {}) } });
|
||||||
|
if (isAdminDel && ownerEmail) {
|
||||||
|
logInfo(`Admin ${authReq.user.email} deleted trip "${delTrip!.title}" owned by ${ownerEmail}`);
|
||||||
|
}
|
||||||
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
|
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
|
||||||
@@ -290,7 +332,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
|||||||
// Notify invited user
|
// Notify invited user
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
|
||||||
import('../services/notifications').then(({ notify }) => {
|
import('../services/notifications').then(({ notify }) => {
|
||||||
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, invitee: target.username } }).catch(() => {});
|
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, invitee: target.email } }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ router.post('/invite', (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Notify invited user
|
// Notify invited user
|
||||||
import('../services/notifications').then(({ notify }) => {
|
import('../services/notifications').then(({ notify }) => {
|
||||||
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
|
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.email } }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -160,42 +160,55 @@ let reminderTask: ScheduledTask | null = null;
|
|||||||
function startTripReminders(): void {
|
function startTripReminders(): void {
|
||||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { db } = require('./db/database');
|
||||||
|
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||||
|
const channel = getSetting('notification_channel') || 'none';
|
||||||
|
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||||
|
const hasSmtp = !!(getSetting('smtp_host') || '').trim();
|
||||||
|
const hasWebhook = !!(getSetting('notification_webhook_url') || '').trim();
|
||||||
|
const channelReady = (channel === 'email' && hasSmtp) || (channel === 'webhook' && hasWebhook);
|
||||||
|
|
||||||
|
if (!channelReady || !reminderEnabled) {
|
||||||
|
const { logInfo: li } = require('./services/auditLog');
|
||||||
|
const reason = !channelReady ? `no ${channel === 'none' ? 'notification channel' : channel} configuration` : 'trip reminders disabled in settings';
|
||||||
|
li(`Trip reminders: disabled (${reason})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
||||||
|
const { logInfo: liSetup } = require('./services/auditLog');
|
||||||
|
liSetup(`Trip reminders: enabled via ${channel}${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tz = process.env.TZ || 'UTC';
|
const tz = process.env.TZ || 'UTC';
|
||||||
reminderTask = cron.schedule('0 9 * * *', async () => {
|
reminderTask = cron.schedule('0 9 * * *', async () => {
|
||||||
try {
|
try {
|
||||||
const { db } = require('./db/database');
|
const { db } = require('./db/database');
|
||||||
const { notify } = require('./services/notifications');
|
const { notifyTripMembers } = require('./services/notifications');
|
||||||
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const dateStr = tomorrow.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
const trips = db.prepare(`
|
const trips = db.prepare(`
|
||||||
SELECT t.id, t.title, t.user_id FROM trips t
|
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
||||||
WHERE t.start_date = ?
|
WHERE t.reminder_days > 0
|
||||||
`).all(dateStr) as { id: number; title: string; user_id: number }[];
|
AND t.start_date IS NOT NULL
|
||||||
|
AND t.start_date = date('now', '+' || t.reminder_days || ' days')
|
||||||
|
`).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
|
||||||
|
|
||||||
for (const trip of trips) {
|
for (const trip of trips) {
|
||||||
await notify({ userId: trip.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
|
await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
|
||||||
|
|
||||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[];
|
|
||||||
for (const m of members) {
|
|
||||||
await notify({ userId: m.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { logInfo: li } = require('./services/auditLog');
|
||||||
if (trips.length > 0) {
|
if (trips.length > 0) {
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
li(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
|
||||||
li(`Trip reminders sent for ${trips.length} trip(s) starting ${dateStr}`);
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const { logError: le } = require('./services/auditLog');
|
const { logError: le } = require('./services/auditLog');
|
||||||
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
|
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||||
}
|
}
|
||||||
}, { timezone: tz });
|
}, { timezone: tz });
|
||||||
|
|
||||||
const { logInfo: li4 } = require('./services/auditLog');
|
|
||||||
li4(`Trip reminders scheduled: daily at 09:00 (${tz})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop(): void {
|
function stop(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user