diff --git a/Dockerfile b/Dockerfile index 332e545..1dd5707 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Stage 1: React Client bauen +# Stage 1: Build React client FROM node:22-alpine AS client-builder WORKDIR /app/client COPY client/package*.json ./ @@ -6,34 +6,25 @@ RUN npm ci COPY client/ ./ RUN npm run build -# Stage 2: Produktions-Server +# Stage 2: Production server FROM node:22-alpine WORKDIR /app -# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools) +# Timezone support + native deps (better-sqlite3 needs build tools) COPY server/package*.json ./ -RUN apk add --no-cache tzdata su-exec python3 make g++ && \ +RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \ npm ci --production && \ apk del python3 make g++ -# Server-Code kopieren COPY server/ ./ - -# Gebauten Client kopieren COPY --from=client-builder /app/client/dist ./public - -# Fonts für PDF-Export kopieren COPY --from=client-builder /app/client/public/fonts ./public/fonts -# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads) -RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ - mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data +RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ + mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \ + chown -R node:node /app -# Fix permissions on mounted volumes at runtime and run as node user -RUN chown -R node:node /app - -# Umgebung setzen ENV NODE_ENV=production ENV PORT=3000 @@ -42,5 +33,5 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget -qO- http://localhost:3000/api/health || exit 1 -# Entrypoint: fix volume permissions then start as node -CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null; exec su-exec node node --import tsx src/index.ts"] +ENTRYPOINT ["dumb-init", "--"] +CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"] diff --git a/README.md b/README.md index ac97a5f..65755a5 100644 --- a/README.md +++ b/README.md @@ -120,20 +120,36 @@ services: app: image: mauriceboe/trek:latest container_name: trek + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - SETUID + - SETGID + tmpfs: + - /tmp:noexec,nosuid,size=64m ports: - "3000:3000" environment: - NODE_ENV=production - PORT=3000 - # - OIDC_ISSUER=https://auth.example.com - # - OIDC_CLIENT_ID=trek - # - OIDC_CLIENT_SECRET=supersecret - # - OIDC_DISPLAY_NAME="SSO" - # - OIDC_ONLY=true # disable password auth entirely + - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links + - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) + - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details volumes: - ./data:/app/data - ./uploads:/app/uploads restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s ``` ```bash @@ -226,17 +242,23 @@ trek.yourdomain.com { | Variable | Description | Default | |----------|-------------|---------| +| **Core** | | | | `PORT` | Server port | `3000` | -| `NODE_ENV` | Environment | `production` | -| `JWT_SECRET` | JWT signing secret | Auto-generated | -| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` | -| `OIDC_ISSUER` | OIDC provider URL | — | +| `NODE_ENV` | Environment (`production` / `development`) | `production` | +| `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated | +| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | +| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | +| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | +| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` | +| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` | +| **OIDC / SSO** | | | +| `OIDC_ISSUER` | OpenID Connect provider URL | — | | `OIDC_CLIENT_ID` | OIDC client ID | — | | `OIDC_CLIENT_SECRET` | OIDC client secret | — | -| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` | -| `OIDC_ONLY` | Disable password auth | `false` | -| `TRUST_PROXY` | Trust proxy headers | `1` | -| `DEMO_MODE` | Enable demo mode | `false` | +| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | +| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` | +| **Other** | | | +| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | ## Optional API Keys @@ -261,6 +283,7 @@ docker build -t trek . - **Database**: SQLite, stored in `./data/travel.db` - **Uploads**: Stored in `./uploads/` +- **Logs**: `./data/logs/trek.log` (auto-rotated) - **Backups**: Create and restore via Admin Panel - **Auto-Backups**: Configurable schedule and retention in Admin Panel diff --git a/client/src/App.tsx b/client/src/App.tsx index 6a434f6..c1b1be6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index cd97c3e..0fd46f4 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -316,6 +316,7 @@ export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data), + testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data), } export default apiClient diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx index f36d69e..2e6e4fa 100644 --- a/client/src/components/Admin/AuditLogPanel.tsx +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -15,7 +15,11 @@ interface AuditEntry { ip: string | null } -export default function AuditLogPanel(): React.ReactElement { +interface AuditLogPanelProps { + serverTimezone?: string +} + +export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement { const { t, locale } = useTranslation() const [entries, setEntries] = useState([]) const [total, setTotal] = useState(0) @@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement { const fmtTime = (iso: string) => { try { - return new Date(iso).toLocaleString(locale, { + return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, { dateStyle: 'short', timeStyle: 'medium', + timeZone: serverTimezone || undefined, }) } catch { return iso diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index e8c7808..b851f57 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -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 + {/* Reminder — only visible to owner (or when creating) */} + {(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && ( +
+ + {!tripRemindersEnabled ? ( +

+ {t('trips.reminderDisabledHint')} +

+ ) : ( + <> +
+ {[ + { 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 => ( + + ))} + +
+ {customReminder && ( +
+ 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" /> + {t('trips.reminderDaysBefore')} +
+ )} + + )} +
+ )} + {/* Members — only for new trips */} {!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
@@ -312,11 +385,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
)} - {!formData.start_date && !formData.end_date && ( -

- {t('dashboard.noDateHint')} -

- )} ) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 890a6fc..068cae2 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -29,6 +29,14 @@ const ar: Record = { 'common.email': 'البريد الإلكتروني', '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': 'جارٍ الرفع...', @@ -154,9 +162,26 @@ const ar: Record = { 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)', 'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات', 'settings.notifyWebhook': 'إشعارات Webhook', + 'settings.notificationsDisabled': 'الإشعارات غير مكوّنة. اطلب من المسؤول تفعيل إشعارات البريد الإلكتروني أو Webhook.', + 'settings.notificationsActive': 'القناة النشطة', + 'settings.notificationsManagedByAdmin': 'يتم تكوين أحداث الإشعارات بواسطة المسؤول.', + 'admin.notifications.title': 'الإشعارات', + 'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.', + 'admin.notifications.none': 'معطّل', + 'admin.notifications.email': 'البريد الإلكتروني (SMTP)', + '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 تجريبي', + 'admin.notifications.testWebhookSuccess': 'تم إرسال webhook التجريبي بنجاح', + 'admin.notifications.testWebhookFailed': 'فشل إرسال webhook التجريبي', 'admin.smtp.title': 'البريد والإشعارات', - 'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.', + 'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.', 'admin.smtp.testButton': 'إرسال بريد تجريبي', + 'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).', 'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح', 'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي', 'dayplan.icsTooltip': 'تصدير التقويم (ICS)', @@ -301,6 +326,8 @@ const ar: Record = { 'login.signIn': 'دخول', 'login.createAdmin': 'إنشاء حساب مسؤول', 'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.', + 'login.setNewPassword': 'تعيين كلمة مرور جديدة', + 'login.setNewPasswordHint': 'يجب عليك تغيير كلمة المرور قبل المتابعة.', 'login.createAccount': 'إنشاء حساب', 'login.createAccountHint': 'سجّل حسابًا جديدًا.', 'login.creating': 'جارٍ الإنشاء…', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 8f6e118..ef333d9 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -25,6 +25,14 @@ const br: Record = { 'common.email': 'E-mail', '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…', @@ -149,9 +157,26 @@ const br: Record = { 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)', 'settings.notifyPackingTagged': 'Lista de mala: atribuições', 'settings.notifyWebhook': 'Notificações webhook', + 'settings.notificationsDisabled': 'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.', + 'settings.notificationsActive': 'Canal ativo', + 'settings.notificationsManagedByAdmin': 'Os eventos de notificação são configurados pelo administrador.', + 'admin.notifications.title': 'Notificações', + 'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.', + 'admin.notifications.none': 'Desativado', + 'admin.notifications.email': 'E-mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Webhook de teste enviado com sucesso', + 'admin.notifications.testWebhookFailed': 'Falha ao enviar webhook de teste', 'admin.smtp.title': 'E-mail e notificações', - 'admin.smtp.hint': 'Configuração SMTP para notificações por e-mail. Opcional: URL webhook para Discord, Slack, etc.', + '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)', @@ -296,6 +321,8 @@ const br: Record = { 'login.signIn': 'Entrar', 'login.createAdmin': 'Criar conta de administrador', 'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.', + 'login.setNewPassword': 'Definir nova senha', + 'login.setNewPasswordHint': 'Você deve alterar sua senha antes de continuar.', 'login.createAccount': 'Criar conta', 'login.createAccountHint': 'Cadastre uma nova conta.', 'login.creating': 'Criando…', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 3e15ffa..1bbdea4 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -25,6 +25,14 @@ const cs: Record = { 'common.email': 'E-mail', '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í…', @@ -150,6 +158,9 @@ const cs: Record = { 'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)', 'settings.notifyPackingTagged': 'Seznam balení: přiřazení', 'settings.notifyWebhook': 'Webhook oznámení', + 'settings.notificationsDisabled': 'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.', + 'settings.notificationsActive': 'Aktivní kanál', + 'settings.notificationsManagedByAdmin': 'Události oznámení jsou konfigurovány administrátorem.', 'settings.on': 'Zapnuto', 'settings.off': 'Vypnuto', 'settings.mcp.title': 'Konfigurace MCP', @@ -235,9 +246,23 @@ const cs: Record = { 'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto', 'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto', 'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu', + 'admin.notifications.title': 'Oznámení', + 'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.', + 'admin.notifications.none': 'Vypnuto', + 'admin.notifications.email': 'E-mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Testovací webhook úspěšně odeslán', + 'admin.notifications.testWebhookFailed': 'Odeslání testovacího webhooku se nezdařilo', 'admin.smtp.title': 'E-mail a oznámení', - 'admin.smtp.hint': 'Konfigurace SMTP pro e-mailová oznámení. Volitelně: Webhook URL pro Discord, Slack apod.', + '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)', @@ -297,6 +322,8 @@ const cs: Record = { 'login.signIn': 'Přihlásit se', 'login.createAdmin': 'Vytvořit účet administrátora', 'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.', + 'login.setNewPassword': 'Nastavit nové heslo', + 'login.setNewPasswordHint': 'Před pokračováním musíte změnit heslo.', 'login.createAccount': 'Vytvořit účet', 'login.createAccountHint': 'Zaregistrujte si nový účet.', 'login.creating': 'Vytváření…', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index e732134..02216a3 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -25,6 +25,14 @@ const de: Record = { 'common.email': 'E-Mail', '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…', @@ -149,9 +157,26 @@ const de: Record = { 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)', 'settings.notifyPackingTagged': 'Packliste: Zuweisungen', 'settings.notifyWebhook': 'Webhook-Benachrichtigungen', + 'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.', + 'settings.notificationsActive': 'Aktiver Kanal', + 'settings.notificationsManagedByAdmin': 'Benachrichtigungsereignisse werden vom Administrator konfiguriert.', + 'admin.notifications.title': 'Benachrichtigungen', + 'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.', + 'admin.notifications.none': 'Deaktiviert', + 'admin.notifications.email': 'E-Mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Test-Webhook erfolgreich gesendet', + 'admin.notifications.testWebhookFailed': 'Test-Webhook fehlgeschlagen', 'admin.smtp.title': 'E-Mail & Benachrichtigungen', - 'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.', + '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)', @@ -296,6 +321,8 @@ const de: Record = { 'login.signIn': 'Anmelden', 'login.createAdmin': 'Admin-Konto erstellen', 'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.', + 'login.setNewPassword': 'Neues Passwort festlegen', + 'login.setNewPasswordHint': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können.', 'login.createAccount': 'Konto erstellen', 'login.createAccountHint': 'Neues Konto registrieren.', 'login.creating': 'Erstelle…', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 092298b..1d58761 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -25,6 +25,14 @@ const en: Record = { 'common.email': 'Email', '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…', @@ -149,11 +157,28 @@ const en: Record = { 'settings.notifyCollabMessage': 'Chat messages (Collab)', 'settings.notifyPackingTagged': 'Packing list: assignments', 'settings.notifyWebhook': 'Webhook notifications', + 'admin.notifications.title': 'Notifications', + 'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.', + 'admin.notifications.none': 'Disabled', + 'admin.notifications.email': 'Email (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully', + 'admin.notifications.testWebhookFailed': 'Test webhook failed', 'admin.smtp.title': 'Email & Notifications', - 'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.', + '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.', + 'settings.notificationsActive': 'Active channel', + 'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.', 'dayplan.icsTooltip': 'Export calendar (ICS)', 'share.linkTitle': 'Public Link', 'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.', @@ -227,6 +252,7 @@ const en: Record = { 'settings.passwordMismatch': 'Passwords do not match', 'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number', 'settings.passwordChanged': 'Password changed successfully', + 'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.', 'settings.deleteAccount': 'Delete account', 'settings.deleteAccountTitle': 'Delete your account?', 'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.', @@ -296,6 +322,8 @@ const en: Record = { 'login.signIn': 'Sign In', 'login.createAdmin': 'Create Admin Account', 'login.createAdminHint': 'Set up the first admin account for TREK.', + 'login.setNewPassword': 'Set New Password', + 'login.setNewPasswordHint': 'You must change your password before continuing.', 'login.createAccount': 'Create Account', 'login.createAccountHint': 'Register a new account.', 'login.creating': 'Creating…', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 05d5819..4780fe3 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -25,6 +25,14 @@ const es: Record = { 'common.email': 'Correo', '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…', @@ -150,9 +158,26 @@ const es: Record = { 'settings.notifyCollabMessage': 'Mensajes de chat (Collab)', 'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones', 'settings.notifyWebhook': 'Notificaciones webhook', + 'settings.notificationsDisabled': 'Las notificaciones no están configuradas. Pida a un administrador que active las notificaciones por correo o webhook.', + 'settings.notificationsActive': 'Canal activo', + 'settings.notificationsManagedByAdmin': 'Los eventos de notificación son configurados por el administrador.', + 'admin.notifications.title': 'Notificaciones', + 'admin.notifications.hint': 'Elija un canal de notificación. Solo uno puede estar activo a la vez.', + 'admin.notifications.none': 'Desactivado', + 'admin.notifications.email': 'Correo (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Webhook de prueba enviado correctamente', + 'admin.notifications.testWebhookFailed': 'Error al enviar webhook de prueba', 'admin.smtp.title': 'Correo y notificaciones', - 'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.', + '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)', @@ -295,6 +320,8 @@ const es: Record = { 'login.signIn': 'Entrar', 'login.createAdmin': 'Crear cuenta de administrador', 'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.', + 'login.setNewPassword': 'Establecer nueva contraseña', + 'login.setNewPasswordHint': 'Debe cambiar su contraseña antes de continuar.', 'login.createAccount': 'Crear cuenta', 'login.createAccountHint': 'Crea una cuenta nueva.', 'login.creating': 'Creando…', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 142e9eb..19bb83d 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -25,6 +25,14 @@ const fr: Record = { 'common.email': 'E-mail', '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…', @@ -149,9 +157,26 @@ const fr: Record = { 'settings.notifyCollabMessage': 'Messages de chat (Collab)', 'settings.notifyPackingTagged': 'Liste de bagages : attributions', 'settings.notifyWebhook': 'Notifications webhook', + 'settings.notificationsDisabled': 'Les notifications ne sont pas configurées. Demandez à un administrateur d\'activer les notifications par e-mail ou webhook.', + 'settings.notificationsActive': 'Canal actif', + 'settings.notificationsManagedByAdmin': 'Les événements de notification sont configurés par votre administrateur.', + 'admin.notifications.title': 'Notifications', + 'admin.notifications.hint': 'Choisissez un canal de notification. Un seul peut être actif à la fois.', + 'admin.notifications.none': 'Désactivé', + 'admin.notifications.email': 'E-mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Webhook de test envoyé avec succès', + 'admin.notifications.testWebhookFailed': 'Échec du webhook de test', 'admin.smtp.title': 'E-mail et notifications', - 'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.', + '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)', @@ -296,6 +321,8 @@ const fr: Record = { 'login.signIn': 'Se connecter', 'login.createAdmin': 'Créer un compte administrateur', 'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.', + 'login.setNewPassword': 'Définir un nouveau mot de passe', + 'login.setNewPasswordHint': 'Vous devez changer votre mot de passe avant de continuer.', 'login.createAccount': 'Créer un compte', 'login.createAccountHint': 'Créez un nouveau compte.', 'login.creating': 'Création…', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 0291dfa..83ffbae 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -25,6 +25,14 @@ const hu: Record = { 'common.email': 'E-mail', '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…', @@ -149,6 +157,9 @@ const hu: Record = { 'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)', 'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések', 'settings.notifyWebhook': 'Webhook értesítések', + 'settings.notificationsDisabled': 'Az értesítések nincsenek beállítva. Kérje meg a rendszergazdát, hogy engedélyezze az e-mail vagy webhook értesítéseket.', + 'settings.notificationsActive': 'Aktív csatorna', + 'settings.notificationsManagedByAdmin': 'Az értesítési eseményeket az adminisztrátor konfigurálja.', 'settings.on': 'Be', 'settings.off': 'Ki', 'settings.mcp.title': 'MCP konfiguráció', @@ -234,9 +245,23 @@ const hu: Record = { 'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve', 'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva', 'settings.mfa.demoBlocked': 'Demo módban nem érhető el', + 'admin.notifications.title': 'Értesítések', + 'admin.notifications.hint': 'Válasszon értesítési csatornát. Egyszerre csak egy lehet aktív.', + 'admin.notifications.none': 'Kikapcsolva', + 'admin.notifications.email': 'E-mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Teszt webhook sikeresen elküldve', + 'admin.notifications.testWebhookFailed': 'Teszt webhook küldése sikertelen', 'admin.smtp.title': 'E-mail és értesítések', - 'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítésekhez. Opcionális: Webhook URL Discordhoz, Slackhez stb.', + '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)', @@ -296,6 +321,8 @@ const hu: Record = { 'login.signIn': 'Bejelentkezés', 'login.createAdmin': 'Admin fiók létrehozása', 'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.', + 'login.setNewPassword': 'Új jelszó beállítása', + 'login.setNewPasswordHint': 'A folytatás előtt meg kell változtatnia a jelszavát.', 'login.createAccount': 'Fiók létrehozása', 'login.createAccountHint': 'Új fiók regisztrálása.', 'login.creating': 'Létrehozás…', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 5645c67..913219a 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -25,6 +25,14 @@ const it: Record = { 'common.email': 'Email', '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…', @@ -149,6 +157,9 @@ const it: Record = { 'settings.notifyCollabMessage': 'Messaggi chat (Collab)', 'settings.notifyPackingTagged': 'Lista valigia: assegnazioni', 'settings.notifyWebhook': 'Notifiche webhook', + 'settings.notificationsDisabled': 'Le notifiche non sono configurate. Chiedi a un amministratore di abilitare le notifiche e-mail o webhook.', + 'settings.notificationsActive': 'Canale attivo', + 'settings.notificationsManagedByAdmin': 'Gli eventi di notifica sono configurati dall\'amministratore.', 'settings.on': 'On', 'settings.off': 'Off', 'settings.mcp.title': 'Configurazione MCP', @@ -234,9 +245,23 @@ const it: Record = { 'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata', 'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata', 'settings.mfa.demoBlocked': 'Non disponibile in modalità demo', + 'admin.notifications.title': 'Notifiche', + 'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.', + 'admin.notifications.none': 'Disattivato', + 'admin.notifications.email': 'E-mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Webhook di test inviato con successo', + 'admin.notifications.testWebhookFailed': 'Invio webhook di test fallito', 'admin.smtp.title': 'Email e notifiche', - 'admin.smtp.hint': 'Configurazione SMTP per le notifiche via email. Opzionale: URL webhook per Discord, Slack, ecc.', + '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)', @@ -296,6 +321,8 @@ const it: Record = { 'login.signIn': 'Accedi', 'login.createAdmin': 'Crea Account Amministratore', 'login.createAdminHint': 'Imposta il primo account amministratore per TREK.', + 'login.setNewPassword': 'Imposta nuova password', + 'login.setNewPasswordHint': 'Devi cambiare la password prima di continuare.', 'login.createAccount': 'Crea Account', 'login.createAccountHint': 'Registra un nuovo account.', 'login.creating': 'Creazione in corso…', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 3ca724d..242c841 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -25,6 +25,14 @@ const nl: Record = { 'common.email': 'E-mail', '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…', @@ -149,9 +157,26 @@ const nl: Record = { 'settings.notifyCollabMessage': 'Chatberichten (Collab)', 'settings.notifyPackingTagged': 'Paklijst: toewijzingen', 'settings.notifyWebhook': 'Webhook-meldingen', + 'settings.notificationsDisabled': 'Meldingen zijn niet geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te schakelen.', + 'settings.notificationsActive': 'Actief kanaal', + 'settings.notificationsManagedByAdmin': 'Meldingsgebeurtenissen worden geconfigureerd door je beheerder.', + 'admin.notifications.title': 'Meldingen', + 'admin.notifications.hint': 'Kies een meldingskanaal. Er kan er slechts één tegelijk actief zijn.', + 'admin.notifications.none': 'Uitgeschakeld', + 'admin.notifications.email': 'E-mail (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': 'Testwebhook succesvol verzonden', + 'admin.notifications.testWebhookFailed': 'Testwebhook mislukt', 'admin.smtp.title': 'E-mail en meldingen', - 'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.', + '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)', @@ -296,6 +321,8 @@ const nl: Record = { 'login.signIn': 'Inloggen', 'login.createAdmin': 'Beheerdersaccount aanmaken', 'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.', + 'login.setNewPassword': 'Nieuw wachtwoord instellen', + 'login.setNewPasswordHint': 'U moet uw wachtwoord wijzigen voordat u verder kunt gaan.', 'login.createAccount': 'Account aanmaken', 'login.createAccountHint': 'Registreer een nieuw account.', 'login.creating': 'Aanmaken…', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index e37f04b..293a440 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -25,6 +25,14 @@ const ru: Record = { 'common.email': 'Эл. почта', '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': 'Загрузка…', @@ -149,9 +157,26 @@ const ru: Record = { 'settings.notifyCollabMessage': 'Сообщения чата (Collab)', 'settings.notifyPackingTagged': 'Список вещей: назначения', 'settings.notifyWebhook': 'Webhook-уведомления', + 'settings.notificationsDisabled': 'Уведомления не настроены. Попросите администратора включить уведомления по электронной почте или webhook.', + 'settings.notificationsActive': 'Активный канал', + 'settings.notificationsManagedByAdmin': 'События уведомлений настраиваются администратором.', + 'admin.notifications.title': 'Уведомления', + 'admin.notifications.hint': 'Выберите канал уведомлений. Одновременно может быть активен только один.', + 'admin.notifications.none': 'Отключено', + 'admin.notifications.email': 'Эл. почта (SMTP)', + 'admin.notifications.webhook': 'Webhook', + 'admin.notifications.events': 'События уведомлений', + 'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.', + 'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.', + 'admin.notifications.save': 'Сохранить настройки уведомлений', + 'admin.notifications.saved': 'Настройки уведомлений сохранены', + 'admin.notifications.testWebhook': 'Отправить тестовый вебхук', + 'admin.notifications.testWebhookSuccess': 'Тестовый вебхук успешно отправлен', + 'admin.notifications.testWebhookFailed': 'Ошибка отправки тестового вебхука', 'admin.smtp.title': 'Почта и уведомления', - 'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.', + 'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.', 'admin.smtp.testButton': 'Отправить тестовое письмо', + 'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).', 'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено', 'admin.smtp.testFailed': 'Ошибка отправки тестового письма', 'dayplan.icsTooltip': 'Экспорт календаря (ICS)', @@ -296,6 +321,8 @@ const ru: Record = { 'login.signIn': 'Войти', 'login.createAdmin': 'Создать аккаунт администратора', 'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.', + 'login.setNewPassword': 'Установить новый пароль', + 'login.setNewPasswordHint': 'Вы должны сменить пароль, прежде чем продолжить.', 'login.createAccount': 'Создать аккаунт', 'login.createAccountHint': 'Зарегистрируйте новый аккаунт.', 'login.creating': 'Создание…', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 44618de..be146a7 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -25,6 +25,14 @@ const zh: Record = { 'common.email': '邮箱', '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': '上传中…', @@ -149,9 +157,26 @@ const zh: Record = { 'settings.notifyCollabMessage': '聊天消息 (Collab)', 'settings.notifyPackingTagged': '行李清单:分配', 'settings.notifyWebhook': 'Webhook 通知', + 'settings.notificationsDisabled': '通知尚未配置。请联系管理员启用电子邮件或 Webhook 通知。', + 'settings.notificationsActive': '活跃频道', + 'settings.notificationsManagedByAdmin': '通知事件由管理员配置。', + 'admin.notifications.title': '通知', + 'admin.notifications.hint': '选择一个通知渠道。一次只能激活一个。', + 'admin.notifications.none': '已禁用', + 'admin.notifications.email': '电子邮件 (SMTP)', + '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', + 'admin.notifications.testWebhookSuccess': '测试 Webhook 发送成功', + 'admin.notifications.testWebhookFailed': '测试 Webhook 发送失败', 'admin.smtp.title': '邮件与通知', - 'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选:Discord、Slack 等的 Webhook URL。', + 'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。', 'admin.smtp.testButton': '发送测试邮件', + 'admin.webhook.hint': '向外部 Webhook 发送通知(Discord、Slack 等)。', 'admin.smtp.testSuccess': '测试邮件发送成功', 'admin.smtp.testFailed': '测试邮件发送失败', 'dayplan.icsTooltip': '导出日历 (ICS)', @@ -296,6 +321,8 @@ const zh: Record = { 'login.signIn': '登录', 'login.createAdmin': '创建管理员账户', 'login.createAdminHint': '为 TREK 设置第一个管理员账户。', + 'login.setNewPassword': '设置新密码', + 'login.setNewPasswordHint': '您必须更改密码才能继续。', 'login.createAccount': '创建账户', 'login.createAccountHint': '注册新账户。', 'login.creating': '创建中…', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index e05191e..7489723 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -122,7 +122,7 @@ export default function AdminPage(): React.ReactElement { const [updating, setUpdating] = useState(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() @@ -974,64 +974,182 @@ export default function AdminPage(): React.ReactElement { - {/* SMTP / Notifications */} + {/* Notifications — exclusive channel selector */}
-

{t('admin.smtp.title')}

-

{t('admin.smtp.hint')}

+

{t('admin.notifications.title')}

+

{t('admin.notifications.hint')}

-
- {smtpLoaded && [ - { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, - { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, - { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, - { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, - { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, - { key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' }, - { key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' }, - ].map(field => ( -
- - setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder} - onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
- ))} - {/* Skip TLS toggle */} -
-
- Skip TLS certificate check -

Enable for self-signed certificates on local mail servers

-
- +
+ {/* Channel selector */} +
+ {(['none', 'email', 'webhook'] as const).map(ch => { + const active = (smtpValues.notification_channel || 'none') === ch + const labels: Record = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') } + return ( + + ) + })} +
+ + {/* Notification event toggles — shown when any channel is active */} + {(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 ( +
+

{t('admin.notifications.events')}

+ {!configValid && ( +

{t('admin.notifications.configureFirst')}

+ )} +

{t('admin.notifications.eventsHint')}

+ {[ + { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') }, + { key: 'notify_booking_change', label: t('settings.notifyBookingChange') }, + { key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') }, + { key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }, + { key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }, + { key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }, + { key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }, + ].map(opt => { + const isOn = (smtpValues[opt.key] ?? 'true') !== 'false' + return ( +
+ {opt.label} + +
+ ) + })} +
+ ) + })()} + + {/* Email (SMTP) settings — shown when email channel is active */} + {(smtpValues.notification_channel || 'none') === 'email' && ( +
+

{t('admin.smtp.hint')}

+ {smtpLoaded && [ + { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, + { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, + { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, + { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, + { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, + ].map(field => ( +
+ + setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} + placeholder={field.placeholder} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ ))} +
+
+ Skip TLS certificate check +

Enable for self-signed certificates on local mail servers

+
+ +
+
+ )} + + {/* Webhook settings — shown when webhook channel is active */} + {(smtpValues.notification_channel || 'none') === 'webhook' && ( +
+

{t('admin.webhook.hint')}

+
+ + setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))} + placeholder="https://discord.com/api/webhooks/..." + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

TREK will POST JSON with event, title, body, and timestamp to this URL.

+
+
+ )} + + {/* Save + Test buttons */} +
+ + {(smtpValues.notification_channel || 'none') === 'email' && ( + + )} + {(smtpValues.notification_channel || 'none') === 'webhook' && ( + + )}
-
@@ -1039,7 +1157,7 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'backup' && } - {activeTab === 'audit' && } + {activeTab === 'audit' && } {activeTab === 'mcp-tokens' && } diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 4dc144a..8ae4046 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -145,9 +145,10 @@ interface TripCardProps { t: (key: string, params?: Record) => 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,
{/* Top-right actions */} + {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}> onEdit(trip)} title={t('common.edit')}> onArchive(trip.id)} title={t('dashboard.archive')}> onDelete(trip)} title={t('common.delete')} danger>
+ )} {/* Bottom content */}
@@ -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): React.ReactElement { +function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit): 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
+ {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}> onEdit(trip)} icon={} label={t('common.edit')} /> onArchive(trip.id)} icon={} label={t('dashboard.archive')} /> onDelete(trip)} icon={} label={t('common.delete')} danger />
+ )} ) } // ── List View Item ────────────────────────────────────────────────────────── -function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { +function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit): React.ReactElement { const status = getTripStatus(trip) const [hovered, setHovered] = useState(false) @@ -403,11 +408,13 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: {/* Actions */} + {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}> onEdit(trip)} icon={} label="" /> onArchive(trip.id)} icon={} label="" /> onDelete(trip)} icon={} label="" danger />
+ )} ) } @@ -421,9 +428,10 @@ interface ArchivedRowProps { onClick: (trip: DashboardTrip) => void t: (key: string, params?: Record) => 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 (
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 }
)} + {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
+ )} ) } @@ -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' && ( { setEditingTrip(tr); setShowForm(true) }} onDelete={handleDelete} onArchive={handleArchive} @@ -797,7 +808,7 @@ export default function DashboardPage(): React.ReactElement { { setEditingTrip(tr); setShowForm(true) }} onDelete={handleDelete} onArchive={handleArchive} @@ -811,7 +822,7 @@ export default function DashboardPage(): React.ReactElement { { setEditingTrip(tr); setShowForm(true) }} onDelete={handleDelete} onArchive={handleArchive} @@ -841,7 +852,7 @@ export default function DashboardPage(): React.ReactElement { { setEditingTrip(tr); setShowForm(true) }} onUnarchive={handleUnarchive} onDelete={handleDelete} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 7121dfd..47e244c 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -9,6 +9,7 @@ import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, interface AppConfig { has_users: boolean allow_registration: boolean + setup_complete: boolean demo_mode: boolean oidc_configured: boolean oidc_display_name?: string @@ -28,7 +29,7 @@ export default function LoginPage(): React.ReactElement { const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) - const { login, register, demoLogin, completeMfaLogin } = useAuthStore() + const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore() const { setLanguageLocal } = useSettingsStore() const navigate = useNavigate() @@ -110,19 +111,39 @@ export default function LoginPage(): React.ReactElement { const [mfaStep, setMfaStep] = useState(false) const [mfaToken, setMfaToken] = useState('') const [mfaCode, setMfaCode] = useState('') + const [passwordChangeStep, setPasswordChangeStep] = useState(false) + const [savedLoginPassword, setSavedLoginPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() setError('') setIsLoading(true) try { + if (passwordChangeStep) { + if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return } + if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return } + if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return } + await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword }) + await loadUser({ silent: true }) + setShowTakeoff(true) + setTimeout(() => navigate('/dashboard'), 2600) + return + } if (mode === 'login' && mfaStep) { if (!mfaCode.trim()) { setError(t('login.mfaCodeRequired')) setIsLoading(false) return } - await completeMfaLogin(mfaToken, mfaCode) + const mfaResult = await completeMfaLogin(mfaToken, mfaCode) + if ('user' in mfaResult && mfaResult.user?.must_change_password) { + setSavedLoginPassword(password) + setPasswordChangeStep(true) + setIsLoading(false) + return + } setShowTakeoff(true) setTimeout(() => navigate('/dashboard'), 2600) return @@ -140,6 +161,12 @@ export default function LoginPage(): React.ReactElement { setIsLoading(false) return } + if ('user' in result && result.user?.must_change_password) { + setSavedLoginPassword(password) + setPasswordChangeStep(true) + setIsLoading(false) + return + } } setShowTakeoff(true) setTimeout(() => navigate('/dashboard'), 2600) @@ -149,7 +176,7 @@ export default function LoginPage(): React.ReactElement { } } - const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode + const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users) // In OIDC-only mode, show a minimal page that redirects directly to the IdP const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured @@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement { ) : ( <>

- {mode === 'login' && mfaStep - ? t('login.mfaTitle') - : mode === 'register' - ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) - : t('login.title')} + {passwordChangeStep + ? t('login.setNewPassword') + : mode === 'login' && mfaStep + ? t('login.mfaTitle') + : mode === 'register' + ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) + : t('login.title')}

- {mode === 'login' && mfaStep - ? t('login.mfaSubtitle') - : mode === 'register' - ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) - : t('login.subtitle')} + {passwordChangeStep + ? t('login.setNewPasswordHint') + : mode === 'login' && mfaStep + ? t('login.mfaSubtitle') + : mode === 'register' + ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) + : t('login.subtitle')}

@@ -537,7 +568,39 @@ export default function LoginPage(): React.ReactElement { )} - {mode === 'login' && mfaStep && ( + {passwordChangeStep && ( + <> +
+ {t('settings.mustChangePassword')} +
+
+ +
+ + ) => setNewPassword(e.target.value)} required + placeholder={t('settings.newPassword')} style={inputBase} + onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'} + onBlur={(e: React.FocusEvent) => e.target.style.borderColor = '#e5e7eb'} + /> +
+
+
+ +
+ + ) => setConfirmPassword(e.target.value)} required + placeholder={t('settings.confirmPassword')} style={inputBase} + onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'} + onBlur={(e: React.FocusEvent) => e.target.style.borderColor = '#e5e7eb'} + /> +
+
+ + )} + + {mode === 'login' && mfaStep && !passwordChangeStep && (
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement { )} {/* Username (register only) */} - {mode === 'register' && ( + {mode === 'register' && !passwordChangeStep && (
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement { )} {/* Email */} - {!(mode === 'login' && mfaStep) && ( + {!(mode === 'login' && mfaStep) && !passwordChangeStep && (
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement { )} {/* Password */} - {!(mode === 'login' && mfaStep) && ( + {!(mode === 'login' && mfaStep) && !passwordChangeStep && (
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement { onMouseLeave={(e: React.MouseEvent) => e.currentTarget.style.background = '#111827'} > {isLoading - ? <>
{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))} - : <>{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))} + ? <>
{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))} + : <>{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))} } {/* Toggle login/register */} - {showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && ( + {showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && !passwordChangeStep && (

{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '} + ) +} + +function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) { + const [notifChannel, setNotifChannel] = useState('none') useEffect(() => { - apiClient.get('/addons').then(r => { - const map: Record = {} - for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled - setAddons(map) + authApi.getAppConfig?.().then((cfg: any) => { + if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel) }).catch(() => {}) }, []) - const toggle = async (key: string) => { - if (!prefs) return - const newVal = prefs[key] ? 0 : 1 - setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev) - try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {} + if (notifChannel === 'none') { + return ( +

+ {t('settings.notificationsDisabled')} +

+ ) } - if (!prefs) return

{t('common.loading')}

- - const options = [ - { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') }, - { key: 'notify_booking_change', label: t('settings.notifyBookingChange') }, - ...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []), - ...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []), - ...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []), - ...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []), - { key: 'notify_webhook', label: t('settings.notifyWebhook') }, - ] + const channelLabel = notifChannel === 'email' + ? (t('admin.notifications.email') || 'Email (SMTP)') + : (t('admin.notifications.webhook') || 'Webhook') return ( -
- {options.map(opt => ( -
- {opt.label} - -
- ))} +
+
+ + + {t('settings.notificationsActive')}: {channelLabel} + +
+

+ {t('settings.notificationsManagedByAdmin')} +

) } @@ -924,6 +922,7 @@ export default function SettingsPage(): React.ReactElement { await authApi.changePassword({ current_password: currentPassword, new_password: newPassword }) toast.success(t('settings.passwordChanged')) setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') + await loadUser({ silent: true }) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('common.error'))) } diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 66206ee..9fbad53 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -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 completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -42,6 +43,7 @@ interface AuthState { setHasMapsKey: (val: boolean) => void setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void + setTripRemindersEnabled: (val: boolean) => void demoLogin: () => Promise } @@ -55,6 +57,7 @@ export const useAuthStore = create((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((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 }) diff --git a/client/src/types.ts b/client/src/types.ts index 4012914..59159f5 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -10,6 +10,8 @@ export interface User { created_at: string /** Present after load; true when TOTP MFA is enabled for password login */ mfa_enabled?: boolean + /** True when a password change is required before the user can continue */ + must_change_password?: boolean } export interface Trip { @@ -20,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 diff --git a/docker-compose.yml b/docker-compose.yml index 300cc97..37e1123 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,34 @@ services: - init-permissions: - image: alpine:3.20 - container_name: trek-init-permissions - user: "0:0" - command: > - sh -c "mkdir -p /app/data /app/uploads && - chown -R 1000:1000 /app/data /app/uploads && - chmod -R u+rwX /app/data /app/uploads" - volumes: - - ./data:/app/data - - ./uploads:/app/uploads - restart: "no" - app: image: mauriceboe/trek:latest container_name: trek - depends_on: - init-permissions: - condition: service_completed_successfully + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - SETUID + - SETGID + tmpfs: + - /tmp:noexec,nosuid,size=64m ports: - "3000:3000" environment: - NODE_ENV=production - - JWT_SECRET=${JWT_SECRET:-} - # - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins - PORT=3000 - - TZ=${TZ:-UTC} + - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) + - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links + - FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy + - TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP) + - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL + - OIDC_CLIENT_ID=trek # OpenID Connect client ID + - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret + - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button + - OIDC_ONLY=false # Set true to disable local password auth entirely (SSO only) volumes: - ./data:/app/data - ./uploads:/app/uploads diff --git a/server/.env.example b/server/.env.example index a4ffe68..2aae179 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,33 +1,19 @@ -PORT=3000 -NODE_ENV=development -DEBUG=false +PORT=3001 # Port to run the server on +NODE_ENV=development # development = development mode; production = production mode +JWT_SECRET=your-super-secret-jwt-key-change-in-production # Auto-generated if not set; persist across restarts for stable sessions +TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) +LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details -# REQUIRED for production — generate with: openssl rand -hex 32 -JWT_SECRET=CHANGEME_GENERATE_WITH_openssl_rand_hex_32 +ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links +FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy +TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For -# Timezone (defaults to system timezone) -# TZ=UTC +OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL +OIDC_CLIENT_ID=trek # OpenID Connect client ID +OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret +OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button +OIDC_ONLY=true # Disable local password auth entirely (SSO only) +OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users +OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role -# CORS — comma-separated origins (leave unset for same-origin in production, allow-all in development) -# ALLOWED_ORIGINS=https://trek.example.com - -# Force HTTPS redirect (set to true behind TLS-terminating proxy) -# FORCE_HTTPS=true - -# Trust proxy (set to number of proxy hops, e.g. 1 for single reverse proxy) -# TRUST_PROXY=1 - -# Application URL (used for OIDC callback validation) -# APP_URL=https://trek.example.com - -# Demo mode (enables demo login, disables registration) -# DEMO_MODE=false - -# --- OIDC / SSO --- -# OIDC_ISSUER=https://auth.example.com -# OIDC_CLIENT_ID= -# OIDC_CLIENT_SECRET= -# OIDC_DISPLAY_NAME=SSO -# OIDC_ONLY=false -# OIDC_ADMIN_CLAIM=groups -# OIDC_ADMIN_VALUE=app-trek-admins +DEMO_MODE=false # Demo mode - resets data hourly diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 32fdac4..cb5c256 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -428,9 +428,14 @@ function runMigrations(db: Database.Database): void { } catch {} }, () => { - // GPX full route geometry stored as JSON array of [lat,lng] pairs try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } 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) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index c6a5d23..0eb24b7 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -18,6 +18,7 @@ function createTables(db: Database.Database): void { mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT, + must_change_password INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -40,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 ); diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 9c51ffc..248d10a 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -1,4 +1,46 @@ 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); + const email = 'admin@trek.local'; + const username = 'admin'; + + db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)').run(username, email, hash, 'admin'); + + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — First Run: Admin Account Created ║'); + console.log('╠══════════════════════════════════════════════╣'); + console.log(`║ Email: ${email.padEnd(33)}║`); + console.log(`║ Password: ${password.padEnd(33)}║`); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + } catch (err: unknown) { + console.error('[ERROR] Error seeding admin account:', err instanceof Error ? err.message : err); + } +} function seedCategories(db: Database.Database): void { try { @@ -45,6 +87,7 @@ function seedAddons(db: Database.Database): void { } function runSeeds(db: Database.Database): void { + seedAdminAccount(db); seedCategories(db); seedAddons(db); } diff --git a/server/src/index.ts b/server/src/index.ts index 6bd68c8..8cde5c3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,6 +9,7 @@ import fs from 'fs'; const app = express(); const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true'; +const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase(); // Trust first proxy (nginx/Docker) for correct req.ip if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) { @@ -29,21 +30,18 @@ const tmpDir = path.join(__dirname, '../data/tmp'); // Middleware const allowedOrigins = process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(',') + ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean) : null; let corsOrigin: cors.CorsOptions['origin']; if (allowedOrigins) { - // Explicit whitelist from env var corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { if (!origin || allowedOrigins.includes(origin)) callback(null, true); else callback(new Error('Not allowed by CORS')); }; } else if (process.env.NODE_ENV === 'production') { - // Production: same-origin only (Express serves the static client) corsOrigin = false; } else { - // Development: allow all origins (needed for Vite dev server) corsOrigin = true; } @@ -92,30 +90,38 @@ app.use(express.urlencoded({ extended: true })); app.use(enforceGlobalMfaPolicy); -if (DEBUG) { +{ + const { logInfo: _logInfo, logDebug: _logDebug, logWarn: _logWarn, logError: _logError } = require('./services/auditLog'); + const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']); + const _redact = (value: unknown): unknown => { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(_redact); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : _redact(v); + } + return out; + }; + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/api/health') return next(); + const startedAt = Date.now(); - const requestId = Math.random().toString(36).slice(2, 10); - const redact = (value: unknown): unknown => { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(redact); - const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']); - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); - } - return out; - }; - - const safeQuery = redact(req.query); - const safeBody = redact(req.body); - console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`); - res.on('finish', () => { - const elapsedMs = Date.now() - startedAt; - console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`); - }); + const ms = Date.now() - startedAt; + 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}`); + } + + const q = Object.keys(req.query).length ? ` query=${JSON.stringify(_redact(req.query))}` : ''; + const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(_redact(req.body))}` : ''; + _logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`); + }); next(); }); } @@ -245,17 +251,32 @@ import * as scheduler from './scheduler'; const PORT = process.env.PORT || 3001; const server = app.listen(PORT, () => { - console.log(`TREK API running on port ${PORT}`); - console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`); + const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog'); + const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const origins = process.env.ALLOWED_ORIGINS || '(same-origin)'; + const banner = [ + '──────────────────────────────────────', + ' TREK API started', + ` Port: ${PORT}`, + ` Environment: ${process.env.NODE_ENV || 'development'}`, + ` Timezone: ${tz}`, + ` Origins: ${origins}`, + ` Log level: ${LOG_LVL}`, + ` Log file: /app/data/logs/trek.log`, + ` PID: ${process.pid}`, + ` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`, + '──────────────────────────────────────', + ]; + banner.forEach(l => console.log(l)); if (JWT_SECRET_IS_GENERATED) { - console.warn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.'); + sLogWarn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.'); } - if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED'); + if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { - console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.'); + sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!'); } scheduler.start(); + scheduler.startTripReminders(); scheduler.startDemoReset(); import('./websocket').then(({ setupWebSocket }) => { setupWebSocket(server); @@ -264,19 +285,19 @@ const server = app.listen(PORT, () => { // Graceful shutdown function shutdown(signal: string): void { - console.log(`\n${signal} received — shutting down gracefully...`); + const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog'); + sLogInfo(`${signal} received — shutting down gracefully...`); scheduler.stop(); closeMcpSessions(); server.close(() => { - console.log('HTTP server closed'); + sLogInfo('HTTP server closed'); const { closeDb } = require('./db/database'); closeDb(); - console.log('Shutdown complete'); + sLogInfo('Shutdown complete'); process.exit(0); }); - // Force exit after 10s if connections don't close setTimeout(() => { - console.error('Forced shutdown after timeout'); + sLogError('Forced shutdown after timeout'); process.exit(1); }, 10000); } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index ecc3f58..0ae2370 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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 }); }); @@ -189,7 +192,8 @@ router.get('/audit-log', (req: Request, res: Response) => { details = { _parse_error: true }; } } - return { ...r, details }; + const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at; + return { ...r, created_at, details }; }), total, limit, diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d7604a2..86e44ad 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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 }; @@ -83,6 +84,7 @@ function stripUserForClient(user: User): Record { updated_at: utcSuffix(rest.updated_at), last_login: utcSuffix(rest.last_login), mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), + must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true), }; } @@ -183,9 +185,17 @@ router.get('/app-config', (_req: Request, res: Response) => { const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value; 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, has_users: userCount > 0, + setup_complete: setupComplete, version, has_maps_key: hasGoogleKey, oidc_configured: oidcConfigured, @@ -197,6 +207,8 @@ router.get('/app-config', (_req: Request, res: Response) => { demo_email: isDemo ? 'demo@trek.app' : undefined, demo_password: isDemo ? 'demo12345' : undefined, timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + notification_channel: notifChannel, + trip_reminders_enabled: tripRemindersEnabled, }); }); @@ -290,6 +302,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { } } + writeAudit({ userId: Number(result.lastInsertRowid), action: 'user.register', ip: getClientIp(req), details: { username, email, role } }); res.status(201).json({ token, user: { ...user, avatar_url: null } }); } catch (err: unknown) { res.status(500).json({ error: 'Error creating user' }); @@ -309,11 +322,13 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined; if (!user) { + writeAudit({ userId: null, action: 'user.login_failed', ip: getClientIp(req), details: { email, reason: 'unknown_email' } }); return res.status(401).json({ error: 'Invalid email or password' }); } const validPassword = bcrypt.compareSync(password, user.password_hash!); if (!validPassword) { + writeAudit({ userId: Number(user.id), action: 'user.login_failed', ip: getClientIp(req), details: { email, reason: 'wrong_password' } }); return res.status(401).json({ error: 'Invalid email or password' }); } @@ -330,13 +345,14 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { const token = generateToken(user); const userSafe = stripUserForClient(user) as Record; + writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } }); res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); }); router.get('/me', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare( - 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled FROM users WHERE id = ?' + 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?' ).get(authReq.user.id) as User | undefined; if (!user) { @@ -370,7 +386,8 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req } const hash = bcrypt.hashSync(new_password, 12); - db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, authReq.user.id); + db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) }); res.json({ success: true }); }); @@ -385,6 +402,7 @@ router.delete('/me', authenticate, (req: Request, res: Response) => { return res.status(400).json({ error: 'Cannot delete the last admin account' }); } } + writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) }); db.prepare('DELETE FROM users WHERE id = ?').run(authReq.user.id); res.json({ success: true }); }); @@ -606,7 +624,7 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) = res.json(result); }); -const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'app_url']; +const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'notification_channel', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']; router.get('/app-settings', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; @@ -626,7 +644,7 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined; if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - const { allow_registration, allowed_file_types, require_mfa } = req.body as Record; + const { require_mfa } = req.body as Record; if (require_mfa === true || require_mfa === 'true') { const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined; @@ -648,16 +666,37 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); } } + const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => req.body[k] !== undefined && !(k === 'smtp_pass' && String(req.body[k]) === '••••••••')); + + const summary: Record = {}; + const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); + const eventsChanged = changedKeys.some(k => k.startsWith('notify_')); + if (changedKeys.includes('notification_channel')) summary.notification_channel = req.body.notification_channel; + if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true; + if (smtpChanged) summary.smtp_settings_updated = true; + if (eventsChanged) summary.notification_events_updated = true; + if (changedKeys.includes('allow_registration')) summary.allow_registration = req.body.allow_registration; + if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true; + if (changedKeys.includes('require_mfa')) summary.require_mfa = req.body.require_mfa; + + const debugDetails: Record = {}; + for (const k of changedKeys) { + debugDetails[k] = k === 'smtp_pass' ? '***' : req.body[k]; + } + writeAudit({ userId: authReq.user.id, action: 'settings.app_update', ip: getClientIp(req), - details: { - allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined, - allowed_file_types_changed: allowed_file_types !== undefined, - require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined, - }, + 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 }); }); @@ -768,6 +807,7 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); const sessionToken = generateToken(user); const userSafe = stripUserForClient(user) as Record; + writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); } catch { return res.status(401).json({ error: 'Invalid or expired verification token' }); diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index d6d3860..7a61178 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -126,6 +126,11 @@ router.post('/notes', authenticate, (req: Request, res: Response) => { const formatted = formatNote(note); res.status(201).json({ note: formatted }); broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string); + + 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.email }).catch(() => {}); + }); }); router.put('/notes/:id', authenticate, (req: Request, res: Response) => { @@ -425,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(() => {}); }); }); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 006ded8..ef3891b 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -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(() => {}); }); } }); diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts index 19b6d70..d0c9123 100644 --- a/server/src/routes/notifications.ts +++ b/server/src/routes/notifications.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; -import { testSmtp } from '../services/notifications'; +import { testSmtp, testWebhook } from '../services/notifications'; const router = express.Router(); @@ -55,4 +55,13 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => { res.json(result); }); +// Admin: test webhook configuration +router.post('/test-webhook', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); + + const result = await testWebhook(); + res.json(result); +}); + export default router; diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index 962cbd3..0b84d8b 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -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(() => {}); } } }); diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index 0c8b09c..5bd4625 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -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(() => {}); }); }); @@ -222,6 +222,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { res.json({ reservation: updated }); broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id'] as string); + + 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.email, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {}); + }); }); router.delete('/:id', authenticate, (req: Request, res: Response) => { @@ -231,10 +236,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; accommodation_id: number | null } | undefined; + const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined; if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); - // Delete linked accommodation if exists if (reservation.accommodation_id) { db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id); broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string); @@ -243,6 +247,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { db.prepare('DELETE FROM reservations WHERE id = ?').run(id); res.json({ success: true }); broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string); + + 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.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {}); + }); }); export default router; diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index f2a7c9d..7145fc7 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -7,6 +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, logInfo } from '../services/auditLog'; const router = express.Router(); @@ -124,29 +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, 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 }); }); @@ -154,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' }); @@ -186,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 = {}; + 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); @@ -226,9 +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); + 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); @@ -287,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 } }).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 } }); diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index ee03496..90374a3 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -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 }); diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index a3272c2..6599e52 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -79,9 +79,11 @@ async function runBackup(): Promise { if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads'); archive.finalize(); }); - console.log(`[Auto-Backup] Created: ${filename}`); + const { logInfo: li } = require('./services/auditLog'); + li(`Auto-Backup created: ${filename}`); } catch (err: unknown) { - console.error('[Auto-Backup] Error:', err instanceof Error ? err.message : err); + const { logError: le } = require('./services/auditLog'); + le(`Auto-Backup: ${err instanceof Error ? err.message : err}`); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); return; } @@ -102,11 +104,13 @@ function cleanupOldBackups(keepDays: number): void { const stat = fs.statSync(filePath); if (stat.birthtimeMs < cutoff) { fs.unlinkSync(filePath); - console.log(`[Auto-Backup] Old backup deleted: ${file}`); + const { logInfo: li } = require('./services/auditLog'); + li(`Auto-Backup old backup deleted: ${file}`); } } } catch (err: unknown) { - console.error('[Auto-Backup] Cleanup error:', err instanceof Error ? err.message : err); + const { logError: le } = require('./services/auditLog'); + le(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`); } } @@ -118,14 +122,16 @@ function start(): void { const settings = loadSettings(); if (!settings.enabled) { - console.log('[Auto-Backup] Disabled'); + const { logInfo: li } = require('./services/auditLog'); + li('Auto-Backup disabled'); return; } const expression = buildCronExpression(settings); const tz = process.env.TZ || 'UTC'; currentTask = cron.schedule(expression, runBackup, { timezone: tz }); - console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`); + const { logInfo: li2 } = require('./services/auditLog'); + li2(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`); } // Demo mode: hourly reset of demo user data @@ -140,15 +146,75 @@ function startDemoReset(): void { const { resetDemoUser } = require('./demo/demo-reset'); resetDemoUser(); } catch (err: unknown) { - console.error('[Demo Reset] Error:', err instanceof Error ? err.message : err); + const { logError: le } = require('./services/auditLog'); + le(`Demo reset: ${err instanceof Error ? err.message : err}`); } }); - console.log('[Demo] Hourly reset scheduled (at :00 every hour)'); + const { logInfo: li3 } = require('./services/auditLog'); + li3('Demo hourly reset scheduled'); +} + +// Trip reminders: daily check at 9 AM local time for trips starting tomorrow +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 { notifyTripMembers } = require('./services/notifications'); + + const trips = db.prepare(` + 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 notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {}); + } + + const { logInfo: li } = require('./services/auditLog'); + if (trips.length > 0) { + 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 }); } function stop(): void { if (currentTask) { currentTask.stop(); currentTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; } + if (reminderTask) { reminderTask.stop(); reminderTask = null; } } -export { start, stop, startDemoReset, loadSettings, saveSettings, VALID_INTERVALS }; +export { start, stop, startDemoReset, startTripReminders, loadSettings, saveSettings, VALID_INTERVALS }; diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts index ed78ad5..527d514 100644 --- a/server/src/services/auditLog.ts +++ b/server/src/services/auditLog.ts @@ -1,5 +1,80 @@ import { Request } from 'express'; import { db } from '../db/database'; +import fs from 'fs'; +import path from 'path'; + +const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase(); +const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB +const MAX_LOG_FILES = 5; + +const C = { + blue: '\x1b[34m', + cyan: '\x1b[36m', + red: '\x1b[31m', + yellow: '\x1b[33m', + reset: '\x1b[0m', +}; + +// ── File logger with rotation ───────────────────────────────────────────── + +const logsDir = path.join(process.cwd(), 'data/logs'); +try { fs.mkdirSync(logsDir, { recursive: true }); } catch {} +const logFilePath = path.join(logsDir, 'trek.log'); + +function rotateIfNeeded(): void { + try { + if (!fs.existsSync(logFilePath)) return; + const stat = fs.statSync(logFilePath); + if (stat.size < MAX_LOG_SIZE) return; + + for (let i = MAX_LOG_FILES - 1; i >= 1; i--) { + const src = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`; + const dst = `${logFilePath}.${i}`; + if (fs.existsSync(src)) fs.renameSync(src, dst); + } + } catch {} +} + +function writeToFile(line: string): void { + try { + rotateIfNeeded(); + fs.appendFileSync(logFilePath, line + '\n'); + } catch {} +} + +// ── Public log helpers ──────────────────────────────────────────────────── + +function formatTs(): string { + const tz = process.env.TZ || 'UTC'; + return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T'); +} + +function logInfo(msg: string): void { + const ts = formatTs(); + console.log(`${C.blue}[INFO]${C.reset} ${ts} ${msg}`); + writeToFile(`[INFO] ${ts} ${msg}`); +} + +function logDebug(msg: string): void { + if (LOG_LEVEL !== 'debug') return; + const ts = formatTs(); + console.log(`${C.cyan}[DEBUG]${C.reset} ${ts} ${msg}`); + writeToFile(`[DEBUG] ${ts} ${msg}`); +} + +function logError(msg: string): void { + const ts = formatTs(); + console.error(`${C.red}[ERROR]${C.reset} ${ts} ${msg}`); + writeToFile(`[ERROR] ${ts} ${msg}`); +} + +function logWarn(msg: string): void { + const ts = formatTs(); + console.warn(`${C.yellow}[WARN]${C.reset} ${ts} ${msg}`); + writeToFile(`[WARN] ${ts} ${msg}`); +} + +// ── IP + audit ──────────────────────────────────────────────────────────── export function getClientIp(req: Request): string | null { const xff = req.headers['x-forwarded-for']; @@ -11,12 +86,37 @@ export function getClientIp(req: Request): string | null { return req.socket?.remoteAddress || null; } +function resolveUserEmail(userId: number | null): string { + if (!userId) return 'anonymous'; + try { + const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; + return row?.email || `uid:${userId}`; + } catch { return `uid:${userId}`; } +} + +const ACTION_LABELS: Record = { + 'user.register': 'registered', + 'user.login': 'logged in', + 'user.login_failed': 'login failed', + 'user.password_change': 'changed password', + 'user.account_delete': 'deleted account', + 'user.mfa_enable': 'enabled MFA', + 'user.mfa_disable': 'disabled MFA', + 'settings.app_update': 'updated settings', + 'trip.create': 'created trip', + 'trip.delete': 'deleted trip', + 'admin.user_role_change': 'changed user role', + 'admin.user_delete': 'deleted user', + 'admin.invite_create': 'created invite', +}; + /** Best-effort; never throws — failures are logged only. */ export function writeAudit(entry: { userId: number | null; action: string; resource?: string | null; details?: Record; + debugDetails?: Record; ip?: string | null; }): void { try { @@ -24,7 +124,41 @@ export function writeAudit(entry: { db.prepare( `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)` ).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null); + + const email = resolveUserEmail(entry.userId); + const label = ACTION_LABELS[entry.action] || entry.action; + const brief = buildInfoSummary(entry.action, entry.details); + logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`); + + if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) { + logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`); + } else if (detailsJson) { + logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`); + } } catch (e) { - console.error('[audit] write failed:', e instanceof Error ? e.message : e); + logError(`Audit write failed: ${e instanceof Error ? e.message : e}`); } } + +function buildInfoSummary(action: string, details?: Record): string { + if (!details || Object.keys(details).length === 0) return ''; + if (action === 'trip.create') return ` "${details.title}"`; + if (action === 'trip.delete') return ` tripId=${details.tripId}`; + if (action === 'user.register') return ` ${details.email}`; + if (action === 'user.login') return ''; + if (action === 'user.login_failed') return ` reason=${details.reason}`; + if (action === 'settings.app_update') { + const parts: string[] = []; + if (details.notification_channel) parts.push(`channel=${details.notification_channel}`); + if (details.smtp_settings_updated) parts.push('smtp'); + if (details.notification_events_updated) parts.push('events'); + if (details.webhook_url_updated) parts.push('webhook_url'); + if (details.allowed_file_types_updated) parts.push('file_types'); + if (details.allow_registration !== undefined) parts.push(`registration=${details.allow_registration}`); + if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`); + return parts.length ? ` (${parts.join(', ')})` : ''; + } + return ''; +} + +export { LOG_LEVEL, logInfo, logDebug, logError, logWarn }; diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 86884b4..53e4e5c 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -1,6 +1,7 @@ import nodemailer from 'nodemailer'; import fetch from 'node-fetch'; import { db } from '../db/database'; +import { logInfo, logDebug, logError } from './auditLog'; // ── Types ────────────────────────────────────────────────────────────────── @@ -42,7 +43,13 @@ function getWebhookUrl(): string | null { } function getAppUrl(): string { - return process.env.APP_URL || getAppSetting('app_url') || ''; + const origins = process.env.ALLOWED_ORIGINS; + if (origins) { + const first = origins.split(',')[0]?.trim(); + if (first) return first.replace(/\/+$/, ''); + } + const port = process.env.PORT || '3000'; + return `http://localhost:${port}`; } function getUserEmail(userId: number): string | null { @@ -53,9 +60,11 @@ function getUserLanguage(userId: number): string { return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en'; } -function getUserPrefs(userId: number): Record { - const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any; - return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 }; +function getAdminEventEnabled(event: EventType): boolean { + const prefKey = EVENT_PREF_MAP[event]; + if (!prefKey) return true; + const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(prefKey) as { value: string } | undefined; + return !row || row.value !== 'false'; } // Event → preference column mapping @@ -90,7 +99,7 @@ type EventTextFn = (params: Record) => EventText const EVENT_TEXTS: Record> = { en: { - trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }), + trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }), booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }), trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }), vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }), @@ -99,7 +108,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }), }, de: { - trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }), + trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }), booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }), trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }), vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }), @@ -108,7 +117,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }), }, fr: { - trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }), + trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }), booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }), trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }), vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }), @@ -117,7 +126,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }), }, es: { - trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }), + trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }), booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }), trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }), vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }), @@ -126,7 +135,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }), }, nl: { - trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }), + trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }), booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }), trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }), vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }), @@ -135,7 +144,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }), }, ru: { - trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }), + trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }), booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }), trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }), vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }), @@ -144,7 +153,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }), }, zh: { - trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }), + trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }), booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }), trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }), vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }), @@ -153,7 +162,7 @@ const EVENT_TEXTS: Record> = { packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }), }, ar: { - trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }), + trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }), booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }), trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }), vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }), @@ -236,50 +245,109 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num text: body, html: buildEmailHtml(subject, body, lang), }); + logInfo(`Email sent to=${to} subject="${subject}"`); + logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`); return true; } catch (err) { - console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err); + logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`); return false; } } +function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string { + const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url); + const isSlack = /hooks\.slack\.com\//.test(url); + + if (isDiscord) { + return JSON.stringify({ + embeds: [{ + title: `📍 ${payload.title}`, + description: payload.body, + color: 0x3b82f6, + footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' }, + timestamp: new Date().toISOString(), + }], + }); + } + + if (isSlack) { + const trip = payload.tripName ? ` • _${payload.tripName}_` : ''; + return JSON.stringify({ + text: `*${payload.title}*\n${payload.body}${trip}`, + }); + } + + return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }); +} + async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise { const url = getWebhookUrl(); if (!url) return false; try { - await fetch(url, { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }), + body: buildWebhookBody(url, payload), signal: AbortSignal.timeout(10000), }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + logError(`Webhook HTTP ${res.status}: ${errBody}`); + return false; + } + + logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`); + logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`); return true; } catch (err) { - console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err); + logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); return false; } } // ── Public API ───────────────────────────────────────────────────────────── +function getNotificationChannel(): string { + return getAppSetting('notification_channel') || 'none'; +} + export async function notify(payload: NotificationPayload): Promise { - const prefs = getUserPrefs(payload.userId); - const prefKey = EVENT_PREF_MAP[payload.event]; - if (prefKey && !prefs[prefKey]) return; + const channel = getNotificationChannel(); + if (channel === 'none') return; + + if (!getAdminEventEnabled(payload.event)) return; const lang = getUserLanguage(payload.userId); const { title, body } = getEventText(lang, payload.event, payload.params); - const email = getUserEmail(payload.userId); - if (email) await sendEmail(email, title, body, payload.userId); - if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip }); + logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`); + + if (channel === 'email') { + const email = getUserEmail(payload.userId); + if (email) await sendEmail(email, title, body, payload.userId); + } else if (channel === 'webhook') { + await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip }); + } } export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record): Promise { + const channel = getNotificationChannel(); + if (channel === 'none') return; + if (!getAdminEventEnabled(event)) return; + const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; if (!trip) return; + if (channel === 'webhook') { + const lang = getUserLanguage(actorUserId); + const { title, body } = getEventText(lang, event, params); + logDebug(`notifyTripMembers event=${event} channel=webhook tripId=${tripId} actor=${actorUserId}`); + await sendWebhook({ event, title, body, tripName: params.trip }); + return; + } + const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[]; const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId); const unique = [...new Set(allIds)]; @@ -297,3 +365,12 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?: return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; } } + +export async function testWebhook(): Promise<{ success: boolean; error?: string }> { + try { + const sent = await sendWebhook({ event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' }); + return sent ? { success: true } : { success: false, error: 'Webhook URL not configured' }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} diff --git a/server/src/types.ts b/server/src/types.ts index 7db6a71..c8902d2 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -16,6 +16,7 @@ export interface User { mfa_enabled?: number | boolean; mfa_secret?: string | null; mfa_backup_codes?: string | null; + must_change_password?: number | boolean; created_at?: string; updated_at?: string; }