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

@@ -316,6 +316,7 @@ export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').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),
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
}
export default apiClient

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail',
'common.password': 'Senha',
'common.saving': 'Salvando...',
'common.saved': 'Salvo',
'common.update': 'Atualizar',
'common.change': 'Alterar',
'common.uploading': 'Enviando…',
@@ -149,8 +150,23 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
'settings.notifyWebhook': 'Notificações webhook',
'settings.notificationsDisabled': 'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.',
'settings.notificationsActive': 'Canal ativo',
'settings.notificationsManagedByAdmin': 'Os eventos de notificação são configurados pelo administrador.',
'admin.notifications.title': 'Notificações',
'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
'admin.notifications.none': 'Desativado',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificação',
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
'admin.notifications.save': 'Salvar configurações de notificação',
'admin.notifications.saved': 'Configurações de notificação salvas',
'admin.notifications.testWebhook': 'Enviar webhook de teste',
'admin.notifications.testWebhookSuccess': 'Webhook de teste enviado com sucesso',
'admin.notifications.testWebhookFailed': 'Falha ao enviar webhook de teste',
'admin.smtp.title': 'E-mail e notificações',
'admin.smtp.hint': 'Configuração SMTP para notificações por e-mail. Opcional: URL webhook para Discord, Slack, etc.',
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
'admin.smtp.testButton': 'Enviar e-mail de teste',
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
'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.createAdmin': 'Criar conta de administrador',
'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.',
'login.setNewPassword': 'Definir nova senha',
'login.setNewPasswordHint': 'Você deve alterar sua senha antes de continuar.',
'login.createAccount': 'Criar conta',
'login.createAccountHint': 'Cadastre uma nova conta.',
'login.creating': 'Criando…',

View File

@@ -25,6 +25,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail',
'common.password': 'Heslo',
'common.saving': 'Ukládání...',
'common.saved': 'Uloženo',
'common.update': 'Aktualizovat',
'common.change': 'Změnit',
'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.notifyPackingTagged': 'Seznam balení: přiřazení',
'settings.notifyWebhook': 'Webhook oznámení',
'settings.notificationsDisabled': 'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.',
'settings.notificationsActive': 'Aktivní kanál',
'settings.notificationsManagedByAdmin': 'Události oznámení jsou konfigurovány administrátorem.',
'settings.on': 'Zapnuto',
'settings.off': 'Vypnuto',
'settings.mcp.title': 'Konfigurace MCP',
@@ -235,8 +239,20 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
'admin.notifications.title': 'Oznámení',
'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.',
'admin.notifications.none': 'Vypnuto',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Události oznámení',
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
'admin.notifications.save': 'Uložit nastavení oznámení',
'admin.notifications.saved': 'Nastavení oznámení uloženo',
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
'admin.notifications.testWebhookSuccess': 'Testovací webhook úspěšně odeslán',
'admin.notifications.testWebhookFailed': 'Odeslání testovacího webhooku se nezdařilo',
'admin.smtp.title': 'E-mail a oznámení',
'admin.smtp.hint': 'Konfigurace SMTP pro e-mailová oznámení. Volitelně: Webhook URL pro Discord, Slack apod.',
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
'admin.smtp.testButton': 'Odeslat testovací e-mail',
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
'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.createAdmin': 'Vytvořit účet administrátora',
'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.',
'login.setNewPassword': 'Nastavit nové heslo',
'login.setNewPasswordHint': 'Před pokračováním musíte změnit heslo.',
'login.createAccount': 'Vytvořit účet',
'login.createAccountHint': 'Zaregistrujte si nový účet.',
'login.creating': 'Vytváření…',

View File

@@ -25,6 +25,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-Mail',
'common.password': 'Passwort',
'common.saving': 'Speichern...',
'common.saved': 'Gespeichert',
'common.update': 'Aktualisieren',
'common.change': 'Ändern',
'common.uploading': 'Hochladen…',
@@ -149,8 +150,23 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.',
'settings.notificationsActive': 'Aktiver Kanal',
'settings.notificationsManagedByAdmin': 'Benachrichtigungsereignisse werden vom Administrator konfiguriert.',
'admin.notifications.title': 'Benachrichtigungen',
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
'admin.notifications.none': 'Deaktiviert',
'admin.notifications.email': 'E-Mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Benachrichtigungsereignisse',
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
'admin.notifications.testWebhook': 'Test-Webhook senden',
'admin.notifications.testWebhookSuccess': 'Test-Webhook erfolgreich gesendet',
'admin.notifications.testWebhookFailed': 'Test-Webhook fehlgeschlagen',
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
'admin.smtp.testButton': 'Test-E-Mail senden',
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
@@ -296,6 +312,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
'login.setNewPassword': 'Neues Passwort festlegen',
'login.setNewPasswordHint': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können.',
'login.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…',

View File

@@ -25,6 +25,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email',
'common.password': 'Password',
'common.saving': 'Saving...',
'common.saved': 'Saved',
'common.update': 'Update',
'common.change': 'Change',
'common.uploading': 'Uploading…',
@@ -149,11 +150,26 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments',
'settings.notifyWebhook': 'Webhook notifications',
'admin.notifications.title': 'Notifications',
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
'admin.notifications.none': 'Disabled',
'admin.notifications.email': 'Email (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Notification Events',
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
'admin.notifications.save': 'Save notification settings',
'admin.notifications.saved': 'Notification settings saved',
'admin.notifications.testWebhook': 'Send test webhook',
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
'admin.notifications.testWebhookFailed': 'Test webhook failed',
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
'admin.smtp.testSuccess': 'Test email sent successfully',
'admin.smtp.testFailed': 'Test email failed',
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
'settings.notificationsActive': 'Active channel',
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
'dayplan.icsTooltip': 'Export calendar (ICS)',
'share.linkTitle': 'Public Link',
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
@@ -227,6 +243,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordMismatch': 'Passwords do not match',
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
'settings.passwordChanged': 'Password changed successfully',
'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
'settings.deleteAccount': 'Delete account',
'settings.deleteAccountTitle': 'Delete your account?',
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
@@ -296,6 +313,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account',
'login.createAdminHint': 'Set up the first admin account for TREK.',
'login.setNewPassword': 'Set New Password',
'login.setNewPasswordHint': 'You must change your password before continuing.',
'login.createAccount': 'Create Account',
'login.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…',

View File

@@ -25,6 +25,7 @@ const es: Record<string, string> = {
'common.email': 'Correo',
'common.password': 'Contraseña',
'common.saving': 'Guardando...',
'common.saved': 'Guardado',
'common.update': 'Actualizar',
'common.change': 'Cambiar',
'common.uploading': 'Subiendo…',
@@ -150,8 +151,23 @@ const es: Record<string, string> = {
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
'settings.notifyWebhook': 'Notificaciones webhook',
'settings.notificationsDisabled': 'Las notificaciones no están configuradas. Pida a un administrador que active las notificaciones por correo o webhook.',
'settings.notificationsActive': 'Canal activo',
'settings.notificationsManagedByAdmin': 'Los eventos de notificación son configurados por el administrador.',
'admin.notifications.title': 'Notificaciones',
'admin.notifications.hint': 'Elija un canal de notificación. Solo uno puede estar activo a la vez.',
'admin.notifications.none': 'Desactivado',
'admin.notifications.email': 'Correo (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificación',
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
'admin.notifications.save': 'Guardar configuración de notificaciones',
'admin.notifications.saved': 'Configuración de notificaciones guardada',
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
'admin.notifications.testWebhookSuccess': 'Webhook de prueba enviado correctamente',
'admin.notifications.testWebhookFailed': 'Error al enviar webhook de prueba',
'admin.smtp.title': 'Correo y notificaciones',
'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.',
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
'admin.smtp.testButton': 'Enviar correo de prueba',
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
@@ -295,6 +311,8 @@ const es: Record<string, string> = {
'login.signIn': 'Entrar',
'login.createAdmin': 'Crear cuenta de administrador',
'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
'login.setNewPassword': 'Establecer nueva contraseña',
'login.setNewPasswordHint': 'Debe cambiar su contraseña antes de continuar.',
'login.createAccount': 'Crear cuenta',
'login.createAccountHint': 'Crea una cuenta nueva.',
'login.creating': 'Creando…',

View File

@@ -25,6 +25,7 @@ const fr: Record<string, string> = {
'common.email': 'E-mail',
'common.password': 'Mot de passe',
'common.saving': 'Enregistrement…',
'common.saved': 'Enregistré',
'common.update': 'Mettre à jour',
'common.change': 'Modifier',
'common.uploading': 'Import en cours…',
@@ -149,8 +150,23 @@ const fr: Record<string, string> = {
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
'settings.notifyWebhook': 'Notifications webhook',
'settings.notificationsDisabled': 'Les notifications ne sont pas configurées. Demandez à un administrateur d\'activer les notifications par e-mail ou webhook.',
'settings.notificationsActive': 'Canal actif',
'settings.notificationsManagedByAdmin': 'Les événements de notification sont configurés par votre administrateur.',
'admin.notifications.title': 'Notifications',
'admin.notifications.hint': 'Choisissez un canal de notification. Un seul peut être actif à la fois.',
'admin.notifications.none': 'Désactivé',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Événements de notification',
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
'admin.notifications.save': 'Enregistrer les paramètres de notification',
'admin.notifications.saved': 'Paramètres de notification enregistrés',
'admin.notifications.testWebhook': 'Envoyer un webhook de test',
'admin.notifications.testWebhookSuccess': 'Webhook de test envoyé avec succès',
'admin.notifications.testWebhookFailed': 'Échec du webhook de test',
'admin.smtp.title': 'E-mail et notifications',
'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.',
'admin.smtp.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
'admin.smtp.testButton': 'Envoyer un e-mail de test',
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
@@ -296,6 +312,8 @@ const fr: Record<string, string> = {
'login.signIn': 'Se connecter',
'login.createAdmin': 'Créer un compte administrateur',
'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.',
'login.setNewPassword': 'Définir un nouveau mot de passe',
'login.setNewPasswordHint': 'Vous devez changer votre mot de passe avant de continuer.',
'login.createAccount': 'Créer un compte',
'login.createAccountHint': 'Créez un nouveau compte.',
'login.creating': 'Création…',

View File

@@ -25,6 +25,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail',
'common.password': 'Jelszó',
'common.saving': 'Mentés...',
'common.saved': 'Mentve',
'common.update': 'Frissítés',
'common.change': 'Módosítá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.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
'settings.notifyWebhook': 'Webhook értesítések',
'settings.notificationsDisabled': 'Az értesítések nincsenek beállítva. Kérje meg a rendszergazdát, hogy engedélyezze az e-mail vagy webhook értesítéseket.',
'settings.notificationsActive': 'Aktív csatorna',
'settings.notificationsManagedByAdmin': 'Az értesítési eseményeket az adminisztrátor konfigurálja.',
'settings.on': 'Be',
'settings.off': 'Ki',
'settings.mcp.title': 'MCP konfiguráció',
@@ -234,8 +238,20 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
'settings.mfa.demoBlocked': 'Demo módban nem érhető el',
'admin.notifications.title': 'Értesítések',
'admin.notifications.hint': 'Válasszon értesítési csatornát. Egyszerre csak egy lehet aktív.',
'admin.notifications.none': 'Kikapcsolva',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Értesítési események',
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
'admin.notifications.save': 'Értesítési beállítások mentése',
'admin.notifications.saved': 'Értesítési beállítások mentve',
'admin.notifications.testWebhook': 'Teszt webhook küldése',
'admin.notifications.testWebhookSuccess': 'Teszt webhook sikeresen elküldve',
'admin.notifications.testWebhookFailed': 'Teszt webhook küldése sikertelen',
'admin.smtp.title': 'E-mail és értesítések',
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítésekhez. Opcionális: Webhook URL Discordhoz, Slackhez stb.',
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
'admin.smtp.testButton': 'Teszt e-mail küldése',
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
'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.createAdmin': 'Admin fiók létrehozása',
'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.',
'login.setNewPassword': 'Új jelszó beállítása',
'login.setNewPasswordHint': 'A folytatás előtt meg kell változtatnia a jelszavát.',
'login.createAccount': 'Fiók létrehozása',
'login.createAccountHint': 'Új fiók regisztrálása.',
'login.creating': 'Létrehozás…',

View File

@@ -25,6 +25,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email',
'common.password': 'Password',
'common.saving': 'Salvataggio...',
'common.saved': 'Salvato',
'common.update': 'Aggiorna',
'common.change': 'Cambia',
'common.uploading': 'Caricamento…',
@@ -149,6 +150,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
'settings.notifyWebhook': 'Notifiche webhook',
'settings.notificationsDisabled': 'Le notifiche non sono configurate. Chiedi a un amministratore di abilitare le notifiche e-mail o webhook.',
'settings.notificationsActive': 'Canale attivo',
'settings.notificationsManagedByAdmin': 'Gli eventi di notifica sono configurati dall\'amministratore.',
'settings.on': 'On',
'settings.off': 'Off',
'settings.mcp.title': 'Configurazione MCP',
@@ -234,8 +238,20 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
'admin.notifications.title': 'Notifiche',
'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.',
'admin.notifications.none': 'Disattivato',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventi di notifica',
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
'admin.notifications.save': 'Salva impostazioni notifiche',
'admin.notifications.saved': 'Impostazioni notifiche salvate',
'admin.notifications.testWebhook': 'Invia webhook di test',
'admin.notifications.testWebhookSuccess': 'Webhook di test inviato con successo',
'admin.notifications.testWebhookFailed': 'Invio webhook di test fallito',
'admin.smtp.title': 'Email e notifiche',
'admin.smtp.hint': 'Configurazione SMTP per le notifiche via email. Opzionale: URL webhook per Discord, Slack, ecc.',
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
'admin.smtp.testButton': 'Invia email di prova',
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
'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.createAdmin': 'Crea Account Amministratore',
'login.createAdminHint': 'Imposta il primo account amministratore per TREK.',
'login.setNewPassword': 'Imposta nuova password',
'login.setNewPasswordHint': 'Devi cambiare la password prima di continuare.',
'login.createAccount': 'Crea Account',
'login.createAccountHint': 'Registra un nuovo account.',
'login.creating': 'Creazione in corso…',

View File

@@ -25,6 +25,7 @@ const nl: Record<string, string> = {
'common.email': 'E-mail',
'common.password': 'Wachtwoord',
'common.saving': 'Opslaan...',
'common.saved': 'Opgeslagen',
'common.update': 'Bijwerken',
'common.change': 'Wijzigen',
'common.uploading': 'Uploaden…',
@@ -149,8 +150,23 @@ const nl: Record<string, string> = {
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
'settings.notifyWebhook': 'Webhook-meldingen',
'settings.notificationsDisabled': 'Meldingen zijn niet geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te schakelen.',
'settings.notificationsActive': 'Actief kanaal',
'settings.notificationsManagedByAdmin': 'Meldingsgebeurtenissen worden geconfigureerd door je beheerder.',
'admin.notifications.title': 'Meldingen',
'admin.notifications.hint': 'Kies een meldingskanaal. Er kan er slechts één tegelijk actief zijn.',
'admin.notifications.none': 'Uitgeschakeld',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Meldingsgebeurtenissen',
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
'admin.notifications.save': 'Meldingsinstellingen opslaan',
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
'admin.notifications.testWebhook': 'Testwebhook verzenden',
'admin.notifications.testWebhookSuccess': 'Testwebhook succesvol verzonden',
'admin.notifications.testWebhookFailed': 'Testwebhook mislukt',
'admin.smtp.title': 'E-mail en meldingen',
'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.',
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
'admin.smtp.testButton': 'Test-e-mail verzenden',
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
'admin.smtp.testFailed': 'Test-e-mail mislukt',
@@ -296,6 +312,8 @@ const nl: Record<string, string> = {
'login.signIn': 'Inloggen',
'login.createAdmin': 'Beheerdersaccount aanmaken',
'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.',
'login.setNewPassword': 'Nieuw wachtwoord instellen',
'login.setNewPasswordHint': 'U moet uw wachtwoord wijzigen voordat u verder kunt gaan.',
'login.createAccount': 'Account aanmaken',
'login.createAccountHint': 'Registreer een nieuw account.',
'login.creating': 'Aanmaken…',

View File

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

View File

@@ -25,6 +25,7 @@ const zh: Record<string, string> = {
'common.email': '邮箱',
'common.password': '密码',
'common.saving': '保存中...',
'common.saved': '已保存',
'common.update': '更新',
'common.change': '修改',
'common.uploading': '上传中…',
@@ -149,8 +150,23 @@ const zh: Record<string, string> = {
'settings.notifyCollabMessage': '聊天消息 (Collab)',
'settings.notifyPackingTagged': '行李清单:分配',
'settings.notifyWebhook': 'Webhook 通知',
'settings.notificationsDisabled': '通知尚未配置。请联系管理员启用电子邮件或 Webhook 通知。',
'settings.notificationsActive': '活跃频道',
'settings.notificationsManagedByAdmin': '通知事件由管理员配置。',
'admin.notifications.title': '通知',
'admin.notifications.hint': '选择一个通知渠道。一次只能激活一个。',
'admin.notifications.none': '已禁用',
'admin.notifications.email': '电子邮件 (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': '通知事件',
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
'admin.notifications.save': '保存通知设置',
'admin.notifications.saved': '通知设置已保存',
'admin.notifications.testWebhook': '发送测试 Webhook',
'admin.notifications.testWebhookSuccess': '测试 Webhook 发送成功',
'admin.notifications.testWebhookFailed': '测试 Webhook 发送失败',
'admin.smtp.title': '邮件与通知',
'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选Discord、Slack 等的 Webhook URL。',
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
'admin.smtp.testButton': '发送测试邮件',
'admin.smtp.testSuccess': '测试邮件发送成功',
'admin.smtp.testFailed': '测试邮件发送失败',
@@ -296,6 +312,8 @@ const zh: Record<string, string> = {
'login.signIn': '登录',
'login.createAdmin': '创建管理员账户',
'login.createAdminHint': '为 TREK 设置第一个管理员账户。',
'login.setNewPassword': '设置新密码',
'login.setNewPasswordHint': '您必须更改密码才能继续。',
'login.createAccount': '创建账户',
'login.createAccountHint': '注册新账户。',
'login.creating': '创建中…',

View File

@@ -974,64 +974,172 @@ export default function AdminPage(): React.ReactElement {
</button>
</div>
</div>
{/* SMTP / Notifications */}
{/* Notifications — exclusive channel selector */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
</div>
<div className="p-6 space-y-3">
{smtpLoaded && [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
].map(field => (
<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}
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"
/>
</div>
))}
{/* Skip TLS toggle */}
<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={async () => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
await authApi.updateAppSettings({ smtp_skip_tls_verify: newVal }).catch(() => {})
}}
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 className="p-6 space-y-4">
{/* Channel selector */}
<div className="flex gap-2">
{(['none', 'email', 'webhook'] as const).map(ch => {
const active = (smtpValues.notification_channel || 'none') === ch
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
return (
<button
key={ch}
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
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'}`}
>
{labels[ch]}
</button>
)
})}
</div>
{/* Notification event toggles — shown when any channel is active */}
{(smtpValues.notification_channel || 'none') !== 'none' && (
<div className="space-y-2 pt-2 border-t border-slate-100">
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
{[
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
{ key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
].map(opt => {
const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
return (
<div key={opt.key} className="flex items-center justify-between py-1">
<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>
<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>
@@ -1039,7 +1147,7 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel />}
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}

View File

@@ -9,6 +9,7 @@ import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe,
interface AppConfig {
has_users: boolean
allow_registration: boolean
setup_complete: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
@@ -28,7 +29,7 @@ export default function LoginPage(): React.ReactElement {
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
@@ -110,19 +111,39 @@ export default function LoginPage(): React.ReactElement {
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = useState('')
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
const [savedLoginPassword, setSavedLoginPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
if (passwordChangeStep) {
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
await loadUser({ silent: true })
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
return
}
if (mode === 'login' && mfaStep) {
if (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired'))
setIsLoading(false)
return
}
await completeMfaLogin(mfaToken, mfaCode)
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
return
@@ -140,6 +161,12 @@ export default function LoginPage(): React.ReactElement {
setIsLoading(false)
return
}
if ('user' in result && result.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
@@ -149,7 +176,7 @@ export default function LoginPage(): React.ReactElement {
}
}
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
@@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement {
) : (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
{passwordChangeStep
? t('login.setNewPassword')
: mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
</h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
{passwordChangeStep
? t('login.setNewPasswordHint')
: mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -537,7 +568,39 @@ export default function LoginPage(): React.ReactElement {
</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>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
<div style={{ position: 'relative' }}>
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Username (register only) */}
{mode === 'register' && (
{mode === 'register' && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
<div style={{ position: 'relative' }}>
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Email */}
{!(mode === 'login' && mfaStep) && (
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}>
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Password */}
{!(mode === 'login' && mfaStep) && (
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}>
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{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'))}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
? <><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} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
}
</button>
</form>
{/* 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' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<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 { 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 { authApi, adminApi, notificationsApi } from '../api/client'
import { authApi, adminApi } from '../api/client'
import apiClient from '../api/client'
import { useAddonStore } from '../store/addonStore'
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 }) {
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
const [addons, setAddons] = useState<Record<string, boolean>>({})
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button onClick={onToggle}
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(() => {
apiClient.get('/addons').then(r => {
const map: Record<string, boolean> = {}
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
setAddons(map)
authApi.getAppConfig?.().then((cfg: any) => {
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
}).catch(() => {})
}, [])
const toggle = async (key: string) => {
if (!prefs) return
const newVal = prefs[key] ? 0 : 1
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
if (notifChannel === 'none') {
return (
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{t('settings.notificationsDisabled')}
</p>
)
}
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
const options = [
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
]
const channelLabel = notifChannel === 'email'
? (t('admin.notifications.email') || 'Email (SMTP)')
: (t('admin.notifications.webhook') || 'Webhook')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{options.map(opt => (
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
<button onClick={() => toggle(opt.key)}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
{t('settings.notificationsActive')}: {channelLabel}
</span>
</div>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
{t('settings.notificationsManagedByAdmin')}
</p>
</div>
)
}
@@ -924,6 +922,7 @@ export default function SettingsPage(): React.ReactElement {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}

View File

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