feat: notifications, audit logging, and admin improvements

- Add centralized notification service with webhook (Discord/Slack) and
  email (SMTP) support, triggered for trip invites, booking changes,
  collab messages, and trip reminders
- Webhook sends one message per event (group channel); email sends
  individually per trip member, excluding the actor
- Discord invite notifications now include the invited user's name
- Add LOG_LEVEL env var (info/debug) controlling console and file output
- INFO logs show user email, action, and IP for audit events; errors
  for HTTP requests
- DEBUG logs show every request with full body/query (passwords redacted),
  audit details, notification params, and webhook payloads
- Add persistent trek.log file logging with 10MB rotation (5 files)
  in /app/data/logs/
- Color-coded log levels in Docker console output
- Timestamps without timezone name (user sets TZ via Docker)
- Add Test Webhook and Save buttons to admin notification settings
- Move notification event toggles to admin panel
- Add daily trip reminder scheduler (9 AM, timezone-aware)
- Wire up booking create/update/delete and collab message notifications
- Add i18n keys for notification UI across all 13 languages

Made-with: Cursor
This commit is contained in:
Andrei Brebene
2026-03-31 15:01:33 +03:00
parent f7160e6dec
commit 9b2f083e4b
35 changed files with 1004 additions and 249 deletions

View File

@@ -1,4 +1,4 @@
# Stage 1: React Client bauen # Stage 1: Build React client
FROM node:22-alpine AS client-builder FROM node:22-alpine AS client-builder
WORKDIR /app/client WORKDIR /app/client
COPY client/package*.json ./ COPY client/package*.json ./
@@ -6,34 +6,25 @@ RUN npm ci
COPY client/ ./ COPY client/ ./
RUN npm run build RUN npm run build
# Stage 2: Produktions-Server # Stage 2: Production server
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools) # Timezone support + native deps (better-sqlite3 needs build tools)
COPY server/package*.json ./ 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 && \ npm ci --production && \
apk del python3 make g++ apk del python3 make g++
# Server-Code kopieren
COPY server/ ./ COPY server/ ./
# Gebauten Client kopieren
COPY --from=client-builder /app/client/dist ./public COPY --from=client-builder /app/client/dist ./public
# Fonts für PDF-Export kopieren
COPY --from=client-builder /app/client/public/fonts ./public/fonts 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/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
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 && \
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 NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
@@ -42,5 +33,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1 CMD wget -qO- http://localhost:3000/api/health || exit 1
# Entrypoint: fix volume permissions then start as node ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null; exec su-exec node node --import tsx src/index.ts"] 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"]

View File

@@ -316,6 +316,7 @@ export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).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 export default apiClient

View File

@@ -15,7 +15,11 @@ interface AuditEntry {
ip: string | null 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 { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([]) const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement {
const fmtTime = (iso: string) => { const fmtTime = (iso: string) => {
try { try {
return new Date(iso).toLocaleString(locale, { return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short', dateStyle: 'short',
timeStyle: 'medium', timeStyle: 'medium',
timeZone: serverTimezone || undefined,
}) })
} catch { } catch {
return iso return iso

View File

@@ -29,6 +29,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'البريد الإلكتروني', 'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور', 'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...', 'common.saving': 'جارٍ الحفظ...',
'common.saved': 'تم الحفظ',
'common.update': 'تحديث', 'common.update': 'تحديث',
'common.change': 'تغيير', 'common.change': 'تغيير',
'common.uploading': 'جارٍ الرفع...', 'common.uploading': 'جارٍ الرفع...',
@@ -154,8 +155,23 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)', 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات', 'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
'settings.notifyWebhook': 'إشعارات Webhook', '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.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
'admin.notifications.testWebhookSuccess': 'تم إرسال webhook التجريبي بنجاح',
'admin.notifications.testWebhookFailed': 'فشل إرسال webhook التجريبي',
'admin.smtp.title': 'البريد والإشعارات', 'admin.smtp.title': 'البريد والإشعارات',
'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.', 'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
'admin.smtp.testButton': 'إرسال بريد تجريبي', 'admin.smtp.testButton': 'إرسال بريد تجريبي',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح', 'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي', 'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
@@ -301,6 +317,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'دخول', 'login.signIn': 'دخول',
'login.createAdmin': 'إنشاء حساب مسؤول', 'login.createAdmin': 'إنشاء حساب مسؤول',
'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.', 'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.',
'login.setNewPassword': 'تعيين كلمة مرور جديدة',
'login.setNewPasswordHint': 'يجب عليك تغيير كلمة المرور قبل المتابعة.',
'login.createAccount': 'إنشاء حساب', 'login.createAccount': 'إنشاء حساب',
'login.createAccountHint': 'سجّل حسابًا جديدًا.', 'login.createAccountHint': 'سجّل حسابًا جديدًا.',
'login.creating': 'جارٍ الإنشاء…', 'login.creating': 'جارٍ الإنشاء…',

View File

@@ -25,6 +25,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Senha', 'common.password': 'Senha',
'common.saving': 'Salvando...', 'common.saving': 'Salvando...',
'common.saved': 'Salvo',
'common.update': 'Atualizar', 'common.update': 'Atualizar',
'common.change': 'Alterar', 'common.change': 'Alterar',
'common.uploading': 'Enviando…', 'common.uploading': 'Enviando…',
@@ -149,8 +150,23 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)', 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
'settings.notifyPackingTagged': 'Lista de mala: atribuições', 'settings.notifyPackingTagged': 'Lista de mala: atribuições',
'settings.notifyWebhook': 'Notificações webhook', '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.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.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.smtp.testButton': 'Enviar e-mail de teste',
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso', 'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste', 'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
@@ -296,6 +312,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Entrar', 'login.signIn': 'Entrar',
'login.createAdmin': 'Criar conta de administrador', 'login.createAdmin': 'Criar conta de administrador',
'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.', '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.createAccount': 'Criar conta',
'login.createAccountHint': 'Cadastre uma nova conta.', 'login.createAccountHint': 'Cadastre uma nova conta.',
'login.creating': 'Criando…', 'login.creating': 'Criando…',

View File

@@ -25,6 +25,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Heslo', 'common.password': 'Heslo',
'common.saving': 'Ukládání...', 'common.saving': 'Ukládání...',
'common.saved': 'Uloženo',
'common.update': 'Aktualizovat', 'common.update': 'Aktualizovat',
'common.change': 'Změnit', 'common.change': 'Změnit',
'common.uploading': 'Nahrávání…', 'common.uploading': 'Nahrávání…',
@@ -150,6 +151,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)', 'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
'settings.notifyPackingTagged': 'Seznam balení: přiřazení', 'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
'settings.notifyWebhook': 'Webhook oznámení', '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.on': 'Zapnuto',
'settings.off': 'Vypnuto', 'settings.off': 'Vypnuto',
'settings.mcp.title': 'Konfigurace MCP', 'settings.mcp.title': 'Konfigurace MCP',
@@ -235,8 +239,20 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto', 'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto', 'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu', '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.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.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.smtp.testButton': 'Odeslat testovací e-mail',
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán', 'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo', 'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
@@ -297,6 +313,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Přihlásit se', 'login.signIn': 'Přihlásit se',
'login.createAdmin': 'Vytvořit účet administrátora', 'login.createAdmin': 'Vytvořit účet administrátora',
'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.', '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.createAccount': 'Vytvořit účet',
'login.createAccountHint': 'Zaregistrujte si nový účet.', 'login.createAccountHint': 'Zaregistrujte si nový účet.',
'login.creating': 'Vytváření…', 'login.creating': 'Vytváření…',

View File

@@ -25,6 +25,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-Mail', 'common.email': 'E-Mail',
'common.password': 'Passwort', 'common.password': 'Passwort',
'common.saving': 'Speichern...', 'common.saving': 'Speichern...',
'common.saved': 'Gespeichert',
'common.update': 'Aktualisieren', 'common.update': 'Aktualisieren',
'common.change': 'Ändern', 'common.change': 'Ändern',
'common.uploading': 'Hochladen…', 'common.uploading': 'Hochladen…',
@@ -149,8 +150,23 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)', 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen', 'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
'settings.notifyWebhook': 'Webhook-Benachrichtigungen', '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.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.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.smtp.testButton': 'Test-E-Mail senden',
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet', 'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen', 'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
@@ -296,6 +312,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Anmelden', 'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen', 'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.', '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.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.', 'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…', 'login.creating': 'Erstelle…',

View File

@@ -25,6 +25,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
'common.saving': 'Saving...', 'common.saving': 'Saving...',
'common.saved': 'Saved',
'common.update': 'Update', 'common.update': 'Update',
'common.change': 'Change', 'common.change': 'Change',
'common.uploading': 'Uploading…', 'common.uploading': 'Uploading…',
@@ -149,11 +150,26 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Chat messages (Collab)', 'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments', 'settings.notifyPackingTagged': 'Packing list: assignments',
'settings.notifyWebhook': 'Webhook notifications', '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.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.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.smtp.testButton': 'Send test email',
'admin.smtp.testSuccess': 'Test email sent successfully', 'admin.smtp.testSuccess': 'Test email sent successfully',
'admin.smtp.testFailed': 'Test email failed', 'admin.smtp.testFailed': 'Test email failed',
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
'settings.notificationsActive': 'Active channel',
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
'dayplan.icsTooltip': 'Export calendar (ICS)', 'dayplan.icsTooltip': 'Export calendar (ICS)',
'share.linkTitle': 'Public Link', 'share.linkTitle': 'Public Link',
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.', 'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
@@ -227,6 +243,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordMismatch': 'Passwords do not match', 'settings.passwordMismatch': 'Passwords do not match',
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number', 'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
'settings.passwordChanged': 'Password changed successfully', '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.deleteAccount': 'Delete account',
'settings.deleteAccountTitle': 'Delete your 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.', 'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
@@ -296,6 +313,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Sign In', 'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account', 'login.createAdmin': 'Create Admin Account',
'login.createAdminHint': 'Set up the first admin account for TREK.', '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.createAccount': 'Create Account',
'login.createAccountHint': 'Register a new account.', 'login.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…', 'login.creating': 'Creating…',

View File

@@ -25,6 +25,7 @@ const es: Record<string, string> = {
'common.email': 'Correo', 'common.email': 'Correo',
'common.password': 'Contraseña', 'common.password': 'Contraseña',
'common.saving': 'Guardando...', 'common.saving': 'Guardando...',
'common.saved': 'Guardado',
'common.update': 'Actualizar', 'common.update': 'Actualizar',
'common.change': 'Cambiar', 'common.change': 'Cambiar',
'common.uploading': 'Subiendo…', 'common.uploading': 'Subiendo…',
@@ -150,8 +151,23 @@ const es: Record<string, string> = {
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)', 'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones', 'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
'settings.notifyWebhook': 'Notificaciones webhook', '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.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.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.smtp.testButton': 'Enviar correo de prueba',
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente', 'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
'admin.smtp.testFailed': 'Error al enviar correo de prueba', 'admin.smtp.testFailed': 'Error al enviar correo de prueba',
@@ -295,6 +311,8 @@ const es: Record<string, string> = {
'login.signIn': 'Entrar', 'login.signIn': 'Entrar',
'login.createAdmin': 'Crear cuenta de administrador', 'login.createAdmin': 'Crear cuenta de administrador',
'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.', '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.createAccount': 'Crear cuenta',
'login.createAccountHint': 'Crea una cuenta nueva.', 'login.createAccountHint': 'Crea una cuenta nueva.',
'login.creating': 'Creando…', 'login.creating': 'Creando…',

View File

@@ -25,6 +25,7 @@ const fr: Record<string, string> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Mot de passe', 'common.password': 'Mot de passe',
'common.saving': 'Enregistrement…', 'common.saving': 'Enregistrement…',
'common.saved': 'Enregistré',
'common.update': 'Mettre à jour', 'common.update': 'Mettre à jour',
'common.change': 'Modifier', 'common.change': 'Modifier',
'common.uploading': 'Import en cours…', 'common.uploading': 'Import en cours…',
@@ -149,8 +150,23 @@ const fr: Record<string, string> = {
'settings.notifyCollabMessage': 'Messages de chat (Collab)', 'settings.notifyCollabMessage': 'Messages de chat (Collab)',
'settings.notifyPackingTagged': 'Liste de bagages : attributions', 'settings.notifyPackingTagged': 'Liste de bagages : attributions',
'settings.notifyWebhook': 'Notifications webhook', '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.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.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.smtp.testButton': 'Envoyer un e-mail de test',
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès', 'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
'admin.smtp.testFailed': 'Échec de l\'e-mail de test', 'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
@@ -296,6 +312,8 @@ const fr: Record<string, string> = {
'login.signIn': 'Se connecter', 'login.signIn': 'Se connecter',
'login.createAdmin': 'Créer un compte administrateur', 'login.createAdmin': 'Créer un compte administrateur',
'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.', '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.createAccount': 'Créer un compte',
'login.createAccountHint': 'Créez un nouveau compte.', 'login.createAccountHint': 'Créez un nouveau compte.',
'login.creating': 'Création…', 'login.creating': 'Création…',

View File

@@ -25,6 +25,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Jelszó', 'common.password': 'Jelszó',
'common.saving': 'Mentés...', 'common.saving': 'Mentés...',
'common.saved': 'Mentve',
'common.update': 'Frissítés', 'common.update': 'Frissítés',
'common.change': 'Módosítás', 'common.change': 'Módosítás',
'common.uploading': 'Feltöltés…', 'common.uploading': 'Feltöltés…',
@@ -149,6 +150,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)', 'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések', 'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
'settings.notifyWebhook': 'Webhook értesíté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.on': 'Be',
'settings.off': 'Ki', 'settings.off': 'Ki',
'settings.mcp.title': 'MCP konfiguráció', 'settings.mcp.title': 'MCP konfiguráció',
@@ -234,8 +238,20 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve', 'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva', 'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
'settings.mfa.demoBlocked': 'Demo módban nem érhető el', '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.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.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.smtp.testButton': 'Teszt e-mail küldése',
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve', 'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen', 'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
@@ -296,6 +312,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Bejelentkezés', 'login.signIn': 'Bejelentkezés',
'login.createAdmin': 'Admin fiók létrehozása', 'login.createAdmin': 'Admin fiók létrehozása',
'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.', '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.createAccount': 'Fiók létrehozása',
'login.createAccountHint': 'Új fiók regisztrálása.', 'login.createAccountHint': 'Új fiók regisztrálása.',
'login.creating': 'Létrehozás…', 'login.creating': 'Létrehozás…',

View File

@@ -25,6 +25,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
'common.saving': 'Salvataggio...', 'common.saving': 'Salvataggio...',
'common.saved': 'Salvato',
'common.update': 'Aggiorna', 'common.update': 'Aggiorna',
'common.change': 'Cambia', 'common.change': 'Cambia',
'common.uploading': 'Caricamento…', 'common.uploading': 'Caricamento…',
@@ -149,6 +150,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Messaggi chat (Collab)', 'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni', 'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
'settings.notifyWebhook': 'Notifiche webhook', '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.on': 'On',
'settings.off': 'Off', 'settings.off': 'Off',
'settings.mcp.title': 'Configurazione MCP', 'settings.mcp.title': 'Configurazione MCP',
@@ -234,8 +238,20 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata', 'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata', 'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo', '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.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.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.smtp.testButton': 'Invia email di prova',
'admin.smtp.testSuccess': 'Email di prova inviata con successo', 'admin.smtp.testSuccess': 'Email di prova inviata con successo',
'admin.smtp.testFailed': 'Invio email di prova fallito', 'admin.smtp.testFailed': 'Invio email di prova fallito',
@@ -296,6 +312,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Accedi', 'login.signIn': 'Accedi',
'login.createAdmin': 'Crea Account Amministratore', 'login.createAdmin': 'Crea Account Amministratore',
'login.createAdminHint': 'Imposta il primo account amministratore per TREK.', '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.createAccount': 'Crea Account',
'login.createAccountHint': 'Registra un nuovo account.', 'login.createAccountHint': 'Registra un nuovo account.',
'login.creating': 'Creazione in corso…', 'login.creating': 'Creazione in corso…',

View File

@@ -25,6 +25,7 @@ const nl: Record<string, string> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Wachtwoord', 'common.password': 'Wachtwoord',
'common.saving': 'Opslaan...', 'common.saving': 'Opslaan...',
'common.saved': 'Opgeslagen',
'common.update': 'Bijwerken', 'common.update': 'Bijwerken',
'common.change': 'Wijzigen', 'common.change': 'Wijzigen',
'common.uploading': 'Uploaden…', 'common.uploading': 'Uploaden…',
@@ -149,8 +150,23 @@ const nl: Record<string, string> = {
'settings.notifyCollabMessage': 'Chatberichten (Collab)', 'settings.notifyCollabMessage': 'Chatberichten (Collab)',
'settings.notifyPackingTagged': 'Paklijst: toewijzingen', 'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
'settings.notifyWebhook': 'Webhook-meldingen', '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.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.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.smtp.testButton': 'Test-e-mail verzenden',
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden', 'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
'admin.smtp.testFailed': 'Test-e-mail mislukt', 'admin.smtp.testFailed': 'Test-e-mail mislukt',
@@ -296,6 +312,8 @@ const nl: Record<string, string> = {
'login.signIn': 'Inloggen', 'login.signIn': 'Inloggen',
'login.createAdmin': 'Beheerdersaccount aanmaken', 'login.createAdmin': 'Beheerdersaccount aanmaken',
'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.', '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.createAccount': 'Account aanmaken',
'login.createAccountHint': 'Registreer een nieuw account.', 'login.createAccountHint': 'Registreer een nieuw account.',
'login.creating': 'Aanmaken…', 'login.creating': 'Aanmaken…',

View File

@@ -25,6 +25,7 @@ const ru: Record<string, string> = {
'common.email': 'Эл. почта', 'common.email': 'Эл. почта',
'common.password': 'Пароль', 'common.password': 'Пароль',
'common.saving': 'Сохранение...', 'common.saving': 'Сохранение...',
'common.saved': 'Сохранено',
'common.update': 'Обновить', 'common.update': 'Обновить',
'common.change': 'Изменить', 'common.change': 'Изменить',
'common.uploading': 'Загрузка…', 'common.uploading': 'Загрузка…',
@@ -149,8 +150,23 @@ const ru: Record<string, string> = {
'settings.notifyCollabMessage': 'Сообщения чата (Collab)', 'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
'settings.notifyPackingTagged': 'Список вещей: назначения', 'settings.notifyPackingTagged': 'Список вещей: назначения',
'settings.notifyWebhook': 'Webhook-уведомления', '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.save': 'Сохранить настройки уведомлений',
'admin.notifications.saved': 'Настройки уведомлений сохранены',
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
'admin.notifications.testWebhookSuccess': 'Тестовый вебхук успешно отправлен',
'admin.notifications.testWebhookFailed': 'Ошибка отправки тестового вебхука',
'admin.smtp.title': 'Почта и уведомления', 'admin.smtp.title': 'Почта и уведомления',
'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.', 'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
'admin.smtp.testButton': 'Отправить тестовое письмо', 'admin.smtp.testButton': 'Отправить тестовое письмо',
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено', 'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
'admin.smtp.testFailed': 'Ошибка отправки тестового письма', 'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
@@ -296,6 +312,8 @@ const ru: Record<string, string> = {
'login.signIn': 'Войти', 'login.signIn': 'Войти',
'login.createAdmin': 'Создать аккаунт администратора', 'login.createAdmin': 'Создать аккаунт администратора',
'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.', 'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.',
'login.setNewPassword': 'Установить новый пароль',
'login.setNewPasswordHint': 'Вы должны сменить пароль, прежде чем продолжить.',
'login.createAccount': 'Создать аккаунт', 'login.createAccount': 'Создать аккаунт',
'login.createAccountHint': 'Зарегистрируйте новый аккаунт.', 'login.createAccountHint': 'Зарегистрируйте новый аккаунт.',
'login.creating': 'Создание…', 'login.creating': 'Создание…',

View File

@@ -25,6 +25,7 @@ const zh: Record<string, string> = {
'common.email': '邮箱', 'common.email': '邮箱',
'common.password': '密码', 'common.password': '密码',
'common.saving': '保存中...', 'common.saving': '保存中...',
'common.saved': '已保存',
'common.update': '更新', 'common.update': '更新',
'common.change': '修改', 'common.change': '修改',
'common.uploading': '上传中…', 'common.uploading': '上传中…',
@@ -149,8 +150,23 @@ const zh: Record<string, string> = {
'settings.notifyCollabMessage': '聊天消息 (Collab)', 'settings.notifyCollabMessage': '聊天消息 (Collab)',
'settings.notifyPackingTagged': '行李清单:分配', 'settings.notifyPackingTagged': '行李清单:分配',
'settings.notifyWebhook': 'Webhook 通知', '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.save': '保存通知设置',
'admin.notifications.saved': '通知设置已保存',
'admin.notifications.testWebhook': '发送测试 Webhook',
'admin.notifications.testWebhookSuccess': '测试 Webhook 发送成功',
'admin.notifications.testWebhookFailed': '测试 Webhook 发送失败',
'admin.smtp.title': '邮件与通知', 'admin.smtp.title': '邮件与通知',
'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选Discord、Slack 等的 Webhook URL。', 'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
'admin.smtp.testButton': '发送测试邮件', 'admin.smtp.testButton': '发送测试邮件',
'admin.smtp.testSuccess': '测试邮件发送成功', 'admin.smtp.testSuccess': '测试邮件发送成功',
'admin.smtp.testFailed': '测试邮件发送失败', 'admin.smtp.testFailed': '测试邮件发送失败',
@@ -296,6 +312,8 @@ const zh: Record<string, string> = {
'login.signIn': '登录', 'login.signIn': '登录',
'login.createAdmin': '创建管理员账户', 'login.createAdmin': '创建管理员账户',
'login.createAdminHint': '为 TREK 设置第一个管理员账户。', 'login.createAdminHint': '为 TREK 设置第一个管理员账户。',
'login.setNewPassword': '设置新密码',
'login.setNewPasswordHint': '您必须更改密码才能继续。',
'login.createAccount': '创建账户', 'login.createAccount': '创建账户',
'login.createAccountHint': '注册新账户。', 'login.createAccountHint': '注册新账户。',
'login.creating': '创建中…', 'login.creating': '创建中…',

View File

@@ -974,64 +974,172 @@ export default function AdminPage(): React.ReactElement {
</button> </button>
</div> </div>
</div> </div>
{/* SMTP / Notifications */} {/* Notifications — exclusive channel selector */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100"> <div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2> <h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p> <p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
</div> </div>
<div className="p-6 space-y-3"> <div className="p-6 space-y-4">
{smtpLoaded && [ {/* Channel selector */}
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, <div className="flex gap-2">
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, {(['none', 'email', 'webhook'] as const).map(ch => {
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, const active = (smtpValues.notification_channel || 'none') === ch
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, return (
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' }, <button
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' }, key={ch}
].map(field => ( onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
<div key={field.key}> className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors border ${active ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'}`}
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label> >
<input {labels[ch]}
type={field.type || 'text'} </button>
value={smtpValues[field.key] || ''} )
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} })}
placeholder={field.placeholder} </div>
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" {/* Notification event toggles — shown when any channel is active */}
/> {(smtpValues.notification_channel || 'none') !== 'none' && (
</div> <div className="space-y-2 pt-2 border-t border-slate-100">
))} <p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
{/* Skip TLS toggle */} <p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}> {[
<div> { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span> { key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p> { key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
</div> { key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
<button onClick={async () => { { key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true' { key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal })) { key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
await authApi.updateAppSettings({ smtp_skip_tls_verify: newVal }).catch(() => {}) ].map(opt => {
}} const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${smtpValues.smtp_skip_tls_verify === 'true' ? 'bg-slate-900' : 'bg-slate-300'}`}> return (
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${smtpValues.smtp_skip_tls_verify === 'true' ? 'translate-x-6' : 'translate-x-1'}`} /> <div key={opt.key} className="flex items-center justify-between py-1">
</button> <span className="text-sm text-slate-700">{opt.label}</span>
<button
onClick={() => {
const newVal = isOn ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, [opt.key]: newVal }))
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isOn ? 'bg-slate-900' : 'bg-slate-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isOn ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)
})}
</div>
)}
{/* Email (SMTP) settings — shown when email channel is active */}
{(smtpValues.notification_channel || 'none') === 'email' && (
<div className="space-y-3 pt-2 border-t border-slate-100">
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
{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 => (
<div key={field.key}>
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
<input
type={field.type || 'text'}
value={smtpValues[field.key] || ''}
onChange={e => 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"
/>
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
<div>
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
</div>
<button onClick={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${smtpValues.smtp_skip_tls_verify === 'true' ? 'bg-slate-900' : 'bg-slate-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${smtpValues.smtp_skip_tls_verify === 'true' ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
)}
{/* Webhook settings — shown when webhook channel is active */}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
<div className="space-y-3 pt-2 border-t border-slate-100">
<p className="text-xs text-slate-400">{t('admin.webhook.hint') || 'Send notifications to an external webhook (Discord, Slack, etc.).'}</p>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
<input
type="text"
value={smtpValues.notification_webhook_url || ''}
onChange={e => 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"
/>
<p className="text-[10px] text-slate-400 mt-1">TREK will POST JSON with event, title, body, and timestamp to this URL.</p>
</div>
</div>
)}
{/* Save + Test buttons */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<button
onClick={async () => {
const notifKeys = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']
const payload: Record<string, string> = {}
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
try {
await authApi.updateAppSettings(payload)
toast.success(t('admin.notifications.saved'))
} catch { toast.error(t('common.error')) }
}}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
<Save className="w-4 h-4" />
{t('common.save')}
</button>
{(smtpValues.notification_channel || 'none') === 'email' && (
<button
onClick={async () => {
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
for (const k of smtpKeys) { if (smtpValues[k]) payload[k] = smtpValues[k] }
await authApi.updateAppSettings(payload).catch(() => {})
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
)}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
<button
onClick={async () => {
if (smtpValues.notification_webhook_url) {
await authApi.updateAppSettings({ notification_webhook_url: smtpValues.notification_webhook_url }).catch(() => {})
}
try {
const result = await notificationsApi.testWebhook()
if (result.success) toast.success(t('admin.notifications.testWebhookSuccess'))
else toast.error(result.error || t('admin.notifications.testWebhookFailed'))
} catch { toast.error(t('admin.notifications.testWebhookFailed')) }
}}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
{t('admin.notifications.testWebhook')}
</button>
)}
</div> </div>
<button
onClick={async () => {
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
}
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1039,7 +1147,7 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'backup' && <BackupPanel />} {activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel />} {activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />} {activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}

View File

@@ -9,6 +9,7 @@ import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe,
interface AppConfig { interface AppConfig {
has_users: boolean has_users: boolean
allow_registration: boolean allow_registration: boolean
setup_complete: boolean
demo_mode: boolean demo_mode: boolean
oidc_configured: boolean oidc_configured: boolean
oidc_display_name?: string oidc_display_name?: string
@@ -28,7 +29,7 @@ export default function LoginPage(): React.ReactElement {
const [inviteToken, setInviteToken] = useState<string>('') const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false) const [inviteValid, setInviteValid] = useState<boolean>(false)
const { login, register, demoLogin, completeMfaLogin } = useAuthStore() const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore() const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate() const navigate = useNavigate()
@@ -110,19 +111,39 @@ export default function LoginPage(): React.ReactElement {
const [mfaStep, setMfaStep] = useState(false) const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('') const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = 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<HTMLFormElement>): Promise<void> => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setIsLoading(true) setIsLoading(true)
try { 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 (mode === 'login' && mfaStep) {
if (!mfaCode.trim()) { if (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired')) setError(t('login.mfaCodeRequired'))
setIsLoading(false) setIsLoading(false)
return 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) setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600) setTimeout(() => navigate('/dashboard'), 2600)
return return
@@ -140,6 +161,12 @@ export default function LoginPage(): React.ReactElement {
setIsLoading(false) setIsLoading(false)
return return
} }
if ('user' in result && result.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
} }
setShowTakeoff(true) setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600) 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 // In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
@@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement {
) : ( ) : (
<> <>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}> <h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'login' && mfaStep {passwordChangeStep
? t('login.mfaTitle') ? t('login.setNewPassword')
: mode === 'register' : mode === 'login' && mfaStep
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) ? t('login.mfaTitle')
: t('login.title')} : mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
</h2> </h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}> <p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{mode === 'login' && mfaStep {passwordChangeStep
? t('login.mfaSubtitle') ? t('login.setNewPasswordHint')
: mode === 'register' : mode === 'login' && mfaStep
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) ? t('login.mfaSubtitle')
: t('login.subtitle')} : mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
</p> </p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -537,7 +568,39 @@ export default function LoginPage(): React.ReactElement {
</div> </div>
)} )}
{mode === 'login' && mfaStep && ( {passwordChangeStep && (
<>
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 13, color: '#92400e' }}>
{t('settings.mustChangePassword')}
</div>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.newPassword')}</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="password" value={newPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)} required
placeholder={t('settings.newPassword')} style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.confirmPassword')}</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="password" value={confirmPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)} required
placeholder={t('settings.confirmPassword')} style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
</>
)}
{mode === 'login' && mfaStep && !passwordChangeStep && (
<div> <div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label> <label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement {
)} )}
{/* Username (register only) */} {/* Username (register only) */}
{mode === 'register' && ( {mode === 'register' && !passwordChangeStep && (
<div> <div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label> <label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement {
)} )}
{/* Email */} {/* Email */}
{!(mode === 'login' && mfaStep) && ( {!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div> <div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label> <label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement {
)} )}
{/* Password */} {/* Password */}
{!(mode === 'login' && mfaStep) && ( {!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div> <div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label> <label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'} onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
> >
{isLoading {isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</> ? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</> : <><Plane size={16} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
} }
</button> </button>
</form> </form>
{/* Toggle login/register */} {/* Toggle login/register */}
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && ( {showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && !passwordChangeStep && (
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}> <p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '} {mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }} <button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}

View File

@@ -7,7 +7,7 @@ import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check } from 'lucide-react' import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check } from 'lucide-react'
import { authApi, adminApi, notificationsApi } from '../api/client' import { authApi, adminApi } from '../api/client'
import apiClient from '../api/client' import apiClient from '../api/client'
import { useAddonStore } from '../store/addonStore' import { useAddonStore } from '../store/addonStore'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
@@ -56,56 +56,54 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
) )
} }
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) { function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
const [prefs, setPrefs] = useState<Record<string, number> | null>(null) return (
const [addons, setAddons] = useState<Record<string, boolean>>({}) <button onClick={onToggle}
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, []) style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<span style={{
position: 'absolute', top: 2, left: on ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: 'white',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)
}
function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
const [notifChannel, setNotifChannel] = useState<string>('none')
useEffect(() => { useEffect(() => {
apiClient.get('/addons').then(r => { authApi.getAppConfig?.().then((cfg: any) => {
const map: Record<string, boolean> = {} if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
setAddons(map)
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
const toggle = async (key: string) => { if (notifChannel === 'none') {
if (!prefs) return return (
const newVal = prefs[key] ? 0 : 1 <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev) {t('settings.notificationsDisabled')}
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {} </p>
)
} }
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p> const channelLabel = notifChannel === 'email'
? (t('admin.notifications.email') || 'Email (SMTP)')
const options = [ : (t('admin.notifications.webhook') || 'Webhook')
{ 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') },
]
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{options.map(opt => ( <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span> <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
<button onClick={() => toggle(opt.key)} {t('settings.notificationsActive')}: {channelLabel}
style={{ </span>
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer', </div>
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)', <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
transition: 'background 0.2s', {t('settings.notificationsManagedByAdmin')}
}}> </p>
<span style={{
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: 'white',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
))}
</div> </div>
) )
} }
@@ -924,6 +922,7 @@ export default function SettingsPage(): React.ReactElement {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword }) await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged')) toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) { } catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error'))) toast.error(getApiErrorMessage(err, t('common.error')))
} }

View File

@@ -10,6 +10,8 @@ export interface User {
created_at: string created_at: string
/** Present after load; true when TOTP MFA is enabled for password login */ /** Present after load; true when TOTP MFA is enabled for password login */
mfa_enabled?: boolean mfa_enabled?: boolean
/** True when a password change is required before the user can continue */
must_change_password?: boolean
} }
export interface Trip { export interface Trip {

View File

@@ -1,31 +1,30 @@
services: 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: app:
image: mauriceboe/trek:latest image: mauriceboe/trek:latest
container_name: trek container_name: trek
depends_on: read_only: true
init-permissions: security_opt:
condition: service_completed_successfully - no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:-} - JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins # ALLOWED_ORIGINS: restrict CORS + used as the app URL in email notification links
# If not set, same-origin CORS is used and email links default to http://localhost:PORT
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- PORT=3000 - PORT=3000
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
# LOG_LEVEL: info (default) or debug (verbose details in docker logs)
- LOG_LEVEL=${LOG_LEVEL:-info}
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads

View File

@@ -1,6 +1,7 @@
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
DEBUG=false DEBUG=false
LOG_LEVEL=info
# REQUIRED for production — generate with: openssl rand -hex 32 # REQUIRED for production — generate with: openssl rand -hex 32
JWT_SECRET=CHANGEME_GENERATE_WITH_openssl_rand_hex_32 JWT_SECRET=CHANGEME_GENERATE_WITH_openssl_rand_hex_32

View File

@@ -428,9 +428,11 @@ function runMigrations(db: Database.Database): void {
} catch {} } 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 places ADD COLUMN route_geometry TEXT'); } catch {}
}, },
() => {
try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {

View File

@@ -18,6 +18,7 @@ function createTables(db: Database.Database): void {
mfa_enabled INTEGER DEFAULT 0, mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT, mfa_secret TEXT,
mfa_backup_codes TEXT, mfa_backup_codes TEXT,
must_change_password INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );

View File

@@ -1,4 +1,31 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import crypto from 'crypto';
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;
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 { function seedCategories(db: Database.Database): void {
try { try {
@@ -45,6 +72,7 @@ function seedAddons(db: Database.Database): void {
} }
function runSeeds(db: Database.Database): void { function runSeeds(db: Database.Database): void {
seedAdminAccount(db);
seedCategories(db); seedCategories(db);
seedAddons(db); seedAddons(db);
} }

View File

@@ -9,6 +9,7 @@ import fs from 'fs';
const app = express(); const app = express();
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true'; 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 // Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) { if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
@@ -29,21 +30,18 @@ const tmpDir = path.join(__dirname, '../data/tmp');
// Middleware // Middleware
const allowedOrigins = process.env.ALLOWED_ORIGINS const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',') ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null; : null;
let corsOrigin: cors.CorsOptions['origin']; let corsOrigin: cors.CorsOptions['origin'];
if (allowedOrigins) { if (allowedOrigins) {
// Explicit whitelist from env var
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true); if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS')); else callback(new Error('Not allowed by CORS'));
}; };
} else if (process.env.NODE_ENV === 'production') { } else if (process.env.NODE_ENV === 'production') {
// Production: same-origin only (Express serves the static client)
corsOrigin = false; corsOrigin = false;
} else { } else {
// Development: allow all origins (needed for Vite dev server)
corsOrigin = true; corsOrigin = true;
} }
@@ -92,30 +90,36 @@ app.use(express.urlencoded({ extended: true }));
app.use(enforceGlobalMfaPolicy); 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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : _redact(v);
}
return out;
};
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path === '/api/health') return next();
const startedAt = Date.now(); 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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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', () => { res.on('finish', () => {
const elapsedMs = Date.now() - startedAt; const ms = Date.now() - startedAt;
console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
});
if (res.statusCode >= 500) {
_logError(`${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(); next();
}); });
} }
@@ -246,17 +250,32 @@ import * as scheduler from './scheduler';
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
console.log(`TREK API running on port ${PORT}`); const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`); 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) { 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') { 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.start();
scheduler.startTripReminders();
scheduler.startDemoReset(); scheduler.startDemoReset();
import('./websocket').then(({ setupWebSocket }) => { import('./websocket').then(({ setupWebSocket }) => {
setupWebSocket(server); setupWebSocket(server);
@@ -265,19 +284,19 @@ const server = app.listen(PORT, () => {
// Graceful shutdown // Graceful shutdown
function shutdown(signal: string): void { 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(); scheduler.stop();
closeMcpSessions(); closeMcpSessions();
server.close(() => { server.close(() => {
console.log('HTTP server closed'); sLogInfo('HTTP server closed');
const { closeDb } = require('./db/database'); const { closeDb } = require('./db/database');
closeDb(); closeDb();
console.log('Shutdown complete'); sLogInfo('Shutdown complete');
process.exit(0); process.exit(0);
}); });
// Force exit after 10s if connections don't close
setTimeout(() => { setTimeout(() => {
console.error('Forced shutdown after timeout'); sLogError('Forced shutdown after timeout');
process.exit(1); process.exit(1);
}, 10000); }, 10000);
} }

View File

@@ -189,7 +189,8 @@ router.get('/audit-log', (req: Request, res: Response) => {
details = { _parse_error: true }; 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, total,
limit, limit,

View File

@@ -83,6 +83,7 @@ function stripUserForClient(user: User): Record<string, unknown> {
updated_at: utcSuffix(rest.updated_at), updated_at: utcSuffix(rest.updated_at),
last_login: utcSuffix(rest.last_login), last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), 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 +184,12 @@ 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 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 oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
res.json({ res.json({
allow_registration: isDemo ? false : allowRegistration, allow_registration: isDemo ? false : allowRegistration,
has_users: userCount > 0, has_users: userCount > 0,
setup_complete: setupComplete,
version, version,
has_maps_key: hasGoogleKey, has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured, oidc_configured: oidcConfigured,
@@ -197,6 +201,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_email: isDemo ? 'demo@trek.app' : undefined, demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined, demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
}); });
}); });
@@ -290,6 +295,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 } }); res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err: unknown) { } catch (err: unknown) {
res.status(500).json({ error: 'Error creating user' }); res.status(500).json({ error: 'Error creating user' });
@@ -309,11 +315,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; const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
if (!user) { 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' }); return res.status(401).json({ error: 'Invalid email or password' });
} }
const validPassword = bcrypt.compareSync(password, user.password_hash!); const validPassword = bcrypt.compareSync(password, user.password_hash!);
if (!validPassword) { 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' }); return res.status(401).json({ error: 'Invalid email or password' });
} }
@@ -330,13 +338,14 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
const token = generateToken(user); const token = generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>; const userSafe = stripUserForClient(user) as Record<string, unknown>;
writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } });
res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } });
}); });
router.get('/me', authenticate, (req: Request, res: Response) => { router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const user = db.prepare( 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; ).get(authReq.user.id) as User | undefined;
if (!user) { if (!user) {
@@ -370,7 +379,8 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
} }
const hash = bcrypt.hashSync(new_password, 12); 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 }); res.json({ success: true });
}); });
@@ -385,6 +395,7 @@ router.delete('/me', authenticate, (req: Request, res: Response) => {
return res.status(400).json({ error: 'Cannot delete the last admin account' }); 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); db.prepare('DELETE FROM users WHERE id = ?').run(authReq.user.id);
res.json({ success: true }); res.json({ success: true });
}); });
@@ -606,7 +617,7 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
res.json(result); 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) => { router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
@@ -626,7 +637,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; 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' }); 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<string, unknown>; const { require_mfa } = req.body as Record<string, unknown>;
if (require_mfa === true || require_mfa === 'true') { 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; const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
@@ -648,15 +659,30 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); 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<string, unknown> = {};
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<string, unknown> = {};
for (const k of changedKeys) {
debugDetails[k] = k === 'smtp_pass' ? '***' : req.body[k];
}
writeAudit({ writeAudit({
userId: authReq.user.id, userId: authReq.user.id,
action: 'settings.app_update', action: 'settings.app_update',
ip: getClientIp(req), ip: getClientIp(req),
details: { details: summary,
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined, debugDetails,
allowed_file_types_changed: allowed_file_types !== undefined,
require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
},
}); });
res.json({ success: true }); res.json({ success: true });
}); });
@@ -768,6 +794,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); db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
const sessionToken = generateToken(user); const sessionToken = generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>; const userSafe = stripUserForClient(user) as Record<string, unknown>;
writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } });
} catch { } catch {
return res.status(401).json({ error: 'Invalid or expired verification token' }); return res.status(401).json({ error: 'Invalid or expired verification token' });

View File

@@ -126,6 +126,11 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
const formatted = formatNote(note); const formatted = formatNote(note);
res.status(201).json({ note: formatted }); res.status(201).json({ note: formatted });
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string); 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.username }).catch(() => {});
});
}); });
router.put('/notes/:id', authenticate, (req: Request, res: Response) => { router.put('/notes/:id', authenticate, (req: Request, res: Response) => {

View File

@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
import { db } from '../db/database'; import { db } from '../db/database';
import { authenticate } from '../middleware/auth'; import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types'; import { AuthRequest } from '../types';
import { testSmtp } from '../services/notifications'; import { testSmtp, testWebhook } from '../services/notifications';
const router = express.Router(); const router = express.Router();
@@ -55,4 +55,13 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
res.json(result); 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; export default router;

View File

@@ -222,6 +222,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
res.json({ reservation: updated }); res.json({ reservation: updated });
broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id'] as string); 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.username, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
});
}); });
router.delete('/:id', authenticate, (req: Request, res: Response) => { 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); const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); 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' }); if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
// Delete linked accommodation if exists
if (reservation.accommodation_id) { if (reservation.accommodation_id) {
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(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); 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); db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string); 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.username, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
});
}); });
export default router; export default router;

View File

@@ -7,6 +7,7 @@ import { db, canAccessTrip, isOwner } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth'; import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket'; import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types'; import { AuthRequest, Trip, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
const router = express.Router(); const router = express.Router();
@@ -147,6 +148,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const tripId = result.lastInsertRowid; const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date); generateDays(tripId, start_date, end_date);
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title } });
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId }); const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
res.status(201).json({ trip }); res.status(201).json({ trip });
}); });
@@ -229,6 +231,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
if (!isOwner(req.params.id, authReq.user.id)) if (!isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'Only the owner can delete the trip' }); return res.status(403).json({ error: 'Only the owner can delete the trip' });
const deletedTripId = Number(req.params.id); const deletedTripId = Number(req.params.id);
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId } });
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string); broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
@@ -287,7 +290,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
// Notify invited user // Notify invited user
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined; const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
import('../services/notifications').then(({ notify }) => { import('../services/notifications').then(({ notify }) => {
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {}); notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, invitee: target.username } }).catch(() => {});
}); });
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } }); res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });

View File

@@ -79,9 +79,11 @@ async function runBackup(): Promise<void> {
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads'); if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize(); archive.finalize();
}); });
console.log(`[Auto-Backup] Created: ${filename}`); const { logInfo: li } = require('./services/auditLog');
li(`Auto-Backup created: ${filename}`);
} catch (err: unknown) { } 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); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return; return;
} }
@@ -102,11 +104,13 @@ function cleanupOldBackups(keepDays: number): void {
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) { if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath); 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) { } 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(); const settings = loadSettings();
if (!settings.enabled) { if (!settings.enabled) {
console.log('[Auto-Backup] Disabled'); const { logInfo: li } = require('./services/auditLog');
li('Auto-Backup disabled');
return; return;
} }
const expression = buildCronExpression(settings); const expression = buildCronExpression(settings);
const tz = process.env.TZ || 'UTC'; const tz = process.env.TZ || 'UTC';
currentTask = cron.schedule(expression, runBackup, { timezone: tz }); 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 // Demo mode: hourly reset of demo user data
@@ -140,15 +146,62 @@ function startDemoReset(): void {
const { resetDemoUser } = require('./demo/demo-reset'); const { resetDemoUser } = require('./demo/demo-reset');
resetDemoUser(); resetDemoUser();
} catch (err: unknown) { } 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; }
const tz = process.env.TZ || 'UTC';
reminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { db } = require('./db/database');
const { notify } = require('./services/notifications');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().split('T')[0];
const trips = db.prepare(`
SELECT t.id, t.title, t.user_id FROM trips t
WHERE t.start_date = ?
`).all(dateStr) as { id: number; title: string; user_id: number }[];
for (const trip of trips) {
await notify({ userId: trip.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[];
for (const m of members) {
await notify({ userId: m.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
}
}
if (trips.length > 0) {
const { logInfo: li } = require('./services/auditLog');
li(`Trip reminders sent for ${trips.length} trip(s) starting ${dateStr}`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
const { logInfo: li4 } = require('./services/auditLog');
li4(`Trip reminders scheduled: daily at 09:00 (${tz})`);
} }
function stop(): void { function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; } if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = 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 };

View File

@@ -1,5 +1,80 @@
import { Request } from 'express'; import { Request } from 'express';
import { db } from '../db/database'; 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 { export function getClientIp(req: Request): string | null {
const xff = req.headers['x-forwarded-for']; const xff = req.headers['x-forwarded-for'];
@@ -11,12 +86,37 @@ export function getClientIp(req: Request): string | null {
return req.socket?.remoteAddress || 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<string, string> = {
'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. */ /** Best-effort; never throws — failures are logged only. */
export function writeAudit(entry: { export function writeAudit(entry: {
userId: number | null; userId: number | null;
action: string; action: string;
resource?: string | null; resource?: string | null;
details?: Record<string, unknown>; details?: Record<string, unknown>;
debugDetails?: Record<string, unknown>;
ip?: string | null; ip?: string | null;
}): void { }): void {
try { try {
@@ -24,7 +124,41 @@ export function writeAudit(entry: {
db.prepare( db.prepare(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)` `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null); ).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) { } 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, unknown>): 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 };

View File

@@ -1,6 +1,7 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { db } from '../db/database'; import { db } from '../db/database';
import { logInfo, logDebug, logError } from './auditLog';
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@@ -42,7 +43,13 @@ function getWebhookUrl(): string | null {
} }
function getAppUrl(): string { 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 { 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'; 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<string, number> { function getAdminEventEnabled(event: EventType): boolean {
const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any; const prefKey = EVENT_PREF_MAP[event];
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 }; 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 // Event → preference column mapping
@@ -90,7 +99,7 @@ type EventTextFn = (params: Record<string, string>) => EventText
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = { const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
en: { 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}".` }), 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!` }), 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.` }), 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<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }), packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
}, },
de: { 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.` }), 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!` }), 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.` }), 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<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }), packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
}, },
fr: { 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}".` }), 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 !` }), 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.` }), 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<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }), packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
}, },
es: { 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}".` }), 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!` }), 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.` }), 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<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }), packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
}, },
nl: { 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}".` }), 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!` }), 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.` }), 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<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }), packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
}, },
ru: { 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}".` }), booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }), trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }), vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
@@ -144,7 +153,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }), packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
}, },
zh: { 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})。` }), booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"${p.type})。` }),
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }), trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }), vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
@@ -153,7 +162,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }), packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
}, },
ar: { 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}".` }), booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }), trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }), 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, text: body,
html: buildEmailHtml(subject, body, lang), 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; return true;
} catch (err) { } 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; 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<boolean> { async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
const url = getWebhookUrl(); const url = getWebhookUrl();
if (!url) return false; if (!url) return false;
try { try {
await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }), body: buildWebhookBody(url, payload),
signal: AbortSignal.timeout(10000), 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; return true;
} catch (err) { } 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; return false;
} }
} }
// ── Public API ───────────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
function getNotificationChannel(): string {
return getAppSetting('notification_channel') || 'none';
}
export async function notify(payload: NotificationPayload): Promise<void> { export async function notify(payload: NotificationPayload): Promise<void> {
const prefs = getUserPrefs(payload.userId); const channel = getNotificationChannel();
const prefKey = EVENT_PREF_MAP[payload.event]; if (channel === 'none') return;
if (prefKey && !prefs[prefKey]) return;
if (!getAdminEventEnabled(payload.event)) return;
const lang = getUserLanguage(payload.userId); const lang = getUserLanguage(payload.userId);
const { title, body } = getEventText(lang, payload.event, payload.params); const { title, body } = getEventText(lang, payload.event, payload.params);
const email = getUserEmail(payload.userId); logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`);
if (email) await sendEmail(email, title, body, payload.userId);
if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip }); 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<string, string>): Promise<void> { export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
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; const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
if (!trip) return; 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 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 allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
const unique = [...new Set(allIds)]; 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' }; 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' };
}
}

View File

@@ -16,6 +16,7 @@ export interface User {
mfa_enabled?: number | boolean; mfa_enabled?: number | boolean;
mfa_secret?: string | null; mfa_secret?: string | null;
mfa_backup_codes?: string | null; mfa_backup_codes?: string | null;
must_change_password?: number | boolean;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }