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:
Andrei Brebene
2026-03-31 16:42:37 +03:00
parent 9b2f083e4b
commit 7522f396e7
31 changed files with 378 additions and 83 deletions

View File

@@ -71,18 +71,19 @@ function RootRedirect() {
}
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()
useEffect(() => {
if (token) {
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?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
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 CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
@@ -23,13 +23,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const toast = useToast()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
reminder_days: 0 as number,
})
const [customReminder, setCustomReminder] = useState(false)
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
@@ -41,25 +45,40 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
useEffect(() => {
if (trip) {
const rd = trip.reminder_days ?? 3
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
reminder_days: rd,
})
setCustomReminder(![0, 1, 3, 9].includes(rd))
setCoverPreview(trip.cover_image || null)
} else {
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
setCustomReminder(false)
setCoverPreview(null)
}
setPendingCoverFile(null)
setSelectedMembers([])
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) {
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
}
}, [trip, isOpen])
useEffect(() => {
if (!trip && isOpen) {
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
}
}, [tripRemindersEnabled])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -74,6 +93,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
reminder_days: formData.reminder_days,
})
// Add selected members for newly created trips
if (selectedMembers.length > 0 && result?.trip?.id) {
@@ -272,6 +292,59 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</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 */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
@@ -312,11 +385,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</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>
</Modal>
)

View File

@@ -30,6 +30,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...',
'common.saved': 'تم الحفظ',
'trips.reminder': 'تذكير',
'trips.reminderNone': 'بدون',
'trips.reminderDay': 'يوم',
'trips.reminderDays': 'أيام',
'trips.reminderCustom': 'مخصص',
'trips.reminderDaysBefore': 'أيام قبل المغادرة',
'trips.reminderDisabledHint': 'تذكيرات الرحلة معطلة. قم بتفعيلها من الإدارة > الإعدادات > الإشعارات.',
'common.update': 'تحديث',
'common.change': 'تغيير',
'common.uploading': 'جارٍ الرفع...',
@@ -165,6 +172,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'أحداث الإشعارات',
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
@@ -173,6 +181,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.smtp.title': 'البريد والإشعارات',
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
'admin.smtp.testButton': 'إرسال بريد تجريبي',
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',

View File

@@ -26,6 +26,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Senha',
'common.saving': 'Salvando...',
'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.change': 'Alterar',
'common.uploading': 'Enviando…',
@@ -160,6 +167,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificação',
'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.saved': 'Configurações de notificação salvas',
'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.hint': 'Configuração SMTP para envio de notificações por e-mail.',
'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.testFailed': 'Falha ao enviar e-mail de teste',
'dayplan.icsTooltip': 'Exportar calendário (ICS)',

View File

@@ -26,6 +26,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Heslo',
'common.saving': 'Ukládání...',
'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.change': 'Změnit',
'common.uploading': 'Nahrávání…',
@@ -246,6 +253,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.webhook': 'Webhook',
'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.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
'admin.notifications.save': 'Uložit nastavení oznámení',
'admin.notifications.saved': 'Nastavení oznámení uloženo',
'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.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
'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.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',

View File

@@ -26,6 +26,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Passwort',
'common.saving': 'Speichern...',
'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.change': 'Ändern',
'common.uploading': 'Hochladen…',
@@ -160,6 +167,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Benachrichtigungsereignisse',
'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.saved': 'Benachrichtigungseinstellungen gespeichert',
'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.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
'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.testFailed': 'Test-E-Mail fehlgeschlagen',
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',

View File

@@ -26,6 +26,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Password',
'common.saving': 'Saving...',
'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.change': 'Change',
'common.uploading': 'Uploading…',
@@ -157,6 +164,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Notification Events',
'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.saved': 'Notification settings saved',
'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.hint': 'SMTP configuration for sending email notifications.',
'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.testFailed': 'Test email failed',
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',

View File

@@ -26,6 +26,13 @@ const es: Record<string, string> = {
'common.password': 'Contraseña',
'common.saving': 'Guardando...',
'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.change': 'Cambiar',
'common.uploading': 'Subiendo…',
@@ -161,6 +168,7 @@ const es: Record<string, string> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificación',
'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.saved': 'Configuración de notificaciones guardada',
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
@@ -169,6 +177,7 @@ const es: Record<string, string> = {
'admin.smtp.title': 'Correo y notificaciones',
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
'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.testFailed': 'Error al enviar correo de prueba',
'dayplan.icsTooltip': 'Exportar calendario (ICS)',

View File

@@ -26,6 +26,13 @@ const fr: Record<string, string> = {
'common.password': 'Mot de passe',
'common.saving': 'Enregistrement…',
'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.change': 'Modifier',
'common.uploading': 'Import en cours…',
@@ -160,6 +167,7 @@ const fr: Record<string, string> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Événements de notification',
'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.saved': 'Paramètres de notification enregistrés',
'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.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
'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.testFailed': 'Échec de l\'e-mail de test',
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',

View File

@@ -26,6 +26,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Jelszó',
'common.saving': 'Mentés...',
'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.change': 'Módosítá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.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.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.saved': 'Értesítési beállítások mentve',
'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.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
'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.testFailed': 'Teszt e-mail küldése sikertelen',
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',

View File

@@ -26,6 +26,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Password',
'common.saving': 'Salvataggio...',
'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.change': 'Cambia',
'common.uploading': 'Caricamento…',
@@ -245,6 +252,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventi di notifica',
'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.saved': 'Impostazioni notifiche salvate',
'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.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
'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.testFailed': 'Invio email di prova fallito',
'dayplan.icsTooltip': 'Esporta calendario (ICS)',

View File

@@ -26,6 +26,13 @@ const nl: Record<string, string> = {
'common.password': 'Wachtwoord',
'common.saving': 'Opslaan...',
'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.change': 'Wijzigen',
'common.uploading': 'Uploaden…',
@@ -160,6 +167,7 @@ const nl: Record<string, string> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Meldingsgebeurtenissen',
'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.saved': 'Meldingsinstellingen opgeslagen',
'admin.notifications.testWebhook': 'Testwebhook verzenden',
@@ -168,6 +176,7 @@ const nl: Record<string, string> = {
'admin.smtp.title': 'E-mail en meldingen',
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
'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.testFailed': 'Test-e-mail mislukt',
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',

View File

@@ -26,6 +26,13 @@ const ru: Record<string, string> = {
'common.password': 'Пароль',
'common.saving': 'Сохранение...',
'common.saved': 'Сохранено',
'trips.reminder': 'Напоминание',
'trips.reminderNone': 'Нет',
'trips.reminderDay': 'день',
'trips.reminderDays': 'дней',
'trips.reminderCustom': 'Другое',
'trips.reminderDaysBefore': 'дней до отъезда',
'trips.reminderDisabledHint': 'Напоминания о поездках отключены. Включите их в Админ > Настройки > Уведомления.',
'common.update': 'Обновить',
'common.change': 'Изменить',
'common.uploading': 'Загрузка…',
@@ -160,6 +167,7 @@ const ru: Record<string, string> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'События уведомлений',
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.',
'admin.notifications.save': 'Сохранить настройки уведомлений',
'admin.notifications.saved': 'Настройки уведомлений сохранены',
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
@@ -168,6 +176,7 @@ const ru: Record<string, string> = {
'admin.smtp.title': 'Почта и уведомления',
'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
'admin.smtp.testButton': 'Отправить тестовое письмо',
'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).',
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',

View File

@@ -26,6 +26,13 @@ const zh: Record<string, string> = {
'common.password': '密码',
'common.saving': '保存中...',
'common.saved': '已保存',
'trips.reminder': '提醒',
'trips.reminderNone': '无',
'trips.reminderDay': '天',
'trips.reminderDays': '天',
'trips.reminderCustom': '自定义',
'trips.reminderDaysBefore': '天前提醒',
'trips.reminderDisabledHint': '旅行提醒已禁用。请在管理 > 设置 > 通知中启用。',
'common.update': '更新',
'common.change': '修改',
'common.uploading': '上传中…',
@@ -160,6 +167,7 @@ const zh: Record<string, string> = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': '通知事件',
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
'admin.notifications.configureFirst': '请先在下方配置 SMTP 或 Webhook然后启用事件。',
'admin.notifications.save': '保存通知设置',
'admin.notifications.saved': '通知设置已保存',
'admin.notifications.testWebhook': '发送测试 Webhook',
@@ -168,6 +176,7 @@ const zh: Record<string, string> = {
'admin.smtp.title': '邮件与通知',
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
'admin.smtp.testButton': '发送测试邮件',
'admin.webhook.hint': '向外部 Webhook 发送通知Discord、Slack 等)。',
'admin.smtp.testSuccess': '测试邮件发送成功',
'admin.smtp.testFailed': '测试邮件发送失败',
'dayplan.icsTooltip': '导出日历 (ICS)',

View File

@@ -122,7 +122,7 @@ export default function AdminPage(): React.ReactElement {
const [updating, setUpdating] = useState<boolean>(false)
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 toast = useToast()
@@ -999,9 +999,15 @@ export default function AdminPage(): React.ReactElement {
</div>
{/* Notification event toggles — shown when any channel is active */}
{(smtpValues.notification_channel || 'none') !== 'none' && (
<div className="space-y-2 pt-2 border-t border-slate-100">
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
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>
{!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>
{[
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
@@ -1029,7 +1035,8 @@ export default function AdminPage(): React.ReactElement {
)
})}
</div>
)}
)
})()}
{/* Email (SMTP) settings — shown when email channel is active */}
{(smtpValues.notification_channel || 'none') === 'email' && (
@@ -1072,7 +1079,7 @@ export default function AdminPage(): React.ReactElement {
{/* Webhook settings — shown when webhook channel is active */}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
<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>
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
<input
@@ -1097,6 +1104,9 @@ export default function AdminPage(): React.ReactElement {
try {
await authApi.updateAppSettings(payload)
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')) }
}}
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"

View File

@@ -145,9 +145,10 @@ interface TripCardProps {
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
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 coverBg = trip.cover_image
@@ -186,12 +187,14 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
</div>
{/* Top-right actions */}
{(!!trip.is_owner || isAdmin) && (
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
<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={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
</div>
)}
{/* Bottom content */}
<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 ────────────────────────────────────────────────────────
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 [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} />
</div>
{(!!trip.is_owner || isAdmin) && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
onClick={e => e.stopPropagation()}>
<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={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
</div>
)}
</div>
</div>
)
}
// ── 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 [hovered, setHovered] = useState(false)
@@ -403,11 +408,13 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
</div>
{/* Actions */}
{(!!trip.is_owner || isAdmin) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
</div>
)}
</div>
)
}
@@ -421,9 +428,10 @@ interface ArchivedRowProps {
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => 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 (
<div onClick={() => onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -449,6 +457,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
</div>
)}
</div>
{(!!trip.is_owner || isAdmin) && (
<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' }}
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} />
</button>
</div>
)}
</div>
)
}
@@ -539,7 +549,8 @@ export default function DashboardPage(): React.ReactElement {
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const { demoMode, user } = useAuthStore()
const isAdmin = user?.role === 'admin'
const { settings, updateSetting } = useSettingsStore()
const dm = settings.dark_mode
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' && (
<SpotlightCard
trip={spotlight}
t={t} locale={locale} dark={dark}
t={t} locale={locale} dark={dark} isAdmin={isAdmin}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -797,7 +808,7 @@ export default function DashboardPage(): React.ReactElement {
<TripCard
key={trip.id}
trip={trip}
t={t} locale={locale}
t={t} locale={locale} isAdmin={isAdmin}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -811,7 +822,7 @@ export default function DashboardPage(): React.ReactElement {
<TripListItem
key={trip.id}
trip={trip}
t={t} locale={locale}
t={t} locale={locale} isAdmin={isAdmin}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -841,7 +852,7 @@ export default function DashboardPage(): React.ReactElement {
<ArchivedRow
key={trip.id}
trip={trip}
t={t} locale={locale}
t={t} locale={locale} isAdmin={isAdmin}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onUnarchive={handleUnarchive}
onDelete={handleDelete}

View File

@@ -26,6 +26,7 @@ interface AuthState {
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
tripRemindersEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
@@ -42,6 +43,7 @@ interface AuthState {
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -55,6 +57,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
@@ -224,6 +227,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
demoLogin: async () => {
set({ isLoading: true, error: null })

View File

@@ -22,6 +22,7 @@ export interface Trip {
end_date: string
cover_url: string | null
is_archived: boolean
reminder_days: number
owner_id: number
created_at: string
updated_at: string

View File

@@ -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 trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {}
},
];
if (currentVersion < migrations.length) {

View File

@@ -41,6 +41,7 @@ function createTables(db: Database.Database): void {
currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
reminder_days INTEGER DEFAULT 3,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,11 +1,26 @@
import Database from 'better-sqlite3';
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 {
try {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
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 password = crypto.randomBytes(12).toString('base64url');
const hash = bcrypt.hashSync(password, 12);

View File

@@ -112,6 +112,8 @@ app.use(enforceGlobalMfaPolicy);
if (res.statusCode >= 500) {
_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) {
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
}

View File

@@ -7,7 +7,7 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
@@ -122,8 +122,9 @@ router.put('/users/:id', (req: Request, res: Response) => {
action: 'admin.user_update',
resource: String(req.params.id),
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 });
});
@@ -133,8 +134,8 @@ router.delete('/users/:id', (req: Request, res: Response) => {
return res.status(400).json({ error: 'Cannot delete own account' });
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id) as { id: number; email: string } | undefined;
if (!userToDel) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
writeAudit({
@@ -142,7 +143,9 @@ router.delete('/users/:id', (req: Request, res: Response) => {
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { targetUser: userToDel.email },
});
logInfo(`Admin ${authReq.user.email} deleted user ${userToDel.email}`);
res.json({ success: true });
});

View File

@@ -18,6 +18,7 @@ import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
import { startTripReminders } from '../scheduler';
authenticator.options = { window: 1 };
@@ -185,6 +186,11 @@ router.get('/app-config', (_req: Request, res: Response) => {
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
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 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());
res.json({
allow_registration: isDemo ? false : allowRegistration,
@@ -202,6 +208,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
});
});
@@ -684,6 +691,12 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
details: summary,
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 });
});

View File

@@ -129,7 +129,7 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
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 }) => {
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();
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(() => {});
});
});

View File

@@ -186,7 +186,7 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
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(() => {});
});
}
});

View File

@@ -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;
for (const uid of user_ids) {
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(() => {});
}
}
});

View File

@@ -105,7 +105,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Notify trip members about new booking
import('../services/notifications').then(({ notifyTripMembers }) => {
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 }) => {
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 }) => {
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(() => {});
});
});

View File

@@ -7,7 +7,7 @@ import { db, canAccessTrip, isOwner } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
const router = express.Router();
@@ -125,30 +125,42 @@ router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const archived = req.query.archived === '1' ? 1 : 0;
const userId = authReq.user.id;
const trips = 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 });
const isAdminUser = authReq.user.role === 'admin';
const trips = isAdminUser
? db.prepare(`
${TRIP_SELECT}
WHERE t.is_archived = :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 });
});
router.post('/', authenticate, (req: Request, res: Response) => {
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 (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' });
const rd = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : 3) : 3;
const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
VALUES (?, ?, ?, ?, ?, ?)
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR', rd);
const tripId = result.lastInsertRowid;
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 });
res.status(201).json({ trip });
});
@@ -156,27 +168,26 @@ router.post('/', authenticate, (req: Request, res: Response) => {
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
const trip = db.prepare(`
${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 });
const isAdminUser = authReq.user.role === 'admin';
const trip = isAdminUser
? db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: req.params.id })
: db.prepare(`
${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' });
res.json({ trip });
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
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' });
if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
return res.status(403).json({ error: 'Only the trip owner can edit trip details' });
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' });
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))
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 newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
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(`
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=?
`).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)
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 });
res.json({ trip: updatedTrip });
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) => {
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' });
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);
res.json({ success: true });
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
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
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 } });

View File

@@ -351,7 +351,7 @@ router.post('/invite', (req: Request, res: Response) => {
// Notify invited user
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 });

View File

@@ -160,42 +160,55 @@ let reminderTask: ScheduledTask | null = null;
function startTripReminders(): void {
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';
reminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { db } = require('./db/database');
const { notify } = require('./services/notifications');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().split('T')[0];
const { notifyTripMembers } = require('./services/notifications');
const trips = db.prepare(`
SELECT t.id, t.title, t.user_id FROM trips t
WHERE t.start_date = ?
`).all(dateStr) as { id: number; title: string; user_id: number }[];
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
WHERE t.reminder_days > 0
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) {
await notify({ userId: trip.user_id, event: 'trip_reminder', params: { 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(() => {});
}
await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
}
const { logInfo: li } = require('./services/auditLog');
if (trips.length > 0) {
const { logInfo: li } = require('./services/auditLog');
li(`Trip reminders sent for ${trips.length} trip(s) starting ${dateStr}`);
li(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
const { logInfo: li4 } = require('./services/auditLog');
li4(`Trip reminders scheduled: daily at 09:00 (${tz})`);
}
function stop(): void {