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

@@ -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')))
}