Merge branch 'dev' into test
This commit is contained in:
29
.gitattributes
vendored
Normal file
29
.gitattributes
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Normalize line endings to LF on commit
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly enforce LF for source files
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -57,4 +57,5 @@ coverage
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
.scannerwork
|
||||
test-data
|
||||
@@ -43,7 +43,8 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -27,7 +27,8 @@ apiClient.interceptors.response.use(
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
window.location.href = '/login'
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -197,6 +198,8 @@ export const adminApi = {
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -326,9 +329,9 @@ export const shareApi = {
|
||||
|
||||
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),
|
||||
updatePreferences: (prefs: Record<string, 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),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
|
||||
@@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
@@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const send = async (label: string, payload: Record<string, unknown>) => {
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
@@ -49,74 +53,69 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: 'Simple → Me',
|
||||
icon: Bell,
|
||||
color: '#6366f1',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Boolean → Me',
|
||||
icon: CheckCircle,
|
||||
color: '#10b981',
|
||||
payload: {
|
||||
type: 'boolean',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Navigate → Me',
|
||||
icon: Navigation,
|
||||
color: '#f59e0b',
|
||||
payload: {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Simple → Admins',
|
||||
icon: Zap,
|
||||
color: '#ef4444',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
title_key: 'notifications.test.adminTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.adminText',
|
||||
text_params: { actor: user?.username || 'Admin' },
|
||||
},
|
||||
},
|
||||
]
|
||||
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
const username = user?.username || 'Admin'
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
DEV ONLY
|
||||
</div>
|
||||
@@ -125,219 +124,162 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
|
||||
</p>
|
||||
|
||||
{/* Quick-fire buttons */}
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{buttons.map(btn => {
|
||||
const Icon = btn.icon
|
||||
return (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={() => send(btn.label, btn.payload)}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${btn.color}20`, color: btn.color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
|
||||
{btn.payload.type} · {btn.payload.scope}
|
||||
</p>
|
||||
</div>
|
||||
{sending === btn.label && (
|
||||
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip-scoped notifications */}
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={trip.id}>{trip.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Simple → Trip', {
|
||||
type: 'simple',
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
|
||||
<Send className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Navigate → Trip', {
|
||||
type: 'navigate',
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: `/trips/${selectedTripId}`,
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-scoped notifications */}
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'simple',
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#06b6d420', color: '#06b6d4' }}>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'boolean',
|
||||
/>
|
||||
<Btn
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#10b98120', color: '#10b981' }}>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
30
client/src/components/Settings/AboutTab.tsx
Normal file
30
client/src/components/Settings/AboutTab.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
|
||||
interface Props {
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
598
client/src/components/Settings/AccountTab.tsx
Normal file
598
client/src/components/Settings/AccountTab.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi, adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { UserWithOidc } from '../../types'
|
||||
import Section from './Section'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
export default function AccountTab(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(config => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode &&
|
||||
!user?.mfa_enabled &&
|
||||
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
if (!backupCodesText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
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>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
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')))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
|
||||
{!user?.mfa_enabled && !mfaQr && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = await authApi.mfaSetup() as { qr_data_url: string; secret: string }
|
||||
setMfaQr(data.qr_data_url)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
|
||||
<img src={mfaQr} alt="" className="rounded-lg border mx-auto block" style={{ maxWidth: 200, borderColor: 'var(--border-primary)' }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
|
||||
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaSetupCode}
|
||||
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={e => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
|
||||
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
|
||||
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
style={{
|
||||
position: 'absolute', bottom: -3, right: -3,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: '#ef4444', color: 'white',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
|
||||
}}>
|
||||
SSO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||
style={{ border: '1px solid #fecaca' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirm */}
|
||||
{showDeleteConfirm === true && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
border: 'none', background: '#ef4444', color: 'white',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
206
client/src/components/Settings/DisplaySettingsTab.tsx
Normal file
206
client/src/components/Settings/DisplaySettingsTab.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from './Section'
|
||||
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
}, [settings.temperature_unit])
|
||||
|
||||
return (
|
||||
<Section title={t('settings.display')} icon={Palette}>
|
||||
{/* Color Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'light', label: t('settings.light'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.dark'), icon: Moon },
|
||||
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
|
||||
].map(opt => {
|
||||
const current = settings.dark_mode
|
||||
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', justifyContent: 'center', minWidth: 0,
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Format */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
343
client/src/components/Settings/IntegrationsTab.tsx
Normal file
343
client/src/components/Settings/IntegrationsTab.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Camera, Terminal, Save, Check, Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi } from '../../api/client'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Section from './Section'
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled } = useAddonStore()
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
|
||||
// Immich state
|
||||
const [immichUrl, setImmichUrl] = useState('')
|
||||
const [immichApiKey, setImmichApiKey] = useState('')
|
||||
const [immichConnected, setImmichConnected] = useState(false)
|
||||
const [immichTesting, setImmichTesting] = useState(false)
|
||||
const [immichSaving, setImmichSaving] = useState(false)
|
||||
const [immichTestPassed, setImmichTestPassed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (memoriesEnabled) {
|
||||
apiClient.get('/integrations/immich/settings').then(r => {
|
||||
setImmichUrl(r.data.immich_url || '')
|
||||
setImmichConnected(r.data.connected)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [memoriesEnabled])
|
||||
|
||||
const handleSaveImmich = async () => {
|
||||
setImmichSaving(true)
|
||||
try {
|
||||
const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
if (saveRes.data.warning) toast.warning(saveRes.data.warning)
|
||||
toast.success(t('memories.saved'))
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
setImmichConnected(res.data.connected)
|
||||
setImmichTestPassed(false)
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setImmichSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestImmich = async () => {
|
||||
setImmichTesting(true)
|
||||
try {
|
||||
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
|
||||
if (res.data.connected) {
|
||||
if (res.data.canonicalUrl) {
|
||||
setImmichUrl(res.data.canonicalUrl)
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''} (URL updated to ${res.data.canonicalUrl})`)
|
||||
} else {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
}
|
||||
setImmichTestPassed(true)
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||
setImmichTestPassed(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setImmichTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [mcpNewName, setMcpNewName] = useState('')
|
||||
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfig = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${mcpEndpoint}",
|
||||
"--header",
|
||||
"Authorization: Bearer <your_token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateMcpToken = async () => {
|
||||
if (!mcpNewName.trim()) return
|
||||
setMcpCreating(true)
|
||||
try {
|
||||
const d = await authApi.mcpTokens.create(mcpNewName.trim())
|
||||
setMcpCreatedToken(d.token.raw_token)
|
||||
setMcpNewName('')
|
||||
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.createError'))
|
||||
} finally {
|
||||
setMcpCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMcpToken = async (id: number) => {
|
||||
try {
|
||||
await authApi.mcpTokens.delete(id)
|
||||
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setMcpDeleteId(null)
|
||||
toast.success(t('settings.mcp.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{memoriesEnabled && (
|
||||
<Section title="Immich" icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||
<input type="url" value={immichUrl} onChange={e => { setImmichUrl(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder="https://immich.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||
<input type="password" value={immichApiKey} onChange={e => { setImmichApiKey(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleSaveImmich} disabled={immichSaving || !immichTestPassed}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!immichTestPassed ? t('memories.testFirst') : ''}>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
|
||||
{immichTesting
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{immichConnected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{mcpEnabled && (
|
||||
<Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
{/* Endpoint URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpEndpoint}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Create MCP Token modal */}
|
||||
{mcpModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
{!mcpCreatedToken ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setMcpModalOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpCreatedToken}
|
||||
</pre>
|
||||
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
|
||||
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP Token confirm */}
|
||||
{mcpDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setMcpDeleteId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.mcp.deleteTokenTitle')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
162
client/src/components/Settings/MapSettingsTab.tsx
Normal file
162
client/src/components/Settings/MapSettingsTab.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Map, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import Section from './Section'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
}, [settings])
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
|
||||
const mapPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Default map center',
|
||||
description: '',
|
||||
lat: defaultLat as number,
|
||||
lng: defaultLng as number,
|
||||
address: '',
|
||||
category_id: 0,
|
||||
icon: null,
|
||||
price: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: Date(),
|
||||
}], [defaultLat, defaultLng])
|
||||
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
default_lng: parseFloat(String(defaultLng)),
|
||||
default_zoom: parseInt(String(defaultZoom)),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
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-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
|
||||
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>
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: handleMapClick,
|
||||
onMapContextMenu: null,
|
||||
center: [settings.default_lat, settings.default_lng],
|
||||
zoom: defaultZoom,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
196
client/src/components/Settings/NotificationsTab.tsx
Normal file
196
client/src/components/Settings/NotificationsTab.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import Section from './Section'
|
||||
|
||||
interface PreferencesMatrix {
|
||||
preferences: Record<string, Record<string, boolean>>
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
trip_invite: 'settings.notifyTripInvite',
|
||||
booking_change: 'settings.notifyBookingChange',
|
||||
trip_reminder: 'settings.notifyTripReminder',
|
||||
vacay_invite: 'settings.notifyVacayInvite',
|
||||
photos_shared: 'settings.notifyPhotosShared',
|
||||
collab_message: 'settings.notifyCollabMessage',
|
||||
packing_tagged: 'settings.notifyPackingTagged',
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
export default function NotificationsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||
const [webhookTesting, setWebhookTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
||||
const val = (data.settings?.webhook_url as string) || ''
|
||||
if (val === '••••••••') {
|
||||
setWebhookIsSet(true)
|
||||
setWebhookUrl('')
|
||||
} else {
|
||||
setWebhookUrl(val)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const visibleChannels = matrix
|
||||
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
: []
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
if (!matrix) return
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = {
|
||||
...matrix.preferences,
|
||||
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
|
||||
}
|
||||
setMatrix(m => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} catch {
|
||||
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveWebhookUrl = async () => {
|
||||
setWebhookSaving(true)
|
||||
try {
|
||||
await settingsApi.set('webhook_url', webhookUrl)
|
||||
if (webhookUrl) setWebhookIsSet(true)
|
||||
else setWebhookIsSet(false)
|
||||
toast.success(t('settings.webhookUrl.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setWebhookSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testWebhookUrl = async () => {
|
||||
if (!webhookUrl && !webhookIsSet) return
|
||||
setWebhookTesting(true)
|
||||
try {
|
||||
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
|
||||
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
||||
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
||||
} catch {
|
||||
toast.error(t('settings.webhookUrl.testFailed'))
|
||||
} finally {
|
||||
setWebhookTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading…</p>
|
||||
|
||||
if (visibleChannels.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{t('settings.notificationPreferences.noChannels')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{matrix.available_channels.webhook && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.webhookUrl.label')}
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={webhookUrl}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={saveWebhookUrl}
|
||||
disabled={webhookSaving}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={testWebhookUrl}
|
||||
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map(eventType => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
|
||||
if (relevantChannels.length === 0) return null
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.notifications')} icon={Lock}>
|
||||
{renderContent()}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
22
client/src/components/Settings/Section.tsx
Normal file
22
client/src/components/Settings/Section.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', marginBottom: 24 }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
client/src/components/Settings/ToggleSwitch.tsx
Normal file
18
client/src/components/Settings/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Settings',
|
||||
'settings.subtitle': 'Configure your personal settings',
|
||||
'settings.tabs.display': 'Display',
|
||||
'settings.tabs.map': 'Map',
|
||||
'settings.tabs.notifications': 'Notifications',
|
||||
'settings.tabs.integrations': 'Integrations',
|
||||
'settings.tabs.account': 'Account',
|
||||
'settings.tabs.about': 'About',
|
||||
'settings.map': 'Map',
|
||||
'settings.mapTemplate': 'Map Template',
|
||||
'settings.mapTemplatePlaceholder.select': 'Select template...',
|
||||
@@ -163,23 +169,44 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||
'settings.notifyWebhook': 'Webhook notifications',
|
||||
'settings.notifyVersionAvailable': 'New version available',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.',
|
||||
'settings.webhookUrl.save': 'Save',
|
||||
'settings.webhookUrl.saved': 'Webhook URL saved',
|
||||
'settings.webhookUrl.test': 'Test',
|
||||
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
|
||||
'settings.webhookUrl.testFailed': 'Test webhook failed',
|
||||
'admin.notifications.title': 'Notifications',
|
||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||
'admin.notifications.none': 'Disabled',
|
||||
'admin.notifications.email': 'Email (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Notification Events',
|
||||
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
||||
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
||||
'admin.notifications.save': 'Save notification settings',
|
||||
'admin.notifications.saved': 'Notification settings saved',
|
||||
'admin.notifications.testWebhook': 'Send test webhook',
|
||||
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'This webhook is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user webhooks and always fires when set.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL saved',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
||||
'admin.webhook.hint': 'Allow users to configure their own webhook URLs for notifications (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||
'admin.smtp.testFailed': 'Test email failed',
|
||||
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||
@@ -383,6 +410,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Users',
|
||||
'admin.tabs.categories': 'Categories',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'admin.tabs.notificationChannels': 'Notification Channels',
|
||||
'admin.tabs.adminNotifications': 'Admin Notifications',
|
||||
'admin.tabs.audit': 'Audit log',
|
||||
'admin.stats.users': 'Users',
|
||||
'admin.stats.trips': 'Trips',
|
||||
@@ -685,8 +715,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Remove',
|
||||
'atlas.confirmMark': 'Mark this country as visited?',
|
||||
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
|
||||
'atlas.markVisited': 'Mark as visited',
|
||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||
'atlas.markRegionVisitedHint': 'Add this region to your visited list',
|
||||
'atlas.addToBucket': 'Add to bucket list',
|
||||
'atlas.addPoi': 'Add place',
|
||||
'atlas.searchCountry': 'Search a country...',
|
||||
@@ -1565,6 +1597,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.system': 'System',
|
||||
|
||||
// Notification test keys (dev only)
|
||||
'notifications.versionAvailable.title': 'Update Available',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is now available.',
|
||||
'notifications.versionAvailable.button': 'View Details',
|
||||
'notifications.test.title': 'Test notification from {actor}',
|
||||
'notifications.test.text': 'This is a simple test notification.',
|
||||
'notifications.test.booleanTitle': '{actor} asks for your approval',
|
||||
@@ -1612,6 +1647,43 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.detail.priority': 'Priority',
|
||||
'todo.detail.noPriority': 'None',
|
||||
'todo.detail.create': 'Create task',
|
||||
|
||||
// Notifications — dev test events
|
||||
'notif.test.title': '[Test] Notification',
|
||||
'notif.test.simple.text': 'This is a simple test notification.',
|
||||
'notif.test.boolean.text': 'Do you accept this test notification?',
|
||||
'notif.test.navigate.text': 'Click below to navigate to the dashboard.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Trip Invitation',
|
||||
'notif.trip_invite.text': '{actor} invited you to {trip}',
|
||||
'notif.booking_change.title': 'Booking Updated',
|
||||
'notif.booking_change.text': '{actor} updated a booking in {trip}',
|
||||
'notif.trip_reminder.title': 'Trip Reminder',
|
||||
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion Invite',
|
||||
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
|
||||
'notif.photos_shared.title': 'Photos Shared',
|
||||
'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}',
|
||||
'notif.collab_message.title': 'New Message',
|
||||
'notif.collab_message.text': '{actor} sent a message in {trip}',
|
||||
'notif.packing_tagged.title': 'Packing Assignment',
|
||||
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
|
||||
'notif.version_available.title': 'New Version Available',
|
||||
'notif.version_available.text': 'TREK {version} is now available',
|
||||
'notif.action.view_trip': 'View Trip',
|
||||
'notif.action.view_collab': 'View Messages',
|
||||
'notif.action.view_packing': 'View Packing',
|
||||
'notif.action.view_photos': 'View Photos',
|
||||
'notif.action.view_vacay': 'View Vacay',
|
||||
'notif.action.view_admin': 'Go to Admin',
|
||||
'notif.action.view': 'View',
|
||||
'notif.action.accept': 'Accept',
|
||||
'notif.action.decline': 'Decline',
|
||||
'notif.generic.title': 'Notification',
|
||||
'notif.generic.text': 'You have a new notification',
|
||||
'notif.dev.unknown_event.title': '[DEV] Unknown Event',
|
||||
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Ustawienia',
|
||||
'settings.subtitle': 'Skonfiguruj swoje ustawienia',
|
||||
'settings.tabs.display': 'Wygląd',
|
||||
'settings.tabs.map': 'Mapa',
|
||||
'settings.tabs.notifications': 'Powiadomienia',
|
||||
'settings.tabs.integrations': 'Integracje',
|
||||
'settings.tabs.account': 'Konto',
|
||||
'settings.tabs.about': 'O aplikacji',
|
||||
'settings.map': 'Mapa',
|
||||
'settings.mapTemplate': 'Szablon mapy',
|
||||
'settings.mapTemplatePlaceholder.select': 'Wybierz szablon...',
|
||||
@@ -149,6 +155,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
|
||||
'settings.notifyPackingTagged': 'Lista pakowania: przypisania',
|
||||
'settings.notifyWebhook': 'Powiadomienia Webhook',
|
||||
'settings.notifyVersionAvailable': 'Nowa wersja dostępna',
|
||||
'admin.smtp.title': 'E-maile i powiadomienia',
|
||||
'admin.smtp.hint': 'Konfiguracja SMTP dla powiadomień e-mail. Opcjonalnie: URL Webhooka dla Discorda, Slacka, itp.',
|
||||
'admin.smtp.testButton': 'Wyślij testowego e-maila',
|
||||
@@ -349,6 +356,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Użytkownicy',
|
||||
'admin.tabs.categories': 'Kategorie',
|
||||
'admin.tabs.backup': 'Backupy',
|
||||
'admin.tabs.notifications': 'Powiadomienia',
|
||||
'admin.tabs.notificationChannels': 'Kanały powiadomień',
|
||||
'admin.tabs.adminNotifications': 'Powiadomienia admina',
|
||||
'admin.tabs.audit': 'Aktywność',
|
||||
'admin.stats.users': 'Użytkownicy',
|
||||
'admin.stats.trips': 'Podróże',
|
||||
@@ -455,8 +465,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Dodatki',
|
||||
'admin.addons.title': 'Dodatki',
|
||||
'admin.addons.subtitle': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w TREK.',
|
||||
'admin.addons.catalog.packing.name': 'Lists',
|
||||
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
|
||||
'admin.addons.catalog.packing.name': 'Listy',
|
||||
'admin.addons.catalog.packing.description': 'Listy pakowania i zadania do wykonania dla Twoich podróży',
|
||||
'admin.addons.catalog.budget.name': 'Budżet',
|
||||
'admin.addons.catalog.budget.description': 'Śledź wydatki i planuj budżet podróży',
|
||||
'admin.addons.catalog.documents.name': 'Dokumenty',
|
||||
@@ -472,7 +482,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol dla integracji asystenta AI',
|
||||
'admin.addons.subtitleBefore': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w ',
|
||||
'admin.addons.subtitleAfter': '',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
'admin.addons.enabled': 'Włączone',
|
||||
'admin.addons.disabled': 'Wyłączone',
|
||||
'admin.addons.type.trip': 'Podróż',
|
||||
@@ -651,8 +661,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Usuń',
|
||||
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
||||
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
||||
'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?',
|
||||
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
||||
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
||||
'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych',
|
||||
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
||||
'atlas.addPoi': 'Dodaj miejsce',
|
||||
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
||||
@@ -701,8 +713,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'Rezerwacje',
|
||||
'trip.tabs.packing': 'Lista pakowania',
|
||||
'trip.tabs.packingShort': 'Pakowanie',
|
||||
'trip.tabs.lists': 'Lists',
|
||||
'trip.tabs.listsShort': 'Lists',
|
||||
'trip.tabs.lists': 'Listy',
|
||||
'trip.tabs.listsShort': 'Listy',
|
||||
'trip.tabs.budget': 'Budżet',
|
||||
'trip.tabs.files': 'Pliki',
|
||||
'trip.loading': 'Ładowanie podróży...',
|
||||
@@ -890,11 +902,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'Przypisz do miejsca',
|
||||
'reservations.pickAssignment': 'Wybierz miejsce z planu...',
|
||||
'reservations.noAssignment': 'Brak przypisania (samodzielna)',
|
||||
'reservations.price': 'Price',
|
||||
'reservations.budgetCategory': 'Budget category',
|
||||
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
|
||||
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
|
||||
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
|
||||
'reservations.price': 'Cena',
|
||||
'reservations.budgetCategory': 'Kategoria budżetu',
|
||||
'reservations.budgetCategoryPlaceholder': 'np. Transport, Zakwaterowanie',
|
||||
'reservations.budgetCategoryAuto': 'Auto (na podstawie typu rezerwacji)',
|
||||
'reservations.budgetHint': 'Wpis budżetowy zostanie automatycznie utworzony podczas zapisywania.',
|
||||
'reservations.departureDate': 'Wylot',
|
||||
'reservations.arrivalDate': 'Przylot',
|
||||
'reservations.departureTime': 'Godz. wylotu',
|
||||
@@ -1443,8 +1455,31 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.testWebhook': 'Wyślij testowy webhook',
|
||||
'admin.notifications.testWebhookSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||
'admin.notifications.testWebhookFailed': 'Testowy webhook nie powiódł się',
|
||||
'admin.webhook.hint': 'Wysyłaj powiadomienia do zewnętrznego webhooka.',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'Powiadomienia w aplikacji są zawsze aktywne i nie można ich globalnie wyłączyć.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook admina',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Ten webhook służy wyłącznie do powiadomień admina (np. alertów o nowych wersjach). Jest niezależny od webhooków użytkowników i wysyła automatycznie, gdy URL jest skonfigurowany.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'URL webhooka admina zapisany',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
|
||||
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
||||
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
||||
'settings.webhookUrl.label': 'URL webhooka',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.',
|
||||
'settings.webhookUrl.save': 'Zapisz',
|
||||
'settings.webhookUrl.saved': 'URL webhooka zapisany',
|
||||
'settings.webhookUrl.test': 'Test',
|
||||
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationsActive': 'Aktywny kanał',
|
||||
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
|
||||
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
||||
@@ -1541,13 +1576,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.deleteAll': 'Usuń wszystkie',
|
||||
'notifications.showAll': 'Pokaż wszystkie',
|
||||
'notifications.empty': 'Brak powiadomień',
|
||||
'notifications.emptyDescription': "You're all caught up!",
|
||||
'notifications.emptyDescription': 'Jesteś na bieżąco!',
|
||||
'notifications.all': 'Wszystkie',
|
||||
'notifications.unreadOnly': 'Nieprzeczytane',
|
||||
'notifications.markRead': 'Oznacz jako przeczytane',
|
||||
'notifications.markUnread': 'Oznacz jako nieprzeczytane',
|
||||
'notifications.delete': 'Usuń',
|
||||
'notifications.system': 'System',
|
||||
'notifications.versionAvailable.title': 'Dostępna aktualizacja',
|
||||
'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.',
|
||||
'notifications.versionAvailable.button': 'Zobacz szczegóły',
|
||||
'notifications.test.title': 'Testowe powiadomienie od {actor}',
|
||||
'notifications.test.text': 'To jest powiadomienie testowe.',
|
||||
'notifications.test.booleanTitle': '{actor} prosi o akceptację',
|
||||
@@ -1563,38 +1601,75 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.test.tripText': 'Testowe powiadomienie dla podróży "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Packing List',
|
||||
'todo.subtab.todo': 'To-Do',
|
||||
'todo.completed': 'completed',
|
||||
'todo.filter.all': 'All',
|
||||
'todo.filter.open': 'Open',
|
||||
'todo.filter.done': 'Done',
|
||||
'todo.uncategorized': 'Uncategorized',
|
||||
'todo.namePlaceholder': 'Task name',
|
||||
'todo.descriptionPlaceholder': 'Description (optional)',
|
||||
'todo.unassigned': 'Unassigned',
|
||||
'todo.noCategory': 'No category',
|
||||
'todo.hasDescription': 'Has description',
|
||||
'todo.addItem': 'Add new task...',
|
||||
'todo.newCategory': 'Category name',
|
||||
'todo.addCategory': 'Add category',
|
||||
'todo.newItem': 'New task',
|
||||
'todo.empty': 'No tasks yet. Add a task to get started!',
|
||||
'todo.filter.my': 'My Tasks',
|
||||
'todo.filter.overdue': 'Overdue',
|
||||
'todo.sidebar.tasks': 'Tasks',
|
||||
'todo.sidebar.categories': 'Categories',
|
||||
'todo.detail.title': 'Task',
|
||||
'todo.detail.description': 'Description',
|
||||
'todo.detail.category': 'Category',
|
||||
'todo.detail.dueDate': 'Due date',
|
||||
'todo.detail.assignedTo': 'Assigned to',
|
||||
'todo.detail.delete': 'Delete',
|
||||
'todo.detail.save': 'Save changes',
|
||||
'todo.detail.create': 'Create task',
|
||||
'todo.detail.priority': 'Priority',
|
||||
'todo.detail.noPriority': 'None',
|
||||
'todo.sortByPrio': 'Priority',
|
||||
'todo.subtab.packing': 'Lista pakowania',
|
||||
'todo.subtab.todo': 'Do zrobienia',
|
||||
'todo.completed': 'ukończono',
|
||||
'todo.filter.all': 'Wszystkie',
|
||||
'todo.filter.open': 'Otwarte',
|
||||
'todo.filter.done': 'Gotowe',
|
||||
'todo.uncategorized': 'Bez kategorii',
|
||||
'todo.namePlaceholder': 'Nazwa zadania',
|
||||
'todo.descriptionPlaceholder': 'Opis (opcjonalnie)',
|
||||
'todo.unassigned': 'Nieprzypisane',
|
||||
'todo.noCategory': 'Brak kategorii',
|
||||
'todo.hasDescription': 'Ma opis',
|
||||
'todo.addItem': 'Dodaj nowe zadanie...',
|
||||
'todo.newCategory': 'Nazwa kategorii',
|
||||
'todo.addCategory': 'Dodaj kategorię',
|
||||
'todo.newItem': 'Nowe zadanie',
|
||||
'todo.empty': 'Brak zadań. Dodaj zadanie, aby zacząć!',
|
||||
'todo.filter.my': 'Moje zadania',
|
||||
'todo.filter.overdue': 'Przeterminowane',
|
||||
'todo.sidebar.tasks': 'Zadania',
|
||||
'todo.sidebar.categories': 'Kategorie',
|
||||
'todo.detail.title': 'Zadanie',
|
||||
'todo.detail.description': 'Opis',
|
||||
'todo.detail.category': 'Kategoria',
|
||||
'todo.detail.dueDate': 'Termin',
|
||||
'todo.detail.assignedTo': 'Przypisano do',
|
||||
'todo.detail.delete': 'Usuń',
|
||||
'todo.detail.save': 'Zapisz zmiany',
|
||||
'todo.detail.create': 'Utwórz zadanie',
|
||||
'todo.detail.priority': 'Priorytet',
|
||||
'todo.detail.noPriority': 'Brak',
|
||||
'todo.sortByPrio': 'Priorytet',
|
||||
|
||||
// Notifications — dev test events
|
||||
'notif.test.title': '[Test] Powiadomienie',
|
||||
'notif.test.simple.text': 'To jest proste powiadomienie testowe.',
|
||||
'notif.test.boolean.text': 'Czy akceptujesz to powiadomienie testowe?',
|
||||
'notif.test.navigate.text': 'Kliknij poniżej, aby przejść do pulpitu.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Zaproszenie do podróży',
|
||||
'notif.trip_invite.text': '{actor} zaprosił Cię do {trip}',
|
||||
'notif.booking_change.title': 'Rezerwacja zaktualizowana',
|
||||
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
|
||||
'notif.trip_reminder.title': 'Przypomnienie o podróży',
|
||||
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
|
||||
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
|
||||
'notif.photos_shared.title': 'Zdjęcia udostępnione',
|
||||
'notif.photos_shared.text': '{actor} udostępnił {count} zdjęcie/zdjęcia w {trip}',
|
||||
'notif.collab_message.title': 'Nowa wiadomość',
|
||||
'notif.collab_message.text': '{actor} wysłał wiadomość w {trip}',
|
||||
'notif.packing_tagged.title': 'Zadanie pakowania',
|
||||
'notif.packing_tagged.text': '{actor} przypisał Cię do {category} w {trip}',
|
||||
'notif.version_available.title': 'Nowa wersja dostępna',
|
||||
'notif.version_available.text': 'TREK {version} jest teraz dostępny',
|
||||
'notif.action.view_trip': 'Zobacz podróż',
|
||||
'notif.action.view_collab': 'Zobacz wiadomości',
|
||||
'notif.action.view_packing': 'Zobacz pakowanie',
|
||||
'notif.action.view_photos': 'Zobacz zdjęcia',
|
||||
'notif.action.view_vacay': 'Zobacz Vacay',
|
||||
'notif.action.view_admin': 'Przejdź do admina',
|
||||
'notif.action.view': 'Zobacz',
|
||||
'notif.action.accept': 'Akceptuj',
|
||||
'notif.action.decline': 'Odrzuć',
|
||||
'notif.generic.title': 'Powiadomienie',
|
||||
'notif.generic.text': 'Masz nowe powiadomienie',
|
||||
'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie',
|
||||
'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,107 @@ interface UpdateInfo {
|
||||
is_docker?: boolean
|
||||
}
|
||||
|
||||
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
}
|
||||
|
||||
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
||||
const [matrix, setMatrix] = useState<any>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
|
||||
const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } }
|
||||
setMatrix((m: any) => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminApi.updateNotificationPreferences(updated)
|
||||
} catch {
|
||||
setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (matrix.event_types.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('settings.notificationPreferences.noChannels')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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.tabs.adminNotifications')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map((eventType: string) => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => toggle(eventType, ch)}
|
||||
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: isOn ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -68,6 +169,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notification-channels', label: t('admin.tabs.notificationChannels') },
|
||||
{ id: 'admin-notifications', label: t('admin.tabs.adminNotifications') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||
@@ -969,189 +1072,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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.notifications.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
|
||||
</div>
|
||||
<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' && (() => {
|
||||
const ch = smtpValues.notification_channel || 'none'
|
||||
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
|
||||
return (
|
||||
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
||||
{!configValid && (
|
||||
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
||||
{[
|
||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||
{ 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"
|
||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: isOn ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</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"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</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')}</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'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<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] !== undefined) 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>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
||||
@@ -1179,6 +1099,209 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notification-channels' && (() => {
|
||||
// Derive active channels from smtpValues.notification_channels (plural)
|
||||
// with fallback to notification_channel (singular) for existing installs
|
||||
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
|
||||
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notification_channels: chans })
|
||||
} catch {
|
||||
// Revert state on failure
|
||||
const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
|
||||
const saveNotifications = async () => {
|
||||
// Saves credentials only — channel activation is auto-saved by the toggle
|
||||
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||
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'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Email Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.emailPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(!emailActive, webhookActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: emailActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{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"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||
<button onClick={saveNotifications}
|
||||
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>
|
||||
<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] !== undefined) 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')) }
|
||||
}}
|
||||
disabled={!smtpConfigured}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.webhookPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(emailActive, !webhookActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: webhookActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-App Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.inappPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
|
||||
</div>
|
||||
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0"
|
||||
style={{ background: 'var(--text-primary)', opacity: 0.5, cursor: 'not-allowed' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: 'translateX(20px)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Webhook Panel */}
|
||||
<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.notifications.adminWebhookPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminWebhookPanel.hint')}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{smtpLoaded && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={smtpValues.admin_webhook_url === '••••••••' ? '' : smtpValues.admin_webhook_url || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
|
||||
placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : '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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
|
||||
toast.success(t('admin.notifications.adminWebhookPanel.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>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
|
||||
if (!url && smtpValues.admin_webhook_url !== '••••••••') return
|
||||
try {
|
||||
if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
|
||||
const result = await notificationsApi.testWebhook(url)
|
||||
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
|
||||
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
|
||||
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
|
||||
}}
|
||||
disabled={!smtpValues.admin_webhook_url?.trim()}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{activeTab === 'admin-notifications' && <AdminNotificationsPanel t={t} toast={toast} />}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
||||
|
||||
@@ -154,7 +154,16 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
|
||||
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
|
||||
const regionLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
|
||||
const [showRegions, setShowRegions] = useState(false)
|
||||
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
|
||||
const regionTooltipRef = useRef<HTMLDivElement>(null)
|
||||
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
|
||||
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
|
||||
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
|
||||
const [bucketMonth, setBucketMonth] = useState(0)
|
||||
const [bucketYear, setBucketYear] = useState(0)
|
||||
|
||||
@@ -221,6 +230,41 @@ export default function AtlasPage(): React.ReactElement {
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load visited regions (geocoded from places/trips) — once on mount
|
||||
useEffect(() => {
|
||||
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
|
||||
.then(r => setVisitedRegions(r.data?.regions || {}))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load admin-1 GeoJSON for countries visible in the current viewport
|
||||
const loadRegionsForViewportRef = useRef<() => void>(() => {})
|
||||
const loadRegionsForViewport = (): void => {
|
||||
if (!mapInstance.current) return
|
||||
const bounds = mapInstance.current.getBounds()
|
||||
const toLoad: string[] = []
|
||||
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
|
||||
if (regionGeoCache.current[code]) continue
|
||||
try {
|
||||
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
|
||||
} catch {}
|
||||
}
|
||||
if (!toLoad.length) return
|
||||
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
|
||||
.then(geoRes => {
|
||||
const geo = geoRes.data
|
||||
if (!geo?.features) return
|
||||
let added = false
|
||||
for (const c of toLoad) {
|
||||
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
|
||||
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
|
||||
}
|
||||
if (added) setRegionGeoLoaded(v => v + 1)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
loadRegionsForViewportRef.current = loadRegionsForViewport
|
||||
|
||||
// Initialize map — runs after loading is done and mapRef is available
|
||||
useEffect(() => {
|
||||
if (loading || !mapRef.current) return
|
||||
@@ -230,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
center: [25, 0],
|
||||
zoom: 3,
|
||||
minZoom: 3,
|
||||
maxZoom: 7,
|
||||
maxZoom: 10,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
maxBounds: [[-90, -220], [90, 220]],
|
||||
@@ -246,7 +290,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 25,
|
||||
updateWhenZooming: true,
|
||||
updateWhenIdle: false,
|
||||
@@ -257,14 +301,49 @@ export default function AtlasPage(): React.ReactElement {
|
||||
|
||||
// Preload adjacent zoom level tiles
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 10,
|
||||
opacity: 0,
|
||||
tileSize: 256,
|
||||
crossOrigin: true,
|
||||
}).addTo(map)
|
||||
|
||||
// Custom pane for region layer — above overlay (z-index 400)
|
||||
map.createPane('regionPane')
|
||||
map.getPane('regionPane')!.style.zIndex = '401'
|
||||
|
||||
mapInstance.current = map
|
||||
|
||||
// Zoom-based region switching
|
||||
map.on('zoomend', () => {
|
||||
const z = map.getZoom()
|
||||
const shouldShow = z >= 5
|
||||
setShowRegions(shouldShow)
|
||||
const overlayPane = map.getPane('overlayPane')
|
||||
if (overlayPane) {
|
||||
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
|
||||
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
|
||||
}
|
||||
if (shouldShow) {
|
||||
// Re-add region layer if it was removed while zoomed out
|
||||
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
|
||||
regionLayerRef.current.addTo(map)
|
||||
}
|
||||
loadRegionsForViewportRef.current()
|
||||
} else {
|
||||
// Physically remove region layer so its SVG paths can't intercept events
|
||||
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
|
||||
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
|
||||
regionLayerRef.current.resetStyle()
|
||||
regionLayerRef.current.removeFrom(map)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
|
||||
})
|
||||
|
||||
return () => { map.remove(); mapInstance.current = null }
|
||||
}, [dark, loading])
|
||||
|
||||
@@ -339,10 +418,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
})
|
||||
layer.on('click', () => {
|
||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||
// Manually marked only — show unmark popup
|
||||
handleUnmarkCountry(c.code)
|
||||
} else {
|
||||
loadCountryDetail(c.code)
|
||||
}
|
||||
})
|
||||
layer.on('mouseover', (e) => {
|
||||
@@ -379,9 +455,153 @@ export default function AtlasPage(): React.ReactElement {
|
||||
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
|
||||
}, [geoData, data, dark])
|
||||
|
||||
// Render sub-national region layer (zoom >= 5)
|
||||
useEffect(() => {
|
||||
if (!mapInstance.current) return
|
||||
|
||||
// Remove existing region layer
|
||||
if (regionLayerRef.current) {
|
||||
mapInstance.current.removeLayer(regionLayerRef.current)
|
||||
regionLayerRef.current = null
|
||||
}
|
||||
|
||||
if (Object.keys(regionGeoCache.current).length === 0) return
|
||||
|
||||
// Build set of visited region codes first
|
||||
const visitedRegionCodes = new Set<string>()
|
||||
const visitedRegionNames = new Set<string>()
|
||||
const regionPlaceCounts: Record<string, number> = {}
|
||||
for (const [, regions] of Object.entries(visitedRegions)) {
|
||||
for (const r of regions) {
|
||||
visitedRegionCodes.add(r.code)
|
||||
visitedRegionNames.add(r.name.toLowerCase())
|
||||
regionPlaceCounts[r.code] = r.placeCount
|
||||
regionPlaceCounts[r.name.toLowerCase()] = r.placeCount
|
||||
}
|
||||
}
|
||||
|
||||
// Match feature by ISO code OR region name
|
||||
const isVisitedFeature = (f: any) => {
|
||||
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
||||
const name = (f.properties?.name || '').toLowerCase()
|
||||
if (visitedRegionNames.has(name)) return true
|
||||
// Fuzzy: check if any visited name is contained in feature name or vice versa
|
||||
for (const vn of visitedRegionNames) {
|
||||
if (name.includes(vn) || vn.includes(name)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Include ALL region features — visited ones get colored fill, unvisited get outline only
|
||||
const allFeatures: any[] = []
|
||||
for (const geo of Object.values(regionGeoCache.current)) {
|
||||
for (const f of geo.features) {
|
||||
allFeatures.push(f)
|
||||
}
|
||||
}
|
||||
if (allFeatures.length === 0) return
|
||||
|
||||
// Use same colors as country layer
|
||||
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
|
||||
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
|
||||
const countryColorMap: Record<string, string> = {}
|
||||
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
|
||||
// Map country A2 code to country color
|
||||
const a2ColorMap: Record<string, string> = {}
|
||||
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
|
||||
|
||||
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
|
||||
|
||||
const svgRenderer = L.svg({ pane: 'regionPane' })
|
||||
|
||||
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
|
||||
renderer: svgRenderer,
|
||||
interactive: true,
|
||||
pane: 'regionPane',
|
||||
style: (feature) => {
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
const visited = isVisitedFeature(feature)
|
||||
return visited ? {
|
||||
fillColor: a2ColorMap[countryA2] || '#6366f1',
|
||||
fillOpacity: 0.85,
|
||||
color: dark ? '#888' : '#64748b',
|
||||
weight: 1.2,
|
||||
} : {
|
||||
fillColor: dark ? '#ffffff' : '#000000',
|
||||
fillOpacity: 0.03,
|
||||
color: dark ? '#555' : '#94a3b8',
|
||||
weight: 1,
|
||||
}
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const regionName = feature?.properties?.name || ''
|
||||
const countryName = feature?.properties?.admin || ''
|
||||
const regionCode = feature?.properties?.iso_3166_2 || ''
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
const visited = isVisitedFeature(feature)
|
||||
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
|
||||
layer.on('click', () => {
|
||||
if (!countryA2) return
|
||||
if (visited) {
|
||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
||||
if (regionEntry?.manuallyMarked) {
|
||||
setConfirmActionRef.current({
|
||||
type: 'unmark-region',
|
||||
code: countryA2,
|
||||
name: regionName,
|
||||
regionCode,
|
||||
countryName,
|
||||
})
|
||||
} else {
|
||||
loadCountryDetailRef.current(countryA2)
|
||||
}
|
||||
} else {
|
||||
setConfirmActionRef.current({
|
||||
type: 'choose-region',
|
||||
code: countryA2, // country A2 code — used for flag display
|
||||
name: regionName, // region name — shown as heading
|
||||
regionCode,
|
||||
countryName,
|
||||
})
|
||||
}
|
||||
})
|
||||
layer.on('mouseover', (e: any) => {
|
||||
e.target.setStyle(visited
|
||||
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
|
||||
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
|
||||
)
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) {
|
||||
tt.style.display = 'block'
|
||||
tt.style.left = e.originalEvent.clientX + 12 + 'px'
|
||||
tt.style.top = e.originalEvent.clientY - 10 + 'px'
|
||||
tt.innerHTML = visited
|
||||
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
|
||||
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
|
||||
}
|
||||
})
|
||||
layer.on('mousemove', (e: any) => {
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
|
||||
})
|
||||
layer.on('mouseout', (e: any) => {
|
||||
regionLayerRef.current?.resetStyle(e.target)
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) tt.style.display = 'none'
|
||||
})
|
||||
},
|
||||
})
|
||||
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
|
||||
if (mapInstance.current.getZoom() >= 6) {
|
||||
regionLayerRef.current.addTo(mapInstance.current)
|
||||
}
|
||||
}, [regionGeoLoaded, visitedRegions, dark, t])
|
||||
|
||||
const handleMarkCountry = (code: string, name: string): void => {
|
||||
setConfirmAction({ type: 'choose', code, name })
|
||||
}
|
||||
handleMarkCountryRef.current = handleMarkCountry
|
||||
setConfirmActionRef.current = setConfirmAction
|
||||
|
||||
const handleUnmarkCountry = (code: string): void => {
|
||||
const country = data?.countries.find(c => c.code === code)
|
||||
@@ -435,6 +655,12 @@ export default function AtlasPage(): React.ReactElement {
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
setVisitedRegions(prev => {
|
||||
if (!prev[code]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[code]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +738,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
setCountryDetail(r.data)
|
||||
} catch { /* */ }
|
||||
}
|
||||
loadCountryDetailRef.current = loadCountryDetail
|
||||
|
||||
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||
const countries = data?.countries || []
|
||||
@@ -533,6 +760,18 @@ export default function AtlasPage(): React.ReactElement {
|
||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||
{/* Map */}
|
||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||
|
||||
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
|
||||
<div ref={regionTooltipRef} style={{
|
||||
position: 'fixed', display: 'none',
|
||||
zIndex: 9999, pointerEvents: 'none',
|
||||
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
|
||||
color: dark ? '#fff' : '#111',
|
||||
borderRadius: 10, padding: '10px 14px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
|
||||
fontSize: 12, minWidth: 120,
|
||||
}} />
|
||||
<div
|
||||
className="absolute z-20 flex justify-center"
|
||||
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||
@@ -769,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
|
||||
setVisitedRegions(prev => {
|
||||
const existing = prev[countryCode] || []
|
||||
if (existing.find(r => r.code === rCode)) return prev
|
||||
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
|
||||
})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
||||
@@ -785,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button onClick={() => setConfirmAction(null)}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
|
||||
setVisitedRegions(prev => {
|
||||
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
|
||||
const next = { ...prev, [countryCode]: remaining }
|
||||
if (remaining.length === 0) delete next[countryCode]
|
||||
return next
|
||||
})
|
||||
// If no manually-marked regions remain, also remove country if it has no trips/places
|
||||
setData(prev => {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === countryCode)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
||||
@@ -815,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -34,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get('redirect')
|
||||
// Only allow relative paths starting with / to prevent open redirect attacks
|
||||
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
|
||||
return redirect
|
||||
}
|
||||
return '/dashboard'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
@@ -99,7 +109,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
try {
|
||||
await demoLogin()
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('login.demoFailed'))
|
||||
} finally {
|
||||
@@ -128,7 +138,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
||||
await loadUser({ silent: true })
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'login' && mfaStep) {
|
||||
@@ -145,7 +155,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'register') {
|
||||
@@ -169,7 +179,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
} catch (err: unknown) {
|
||||
setError(getApiErrorMessage(err, t('login.error')))
|
||||
setIsLoading(false)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
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, Info } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import apiClient from '../api/client'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import type { Place } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
|
||||
import MapSettingsTab from '../components/Settings/MapSettingsTab'
|
||||
import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
import IntegrationsTab from '../components/Settings/IntegrationsTab'
|
||||
import AccountTab from '../components/Settings/AccountTab'
|
||||
import AboutTab from '../components/Settings/AboutTab'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
@@ -140,7 +141,7 @@ function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
@@ -150,9 +151,6 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Addon gating (derived from store)
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
@@ -163,14 +161,12 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
|
||||
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
}, [])
|
||||
const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => {
|
||||
const raw = provider.config || {}
|
||||
@@ -604,16 +600,37 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-switch to account tab when MFA is required
|
||||
useEffect(() => {
|
||||
if (searchParams.get('mfa') === 'required') {
|
||||
setActiveTab('account')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const TABS = [
|
||||
{ id: 'display', label: t('settings.tabs.display') },
|
||||
{ id: 'map', label: t('settings.tabs.map') },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
|
||||
{ id: 'account', label: t('settings.tabs.account') },
|
||||
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<style>{`@media (max-width: 900px) { .settings-columns { column-count: 1 !important; } }`}</style>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}>
|
||||
<Settings className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-columns" style={{ columnCount: 2, columnGap: 24 }}>
|
||||
@@ -1385,147 +1402,30 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
{/* Tab bar */}
|
||||
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving.profile}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
const data = await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||
style={{ border: '1px solid #fecaca' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{appVersion && (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm === true && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
border: 'none', background: '#ef4444', color: 'white',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'display' && <DisplaySettingsTab />}
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<script type="module" crossorigin src="/assets/index-BBkAKwut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CR224PtB.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -83,7 +83,8 @@ export function createApp(): express.Application {
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson"
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||
"https://router.project-osrm.org/route/v1"
|
||||
],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
|
||||
@@ -736,6 +736,92 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try {db.exec('UPDATE addons SET enabled = 0 WHERE id = memories');} catch (err) {}
|
||||
}
|
||||
// Migration 69: Place region cache for sub-national Atlas regions
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_regions (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
country_code TEXT NOT NULL,
|
||||
region_code TEXT NOT NULL,
|
||||
region_name TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code);
|
||||
`);
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS visited_regions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
region_code TEXT NOT NULL,
|
||||
region_name TEXT NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, region_code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code);
|
||||
`);
|
||||
},
|
||||
// Migration 71: Normalized per-user per-channel notification preferences
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
|
||||
// Migrate data from old notification_preferences table (may not exist on fresh installs)
|
||||
const tableExists = (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notification_preferences'").get() as { name: string } | undefined) != null;
|
||||
const oldPrefs: Array<Record<string, number>> = tableExists
|
||||
? db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>
|
||||
: [];
|
||||
const eventCols: Record<string, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||
for (const [userId, eventType, channel, enabled] of rows) {
|
||||
insert.run(userId, eventType, channel, enabled);
|
||||
}
|
||||
});
|
||||
|
||||
for (const row of oldPrefs) {
|
||||
const userId = row.user_id as number;
|
||||
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||
const rows: Array<[number, string, string, number]> = [];
|
||||
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||
const emailEnabled = (row[col] as number) ?? 1;
|
||||
// Only insert if disabled (no row = enabled is our default)
|
||||
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||
}
|
||||
if (rows.length > 0) insertMany(rows);
|
||||
}
|
||||
|
||||
// Copy existing single-channel setting to new plural key
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO app_settings (key, value)
|
||||
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||
`);
|
||||
},
|
||||
// Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71)
|
||||
() => {
|
||||
db.exec('DROP TABLE IF EXISTS notification_preferences;');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -449,6 +449,15 @@ function createTables(db: Database.Database): void {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
|
||||
}
|
||||
scheduler.start();
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import * as svc from '../services/adminService';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -132,6 +133,19 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
res.json(await svc.checkVersion());
|
||||
});
|
||||
|
||||
// ── Admin notification preferences ────────────────────────────────────────
|
||||
|
||||
router.get('/notification-preferences', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||
});
|
||||
|
||||
router.put('/notification-preferences', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
setAdminPreferences(authReq.user.id, req.body);
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||
});
|
||||
|
||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/invites', (_req: Request, res: Response) => {
|
||||
@@ -313,38 +327,22 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
|
||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const { createNotification } = require('../services/inAppNotifications');
|
||||
const { send } = require('../services/notificationService');
|
||||
|
||||
router.post('/dev/test-notification', (req: Request, res: Response) => {
|
||||
router.post('/dev/test-notification', async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { type, scope, target, title_key, text_key, title_params, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target } = req.body;
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
type: type || 'simple',
|
||||
scope: scope || 'user',
|
||||
target: target ?? authReq.user.id,
|
||||
sender_id: authReq.user.id,
|
||||
title_key: title_key || 'notifications.test.title',
|
||||
title_params: title_params || {},
|
||||
text_key: text_key || 'notifications.test.text',
|
||||
text_params: text_params || {},
|
||||
};
|
||||
|
||||
if (type === 'boolean') {
|
||||
input.positive_text_key = positive_text_key || 'notifications.test.accept';
|
||||
input.negative_text_key = negative_text_key || 'notifications.test.decline';
|
||||
input.positive_callback = positive_callback || { action: 'test_approve', payload: {} };
|
||||
input.negative_callback = negative_callback || { action: 'test_deny', payload: {} };
|
||||
} else if (type === 'navigate') {
|
||||
input.navigate_text_key = navigate_text_key || 'notifications.test.goThere';
|
||||
input.navigate_target = navigate_target || '/dashboard';
|
||||
}
|
||||
const { event = 'trip_reminder', scope = 'user', targetId, params = {}, inApp } = req.body;
|
||||
|
||||
try {
|
||||
const ids = createNotification(input);
|
||||
res.json({ success: true, notification_ids: ids });
|
||||
await send({
|
||||
event,
|
||||
actorId: authReq.user.id,
|
||||
scope,
|
||||
targetId: targetId ?? authReq.user.id,
|
||||
params: { actor: authReq.user.email, ...params },
|
||||
inApp,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
getCountryPlaces,
|
||||
markCountryVisited,
|
||||
unmarkCountryVisited,
|
||||
markRegionVisited,
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
listBucketList,
|
||||
createBucketItem,
|
||||
updateBucketItem,
|
||||
@@ -21,6 +25,21 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.get('/regions', async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
const data = await getVisitedRegions(userId);
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.get('/regions/geo', async (req: Request, res: Response) => {
|
||||
const countries = (req.query.countries as string || '').split(',').filter(Boolean);
|
||||
if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] });
|
||||
const geo = await getRegionGeo(countries);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.json(geo);
|
||||
});
|
||||
|
||||
router.get('/country/:code', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const code = req.params.code.toUpperCase();
|
||||
@@ -39,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const { name, country_code } = req.body;
|
||||
if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' });
|
||||
markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
unmarkRegionVisited(userId, req.params.code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
@@ -79,9 +79,9 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
|
||||
res.status(201).json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email }).catch(() => {});
|
||||
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,10 +256,10 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
||||
broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new chat message
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview }).catch(() => {});
|
||||
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp, testWebhook } from '../services/notifications';
|
||||
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
@@ -12,22 +12,19 @@ import {
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
} from '../services/inAppNotifications';
|
||||
import * as prefsService from '../services/notificationPreferencesService';
|
||||
import { getPreferencesMatrix, setPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ preferences: prefsService.getPreferences(authReq.user.id) });
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||
});
|
||||
|
||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
||||
const preferences = prefsService.updatePreferences(authReq.user.id, {
|
||||
notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook
|
||||
});
|
||||
res.json({ preferences });
|
||||
setPreferences(authReq.user.id, req.body);
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||
});
|
||||
|
||||
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||
@@ -39,8 +36,15 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
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' });
|
||||
res.json(await testWebhook());
|
||||
let { url } = req.body;
|
||||
if (!url || url === '••••••••') {
|
||||
url = getUserWebhookUrl(authReq.user.id);
|
||||
if (!url && authReq.user.role === 'admin') url = getAdminWebhookUrl();
|
||||
if (!url) return res.status(400).json({ error: 'No webhook URL configured' });
|
||||
}
|
||||
if (typeof url !== 'string') return res.status(400).json({ error: 'url must be a string' });
|
||||
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||
res.json(await testWebhook(url));
|
||||
});
|
||||
|
||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -224,13 +224,10 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
||||
|
||||
// Notify newly assigned users
|
||||
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
for (const uid of user_ids) {
|
||||
if (uid !== authReq.user.id) {
|
||||
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Use trip scope so the service resolves recipients — actor is excluded automatically
|
||||
send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,9 +69,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new booking
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking' }).catch(() => {});
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,9 +137,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ reservation });
|
||||
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking' }).catch(() => {});
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,9 +163,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ router.put('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { key, value } = req.body;
|
||||
if (!key) return res.status(400).json({ error: 'Key is required' });
|
||||
if (value === '••••••••') return res.json({ success: true, key, unchanged: true });
|
||||
settingsService.upsertSetting(authReq.user.id, key, value);
|
||||
res.json({ success: true, key, value });
|
||||
});
|
||||
|
||||
@@ -412,8 +412,8 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id);
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: result.targetUserId, event: 'trip_invite', params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email } }).catch(() => {});
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
send({ event: 'trip_invite', actorId: authReq.user.id, scope: 'user', targetId: result.targetUserId, params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email, tripId: String(req.params.id) } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: result.member });
|
||||
|
||||
@@ -163,22 +163,23 @@ function startTripReminders(): void {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const channel = getSetting('notification_channel') || 'none';
|
||||
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||
const hasSmtp = !!(getSetting('smtp_host') || '').trim();
|
||||
const hasWebhook = !!(getSetting('notification_webhook_url') || '').trim();
|
||||
const channelReady = (channel === 'email' && hasSmtp) || (channel === 'webhook' && hasWebhook);
|
||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
|
||||
const hasWebhook = activeChannels.includes('webhook');
|
||||
const channelReady = hasEmail || hasWebhook;
|
||||
|
||||
if (!channelReady || !reminderEnabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
const reason = !channelReady ? `no ${channel === 'none' ? 'notification channel' : channel} configuration` : 'trip reminders disabled in settings';
|
||||
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
|
||||
li(`Trip reminders: disabled (${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
||||
const { logInfo: liSetup } = require('./services/auditLog');
|
||||
liSetup(`Trip reminders: enabled via ${channel}${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
liSetup(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +188,7 @@ function startTripReminders(): void {
|
||||
reminderTask = cron.schedule('0 9 * * *', async () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const { notifyTripMembers } = require('./services/notifications');
|
||||
const { send } = require('./services/notificationService');
|
||||
|
||||
const trips = db.prepare(`
|
||||
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
||||
@@ -197,7 +198,7 @@ function startTripReminders(): void {
|
||||
`).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
|
||||
|
||||
for (const trip of trips) {
|
||||
await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
|
||||
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
|
||||
}
|
||||
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
@@ -211,10 +212,29 @@ function startTripReminders(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Version check: daily at 9 AM — notify admins if a new TREK release is available
|
||||
let versionCheckTask: ScheduledTask | null = null;
|
||||
|
||||
function startVersionCheck(): void {
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
versionCheckTask = cron.schedule('0 9 * * *', async () => {
|
||||
try {
|
||||
const { checkAndNotifyVersion } = require('./services/adminService');
|
||||
await checkAndNotifyVersion();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Version check: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } f
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -313,6 +314,28 @@ export async function checkVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAndNotifyVersion(): Promise<void> {
|
||||
try {
|
||||
const result = await checkVersion();
|
||||
if (!result.update_available) return;
|
||||
|
||||
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
if (lastNotified === result.latest) return;
|
||||
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
|
||||
|
||||
await sendNotification({
|
||||
event: 'version_available',
|
||||
actorId: null,
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: result.latest as string },
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore — version check is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
export function listInvites() {
|
||||
|
||||
@@ -2,6 +2,38 @@ import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
|
||||
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
||||
|
||||
let admin1GeoCache: any = null;
|
||||
let admin1GeoLoading: Promise<any> | null = null;
|
||||
|
||||
async function loadAdmin1Geo(): Promise<any> {
|
||||
if (admin1GeoCache) return admin1GeoCache;
|
||||
if (admin1GeoLoading) return admin1GeoLoading;
|
||||
admin1GeoLoading = fetch(
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
).then(r => r.json()).then(geo => {
|
||||
admin1GeoCache = geo;
|
||||
admin1GeoLoading = null;
|
||||
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
}).catch(err => {
|
||||
admin1GeoLoading = null;
|
||||
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
||||
return null;
|
||||
});
|
||||
return admin1GeoLoading;
|
||||
}
|
||||
|
||||
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||
const geo = await loadAdmin1Geo();
|
||||
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
// ── Geocode cache ───────────────────────────────────────────────────────────
|
||||
|
||||
const geocodeCache = new Map<string, string | null>();
|
||||
@@ -339,6 +371,126 @@ export function markCountryVisited(userId: number, code: string): void {
|
||||
|
||||
export function unmarkCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
}
|
||||
|
||||
// ── Mark / unmark region ────────────────────────────────────────────────────
|
||||
|
||||
export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] {
|
||||
return db.prepare(
|
||||
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as { region_code: string; region_name: string; country_code: string }[];
|
||||
}
|
||||
|
||||
export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode);
|
||||
// Auto-mark parent country if not already visited
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode);
|
||||
}
|
||||
|
||||
export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
||||
const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined;
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode);
|
||||
if (region) {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number };
|
||||
if (remaining.count === 0) {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-national region resolution ────────────────────────────────────────
|
||||
|
||||
interface RegionInfo { country_code: string; region_code: string; region_name: string }
|
||||
|
||||
const regionCache = new Map<string, RegionInfo | null>();
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: Record<string, string> };
|
||||
const countryCode = data.address?.country_code?.toUpperCase() || null;
|
||||
// Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions)
|
||||
let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null;
|
||||
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
|
||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||
}
|
||||
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
||||
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||
const info: RegionInfo = {
|
||||
country_code: countryCode,
|
||||
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
|
||||
region_name: regionName,
|
||||
};
|
||||
regionCache.set(key, info);
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
|
||||
const trips = getUserTrips(userId);
|
||||
const tripIds = trips.map(t => t.id);
|
||||
const places = getPlacesForTrips(tripIds);
|
||||
|
||||
// Check DB cache first
|
||||
const placeIds = places.filter(p => p.lat && p.lng).map(p => p.id);
|
||||
const cached = placeIds.length > 0
|
||||
? db.prepare(`SELECT * FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`).all(...placeIds) as { place_id: number; country_code: string; region_code: string; region_name: string }[]
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
|
||||
|
||||
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
|
||||
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
|
||||
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
|
||||
for (const place of uncached) {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) {
|
||||
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
cachedMap.set(place.id, { place_id: place.id, ...info });
|
||||
}
|
||||
// Nominatim rate limit: 1 req/sec
|
||||
if (uncached.indexOf(place) < uncached.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 1100));
|
||||
}
|
||||
}
|
||||
|
||||
// Group by country → regions with place counts
|
||||
const regionMap: Record<string, Map<string, { code: string; name: string; placeCount: number }>> = {};
|
||||
for (const [, entry] of cachedMap) {
|
||||
if (!regionMap[entry.country_code]) regionMap[entry.country_code] = new Map();
|
||||
const existing = regionMap[entry.country_code].get(entry.region_code);
|
||||
if (existing) {
|
||||
existing.placeCount++;
|
||||
} else {
|
||||
regionMap[entry.country_code].set(entry.region_code, { code: entry.region_code, name: entry.region_name, placeCount: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]> = {};
|
||||
for (const [country, regions] of Object.entries(regionMap)) {
|
||||
result[country] = [...regions.values()];
|
||||
}
|
||||
|
||||
// Merge manually marked regions
|
||||
const manualRegions = listManuallyVisitedRegions(userId);
|
||||
for (const r of manualRegions) {
|
||||
if (!result[r.country_code]) result[r.country_code] = [];
|
||||
if (!result[r.country_code].find(x => x.code === r.region_code)) {
|
||||
result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { regions: result };
|
||||
}
|
||||
|
||||
// ── Bucket list CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,9 +31,7 @@ const MFA_BACKUP_CODE_COUNT = 10;
|
||||
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',
|
||||
'notification_channels', 'admin_webhook_url',
|
||||
];
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -195,8 +193,10 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
||||
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
||||
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
|
||||
const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
|
||||
const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
|
||||
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
|
||||
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
@@ -216,6 +216,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||
dev_mode: process.env.NODE_ENV === 'development',
|
||||
@@ -676,7 +678,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
||||
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
|
||||
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url') ? '••••••••' : row.value;
|
||||
}
|
||||
return { data: result };
|
||||
}
|
||||
@@ -714,6 +716,8 @@ export function updateAppSettings(
|
||||
}
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
||||
if (key === 'admin_webhook_url' && val === '••••••••') continue;
|
||||
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
@@ -722,11 +726,9 @@ export function updateAppSettings(
|
||||
|
||||
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 = body.notification_channel;
|
||||
if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true;
|
||||
if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels;
|
||||
if (changedKeys.includes('admin_webhook_url')) summary.admin_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 = body.allow_registration;
|
||||
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
||||
if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa;
|
||||
@@ -736,7 +738,7 @@ export function updateAppSettings(
|
||||
debugDetails[k] = k === 'smtp_pass' ? '***' : body[k];
|
||||
}
|
||||
|
||||
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
|
||||
const notifRelated = ['notification_channels', 'smtp_host'];
|
||||
const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k));
|
||||
if (shouldRestartScheduler) {
|
||||
startTripReminders();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
@@ -11,6 +12,7 @@ interface BaseNotificationInput {
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
event_type?: NotifEventType;
|
||||
title_key: string;
|
||||
title_params?: Record<string, string>;
|
||||
text_key: string;
|
||||
@@ -61,7 +63,7 @@ interface NotificationRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
export function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
let userIds: number[] = [];
|
||||
|
||||
if (scope === 'trip') {
|
||||
@@ -93,7 +95,8 @@ function createNotification(input: NotificationInput): number[] {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
const insertedIds: number[] = [];
|
||||
// Track inserted id → recipientId pairs (some recipients may be skipped by pref check)
|
||||
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
|
||||
|
||||
const insert = db.transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
@@ -106,6 +109,11 @@ function createNotification(input: NotificationInput): number[] {
|
||||
`);
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
// Check per-user in-app preference if an event_type is provided
|
||||
if (input.event_type && !isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
@@ -130,7 +138,7 @@ function createNotification(input: NotificationInput): number[] {
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
insertedIds.push(result.lastInsertRowid as number);
|
||||
insertedPairs.push({ id: result.lastInsertRowid as number, recipientId });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,9 +150,7 @@ function createNotification(input: NotificationInput): number[] {
|
||||
: null;
|
||||
|
||||
// Broadcast to each recipient
|
||||
for (let i = 0; i < insertedIds.length; i++) {
|
||||
const notificationId = insertedIds[i];
|
||||
const recipientId = recipients[i];
|
||||
for (const { id: notificationId, recipientId } of insertedPairs) {
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
|
||||
if (!row) continue;
|
||||
|
||||
@@ -158,7 +164,66 @@ function createNotification(input: NotificationInput): number[] {
|
||||
});
|
||||
}
|
||||
|
||||
return insertedIds;
|
||||
return insertedPairs.map(p => p.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single in-app notification for one pre-resolved recipient and broadcast via WebSocket.
|
||||
* Used by notificationService.send() which handles recipient resolution externally.
|
||||
*/
|
||||
export function createNotificationForRecipient(
|
||||
input: NotificationInput,
|
||||
recipientId: number,
|
||||
sender: { username: string; avatar: string | null } | null
|
||||
): number | null {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
let negativeCallback: string | null = null;
|
||||
let navigateTextKey: string | null = null;
|
||||
let navigateTarget: string | null = null;
|
||||
|
||||
if (input.type === 'boolean') {
|
||||
positiveTextKey = input.positive_text_key;
|
||||
negativeTextKey = input.negative_text_key;
|
||||
positiveCallback = JSON.stringify(input.positive_callback);
|
||||
negativeCallback = JSON.stringify(input.negative_callback);
|
||||
} else if (input.type === 'navigate') {
|
||||
navigateTextKey = input.navigate_text_key;
|
||||
navigateTarget = input.navigate_target;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||
input.title_key, titleParams, input.text_key, textParams,
|
||||
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
const notificationId = result.lastInsertRowid as number;
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
|
||||
function getNotifications(
|
||||
@@ -266,55 +331,6 @@ async function respondToBoolean(
|
||||
return { success: true, notification: updated };
|
||||
}
|
||||
|
||||
interface NotificationPreferences {
|
||||
id: number;
|
||||
user_id: number;
|
||||
notify_trip_invite: number;
|
||||
notify_booking_change: number;
|
||||
notify_trip_reminder: number;
|
||||
notify_webhook: number;
|
||||
}
|
||||
|
||||
interface PreferencesUpdate {
|
||||
notify_trip_invite?: boolean;
|
||||
notify_booking_change?: boolean;
|
||||
notify_trip_reminder?: boolean;
|
||||
notify_webhook?: boolean;
|
||||
}
|
||||
|
||||
function getPreferences(userId: number): NotificationPreferences {
|
||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences | undefined;
|
||||
if (!prefs) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
||||
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences;
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
function updatePreferences(userId: number, updates: PreferencesUpdate): NotificationPreferences {
|
||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
||||
}
|
||||
|
||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = updates;
|
||||
|
||||
db.prepare(`UPDATE notification_preferences SET
|
||||
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
||||
notify_booking_change = COALESCE(?, notify_booking_change),
|
||||
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
||||
notify_webhook = COALESCE(?, notify_webhook)
|
||||
WHERE user_id = ?`).run(
|
||||
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
|
||||
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
|
||||
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
|
||||
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
|
||||
userId
|
||||
);
|
||||
|
||||
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences;
|
||||
}
|
||||
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
@@ -325,8 +341,6 @@ export {
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
};
|
||||
|
||||
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
||||
|
||||
@@ -1,40 +1,267 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
export function getPreferences(userId: number) {
|
||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!prefs) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
||||
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
}
|
||||
return prefs;
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NotifChannel = 'email' | 'webhook' | 'inapp';
|
||||
|
||||
export type NotifEventType =
|
||||
| 'trip_invite'
|
||||
| 'booking_change'
|
||||
| 'trip_reminder'
|
||||
| 'vacay_invite'
|
||||
| 'photos_shared'
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available';
|
||||
|
||||
export interface AvailableChannels {
|
||||
email: boolean;
|
||||
webhook: boolean;
|
||||
inapp: boolean;
|
||||
}
|
||||
|
||||
export function updatePreferences(
|
||||
userId: number,
|
||||
fields: {
|
||||
notify_trip_invite?: boolean;
|
||||
notify_booking_change?: boolean;
|
||||
notify_trip_reminder?: boolean;
|
||||
notify_webhook?: boolean;
|
||||
}
|
||||
) {
|
||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
||||
// Which channels are implemented for each event type.
|
||||
// Only implemented combos show toggles in the user preferences UI.
|
||||
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||
trip_invite: ['inapp', 'email', 'webhook'],
|
||||
booking_change: ['inapp', 'email', 'webhook'],
|
||||
trip_reminder: ['inapp', 'email', 'webhook'],
|
||||
vacay_invite: ['inapp', 'email', 'webhook'],
|
||||
photos_shared: ['inapp', 'email', 'webhook'],
|
||||
collab_message: ['inapp', 'email', 'webhook'],
|
||||
packing_tagged: ['inapp', 'email', 'webhook'],
|
||||
version_available: ['inapp', 'email', 'webhook'],
|
||||
};
|
||||
|
||||
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
return (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || null;
|
||||
}
|
||||
|
||||
// ── Active channels (admin-configured) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns which channels the admin has enabled (email and/or webhook).
|
||||
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
|
||||
* In-app is always considered active at the service level.
|
||||
*/
|
||||
export function getActiveChannels(): NotifChannel[] {
|
||||
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
||||
if (raw === 'none') return [];
|
||||
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which channels are configured (have valid credentials/URLs set).
|
||||
* In-app is always available. Email/webhook depend on configuration.
|
||||
*/
|
||||
export function getAvailableChannels(): AvailableChannels {
|
||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
const hasWebhook = getActiveChannels().includes('webhook');
|
||||
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the user has this event+channel enabled.
|
||||
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
|
||||
*/
|
||||
export function isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, channel) as { enabled: number } | undefined;
|
||||
return row === undefined || row.enabled === 1;
|
||||
}
|
||||
|
||||
// ── Preferences matrix ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PreferencesMatrix {
|
||||
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
|
||||
available_channels: AvailableChannels;
|
||||
event_types: NotifEventType[];
|
||||
implemented_combos: Record<NotifEventType, NotifChannel[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferences matrix for a user.
|
||||
* scope='user' — excludes admin-scoped events (for user settings page)
|
||||
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
|
||||
*/
|
||||
export function getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
|
||||
const rows = db.prepare(
|
||||
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?'
|
||||
).all(userId) as Array<{ event_type: string; channel: string; enabled: number }>;
|
||||
|
||||
// Build a lookup from stored rows
|
||||
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
for (const row of rows) {
|
||||
if (!stored[row.event_type]) stored[row.event_type] = {};
|
||||
stored[row.event_type]![row.channel] = row.enabled === 1;
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE notification_preferences SET
|
||||
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
||||
notify_booking_change = COALESCE(?, notify_booking_change),
|
||||
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
||||
notify_webhook = COALESCE(?, notify_webhook)
|
||||
WHERE user_id = ?`).run(
|
||||
fields.notify_trip_invite !== undefined ? (fields.notify_trip_invite ? 1 : 0) : null,
|
||||
fields.notify_booking_change !== undefined ? (fields.notify_booking_change ? 1 : 0) : null,
|
||||
fields.notify_trip_reminder !== undefined ? (fields.notify_trip_reminder ? 1 : 0) : null,
|
||||
fields.notify_webhook !== undefined ? (fields.notify_webhook ? 1 : 0) : null,
|
||||
userId
|
||||
// Build the full matrix with defaults (true when no row exists)
|
||||
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
|
||||
const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[];
|
||||
|
||||
for (const eventType of allEvents) {
|
||||
const channels = IMPLEMENTED_COMBOS[eventType];
|
||||
preferences[eventType] = {};
|
||||
for (const channel of channels) {
|
||||
// Admin-scoped events use global settings for email/webhook
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
|
||||
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
||||
} else {
|
||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter event types by scope
|
||||
const event_types = scope === 'admin'
|
||||
? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e))
|
||||
: allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
|
||||
// Available channels depend on scope
|
||||
let available_channels: AvailableChannels;
|
||||
if (scope === 'admin') {
|
||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
|
||||
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true };
|
||||
} else {
|
||||
const activeChannels = getActiveChannels();
|
||||
available_channels = {
|
||||
email: activeChannels.includes('email'),
|
||||
webhook: activeChannels.includes('webhook'),
|
||||
inapp: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preferences,
|
||||
available_channels,
|
||||
event_types,
|
||||
implemented_combos: IMPLEMENTED_COMBOS,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
||||
|
||||
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook'];
|
||||
|
||||
/**
|
||||
* Returns the global admin preference for an event+channel.
|
||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||
* Defaults to true (enabled) when no row exists.
|
||||
*/
|
||||
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'): boolean {
|
||||
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
return val !== '0';
|
||||
}
|
||||
|
||||
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', enabled: boolean): void {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
`admin_notif_pref_${event}_${channel}`,
|
||||
enabled ? '1' : '0'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preferences update ─────────────────────────────────────────────────────
|
||||
|
||||
// ── Shared helper for per-user channel preference upserts ─────────────────
|
||||
|
||||
function applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
upsert: ReturnType<typeof db.prepare>,
|
||||
del: ReturnType<typeof db.prepare>
|
||||
): void {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (enabled) {
|
||||
// Remove explicit row — default is enabled
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update preferences from the matrix UI.
|
||||
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||
*/
|
||||
export function setPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
db.transaction(() => applyUserChannelPrefs(userId, prefs, upsert, del))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update admin notification preferences.
|
||||
* email/webhook channels are stored globally in app_settings (not per-user).
|
||||
* inapp channel remains per-user in notification_channel_preferences.
|
||||
*/
|
||||
export function setAdminPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
|
||||
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
// Split global (email/webhook) from per-user (inapp) prefs
|
||||
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
|
||||
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
|
||||
globalPrefs[eventType]![channel] = enabled;
|
||||
} else {
|
||||
if (!userPrefs[eventType]) userPrefs[eventType] = {};
|
||||
userPrefs[eventType]![channel] = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply global prefs outside the transaction (they write to app_settings)
|
||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-user (inapp) prefs in a transaction
|
||||
db.transaction(() => applyUserChannelPrefs(userId, userPrefs, upsert, del))();
|
||||
}
|
||||
|
||||
// ── SMTP availability helper (for authService) ─────────────────────────────
|
||||
|
||||
export function isSmtpConfigured(): boolean {
|
||||
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
}
|
||||
|
||||
export function isWebhookConfigured(): boolean {
|
||||
return getActiveChannels().includes('webhook');
|
||||
}
|
||||
|
||||
285
server/src/services/notificationService.ts
Normal file
285
server/src/services/notificationService.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { db } from '../db/database';
|
||||
import { logDebug, logError } from './auditLog';
|
||||
import {
|
||||
getActiveChannels,
|
||||
isEnabledForEvent,
|
||||
getAdminGlobalPref,
|
||||
isSmtpConfigured,
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
type NotifEventType,
|
||||
} from './notificationPreferencesService';
|
||||
import {
|
||||
getEventText,
|
||||
sendEmail,
|
||||
sendWebhook,
|
||||
getUserEmail,
|
||||
getUserLanguage,
|
||||
getUserWebhookUrl,
|
||||
getAdminWebhookUrl,
|
||||
getAppUrl,
|
||||
} from './notifications';
|
||||
import {
|
||||
resolveRecipients,
|
||||
createNotificationForRecipient,
|
||||
type NotificationInput,
|
||||
} from './inAppNotifications';
|
||||
|
||||
// ── Event config map ───────────────────────────────────────────────────────
|
||||
|
||||
interface EventNotifConfig {
|
||||
inAppType: 'simple' | 'navigate';
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
navigateTextKey?: string;
|
||||
navigateTarget: (params: Record<string, string>) => string | null;
|
||||
}
|
||||
|
||||
const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
// ── Dev-only test events ──────────────────────────────────────────────────
|
||||
test_simple: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.simple.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_boolean: {
|
||||
inAppType: 'simple', // overridden by inApp.type at call site
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.boolean.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_navigate: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.navigate.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/dashboard',
|
||||
},
|
||||
// ── Production events ─────────────────────────────────────────────────────
|
||||
trip_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_invite.title',
|
||||
textKey: 'notif.trip_invite.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
booking_change: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.booking_change.title',
|
||||
textKey: 'notif.booking_change.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
trip_reminder: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_reminder.title',
|
||||
textKey: 'notif.trip_reminder.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
vacay_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_invite.title',
|
||||
textKey: 'notif.vacay_invite.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
|
||||
},
|
||||
photos_shared: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.photos_shared.title',
|
||||
textKey: 'notif.photos_shared.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
collab_message: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collab_message.title',
|
||||
textKey: 'notif.collab_message.text',
|
||||
navigateTextKey: 'notif.action.view_collab',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
packing_tagged: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.packing_tagged.title',
|
||||
textKey: 'notif.packing_tagged.text',
|
||||
navigateTextKey: 'notif.action.view_packing',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
version_available: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.version_available.title',
|
||||
textKey: 'notif.version_available.text',
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
const FALLBACK_EVENT_CONFIG: EventNotifConfig = {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.generic.title',
|
||||
textKey: 'notif.generic.text',
|
||||
navigateTarget: () => null,
|
||||
};
|
||||
|
||||
// ── Unified send() API ─────────────────────────────────────────────────────
|
||||
|
||||
export interface NotificationPayload {
|
||||
event: NotifEventType;
|
||||
actorId: number | null;
|
||||
params: Record<string, string>;
|
||||
scope: 'trip' | 'user' | 'admin';
|
||||
targetId: number; // tripId for trip scope, userId for user scope, 0 for admin
|
||||
/** Optional in-app overrides (e.g. boolean type with callbacks) */
|
||||
inApp?: {
|
||||
type?: 'simple' | 'boolean' | 'navigate';
|
||||
positiveTextKey?: string;
|
||||
negativeTextKey?: string;
|
||||
positiveCallback?: { action: string; payload: Record<string, unknown> };
|
||||
negativeCallback?: { action: string; payload: Record<string, unknown> };
|
||||
navigateTarget?: string; // override the auto-generated navigate target
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(payload: NotificationPayload): Promise<void> {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
// Resolve recipients based on scope
|
||||
const recipients = resolveRecipients(scope, targetId, actorId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
if (!configEntry) {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (process.env.NODE_ENV === 'development' && actorId != null) {
|
||||
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||
createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: actorId,
|
||||
sender_id: null,
|
||||
title_key: 'notif.dev.unknown_event.title',
|
||||
text_key: 'notif.dev.unknown_event.text',
|
||||
text_params: { event },
|
||||
}, actorId, devSender);
|
||||
}
|
||||
}
|
||||
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||
const activeChannels = getActiveChannels();
|
||||
const appUrl = getAppUrl();
|
||||
|
||||
// Build navigate target (used by email/webhook CTA and in-app navigate)
|
||||
const navigateTarget = inApp?.navigateTarget ?? config.navigateTarget(params);
|
||||
const fullLink = navigateTarget ? `${appUrl}${navigateTarget}` : undefined;
|
||||
|
||||
// Fetch sender info once for in-app WS payloads
|
||||
const sender = actorId
|
||||
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null
|
||||
: null;
|
||||
|
||||
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
|
||||
|
||||
// Dispatch to each recipient in parallel
|
||||
await Promise.all(recipients.map(async (recipientId) => {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
if (inAppType === 'boolean' && inApp?.positiveCallback && inApp?.negativeCallback) {
|
||||
notifInput = {
|
||||
type: 'boolean',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
positive_text_key: inApp.positiveTextKey ?? 'notif.action.accept',
|
||||
negative_text_key: inApp.negativeTextKey ?? 'notif.action.decline',
|
||||
positive_callback: inApp.positiveCallback,
|
||||
negative_callback: inApp.negativeCallback,
|
||||
};
|
||||
} else if (inAppType === 'navigate' && navigateTarget) {
|
||||
notifInput = {
|
||||
type: 'navigate',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
navigate_text_key: config.navigateTextKey ?? 'notif.action.view',
|
||||
navigate_target: navigateTarget,
|
||||
};
|
||||
} else {
|
||||
notifInput = {
|
||||
type: 'simple',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
};
|
||||
}
|
||||
|
||||
promises.push(
|
||||
Promise.resolve().then(() => createNotificationForRecipient(notifInput, recipientId, sender ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email ────────────────────────────────────────────────────────────
|
||||
// Admin-scoped events: use global pref + SMTP check (bypass notification_channels toggle)
|
||||
// Regular events: use active channels + per-user pref
|
||||
const emailEnabled = ADMIN_SCOPED_EVENTS.has(event)
|
||||
? isSmtpConfigured() && getAdminGlobalPref(event, 'email')
|
||||
: activeChannels.includes('email') && isEnabledForEvent(recipientId, event, 'email');
|
||||
|
||||
if (emailEnabled) {
|
||||
const email = getUserEmail(recipientId);
|
||||
if (email) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
promises.push(sendEmail(email, title, body, recipientId, navigateTarget ?? undefined));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Webhook (per-user) — skip for admin-scoped events (handled globally below) ──
|
||||
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('webhook') && isEnabledForEvent(recipientId, event, 'webhook')) {
|
||||
const webhookUrl = getUserWebhookUrl(recipientId);
|
||||
if (webhookUrl) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
promises.push(sendWebhook(webhookUrl, { event, title, body, tripName: params.trip, link: fullLink }));
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logError(`notificationService.send channel dispatch failed event=${event} recipient=${recipientId}: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Admin webhook (scope: admin) — global, respects global pref ──────
|
||||
if (scope === 'admin' && getAdminGlobalPref(event, 'webhook')) {
|
||||
const adminWebhookUrl = getAdminWebhookUrl();
|
||||
if (adminWebhookUrl) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
await sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch((err: unknown) => {
|
||||
logError(`notificationService.send admin webhook failed event=${event}: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,11 @@ import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { logInfo, logDebug, logError } from './auditLog';
|
||||
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
|
||||
|
||||
interface NotificationPayload {
|
||||
userId: number;
|
||||
event: EventType;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
import type { NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
@@ -23,6 +18,17 @@ interface SmtpConfig {
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
// ── HTML escaping ──────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Settings helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
@@ -39,11 +45,8 @@ function getSmtpConfig(): SmtpConfig | null {
|
||||
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
|
||||
}
|
||||
|
||||
function getWebhookUrl(): string | null {
|
||||
return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
|
||||
}
|
||||
|
||||
function getAppUrl(): string {
|
||||
// Exported for use by notificationService
|
||||
export function getAppUrl(): string {
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
const origins = process.env.ALLOWED_ORIGINS;
|
||||
if (origins) {
|
||||
@@ -54,31 +57,23 @@ function getAppUrl(): string {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
function getUserEmail(userId: number): string | null {
|
||||
export function getUserEmail(userId: number): string | null {
|
||||
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
|
||||
}
|
||||
|
||||
function getUserLanguage(userId: number): string {
|
||||
export function getUserLanguage(userId: number): string {
|
||||
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
||||
}
|
||||
|
||||
function getAdminEventEnabled(event: EventType): boolean {
|
||||
const prefKey = EVENT_PREF_MAP[event];
|
||||
if (!prefKey) return true;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(prefKey) as { value: string } | undefined;
|
||||
return !row || row.value !== 'false';
|
||||
export function getUserWebhookUrl(userId: number): string | null {
|
||||
const value = (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
// Event → preference column mapping
|
||||
const EVENT_PREF_MAP: Record<EventType, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
export function getAdminWebhookUrl(): string | null {
|
||||
const value = getAppSetting('admin_webhook_url') || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
// ── Email i18n strings ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -99,7 +94,7 @@ const I18N: Record<string, EmailStrings> = {
|
||||
interface EventText { title: string; body: string }
|
||||
type EventTextFn = (params: Record<string, string>) => EventText
|
||||
|
||||
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
en: {
|
||||
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}".` }),
|
||||
@@ -108,6 +103,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||
@@ -117,6 +113,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
|
||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||
@@ -126,6 +123,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||
@@ -135,6 +133,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||
@@ -144,6 +143,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||
@@ -153,6 +153,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||
@@ -162,6 +163,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
|
||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||
@@ -171,21 +173,76 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
|
||||
},
|
||||
br: {
|
||||
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }),
|
||||
vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
|
||||
},
|
||||
cs: {
|
||||
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }),
|
||||
vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }),
|
||||
photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
|
||||
},
|
||||
hu: {
|
||||
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
|
||||
booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }),
|
||||
trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }),
|
||||
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
|
||||
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
|
||||
},
|
||||
it: {
|
||||
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }),
|
||||
vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }),
|
||||
photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
|
||||
},
|
||||
pl: {
|
||||
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }),
|
||||
vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }),
|
||||
photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
|
||||
},
|
||||
};
|
||||
|
||||
// Get localized event text
|
||||
export function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
||||
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): EventText {
|
||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||
return texts[event](params);
|
||||
const fn = texts[event] ?? EVENT_TEXTS.en[event];
|
||||
if (!fn) return { title: event, body: '' };
|
||||
return fn(params);
|
||||
}
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string): string {
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = appUrl || '#';
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -202,9 +259,9 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str
|
||||
</td></tr>
|
||||
<!-- Content -->
|
||||
<tr><td style="padding: 32px 32px 16px;">
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
|
||||
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
|
||||
</td></tr>
|
||||
<!-- CTA -->
|
||||
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
@@ -224,7 +281,7 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str
|
||||
|
||||
// ── Send functions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
|
||||
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
|
||||
const config = getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
@@ -245,7 +302,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
||||
to,
|
||||
subject: `TREK — ${subject}`,
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang),
|
||||
html: buildEmailHtml(subject, body, lang, navigateTarget),
|
||||
});
|
||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||
@@ -256,7 +313,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
@@ -265,6 +322,7 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
||||
embeds: [{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
url: payload.link,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -274,24 +332,32 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
||||
|
||||
if (isSlack) {
|
||||
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
||||
const link = payload.link ? `\n<${payload.link}|Open in TREK>` : '';
|
||||
return JSON.stringify({
|
||||
text: `*${payload.title}*\n${payload.body}${trip}`,
|
||||
text: `*${payload.title}*\n${payload.body}${trip}${link}`,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||
}
|
||||
|
||||
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
|
||||
const url = getWebhookUrl();
|
||||
export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = createPinnedAgent(ssrf.resolvedIp!, new URL(url).protocol);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: buildWebhookBody(url, payload),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
agent,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -309,56 +375,6 @@ async function sendWebhook(payload: { event: string; title: string; body: string
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getNotificationChannel(): string {
|
||||
return getAppSetting('notification_channel') || 'none';
|
||||
}
|
||||
|
||||
export async function notify(payload: NotificationPayload): Promise<void> {
|
||||
const channel = getNotificationChannel();
|
||||
if (channel === 'none') return;
|
||||
|
||||
if (!getAdminEventEnabled(payload.event)) return;
|
||||
|
||||
const lang = getUserLanguage(payload.userId);
|
||||
const { title, body } = getEventText(lang, payload.event, payload.params);
|
||||
|
||||
logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`);
|
||||
|
||||
if (channel === 'email') {
|
||||
const email = getUserEmail(payload.userId);
|
||||
if (email) await sendEmail(email, title, body, payload.userId);
|
||||
} else if (channel === 'webhook') {
|
||||
await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<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;
|
||||
if (!trip) return;
|
||||
|
||||
if (channel === 'webhook') {
|
||||
const lang = getUserLanguage(actorUserId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
logDebug(`notifyTripMembers event=${event} channel=webhook tripId=${tripId} actor=${actorUserId}`);
|
||||
await sendWebhook({ event, title, body, tripName: params.trip });
|
||||
return;
|
||||
}
|
||||
|
||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
|
||||
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
|
||||
const unique = [...new Set(allIds)];
|
||||
|
||||
for (const userId of unique) {
|
||||
await notify({ userId, event, params });
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
||||
@@ -368,11 +384,12 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
|
||||
}
|
||||
}
|
||||
|
||||
export async function testWebhook(): Promise<{ success: boolean; error?: string }> {
|
||||
export async function testWebhook(url: string): 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' };
|
||||
const sent = await sendWebhook(url, { 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: 'Failed to send webhook' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { db } from '../db/database';
|
||||
import { maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']);
|
||||
|
||||
export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
|
||||
settings[row.key] = row.value ? '••••••••' : '';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
settings[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
@@ -13,12 +20,17 @@ export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
return settings;
|
||||
}
|
||||
|
||||
function serializeValue(key: string, value: unknown): string {
|
||||
const raw = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
if (ENCRYPTED_SETTING_KEYS.has(key)) return maybe_encrypt_api_key(raw) ?? raw;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function upsertSetting(userId: number, key: string, value: unknown) {
|
||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
db.prepare(`
|
||||
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
|
||||
`).run(userId, key, serialized);
|
||||
`).run(userId, key, serializeValue(key, value));
|
||||
}
|
||||
|
||||
export function bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
|
||||
@@ -29,8 +41,7 @@ export function bulkUpsertSettings(userId: number, settings: Record<string, unkn
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
upsert.run(userId, key, serialized);
|
||||
upsert.run(userId, key, serializeValue(key, value));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
@@ -342,8 +342,8 @@ export function sendInvite(planId: number, inviterId: number, inviterUsername: s
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: targetUserId, event: 'vacay_invite', params: { actor: inviterEmail } }).catch(() => {});
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
|
||||
});
|
||||
|
||||
return {};
|
||||
|
||||
@@ -112,10 +112,15 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
*/
|
||||
export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent {
|
||||
const options = {
|
||||
lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, addr: string, family: number) => void) => {
|
||||
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
|
||||
// Determine address family from IP format
|
||||
const family = resolvedIp.includes(':') ? 6 : 4;
|
||||
callback(null, resolvedIp, family);
|
||||
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
|
||||
if (opts && opts.all) {
|
||||
callback(null, [{ address: resolvedIp, family }]);
|
||||
} else {
|
||||
callback(null, resolvedIp, family);
|
||||
}
|
||||
},
|
||||
};
|
||||
return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options);
|
||||
|
||||
@@ -480,3 +480,29 @@ export function createInviteToken(
|
||||
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
||||
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Upsert a key/value pair into app_settings. */
|
||||
export function setAppSetting(db: Database.Database, key: string, value: string): void {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
/** Set the active notification channels (e.g. 'email', 'webhook', 'email,webhook', 'none'). */
|
||||
export function setNotificationChannels(db: Database.Database, channels: string): void {
|
||||
setAppSetting(db, 'notification_channels', channels);
|
||||
}
|
||||
|
||||
/** Explicitly disable a per-user notification preference for a given event+channel combo. */
|
||||
export function disableNotificationPref(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
eventType: string,
|
||||
channel: string
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
|
||||
).run(userId, eventType, channel);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const RESET_TABLES = [
|
||||
'vacay_plans',
|
||||
'atlas_visited_countries',
|
||||
'atlas_bucket_list',
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
'user_settings',
|
||||
|
||||
@@ -39,12 +39,13 @@ vi.mock('../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -154,6 +155,137 @@ describe('In-app notifications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// New preferences matrix API (NROUTE series)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/notifications/preferences — matrix format', () => {
|
||||
it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('preferences');
|
||||
expect(res.body).toHaveProperty('available_channels');
|
||||
expect(res.body).toHaveProperty('event_types');
|
||||
expect(res.body).toHaveProperty('implemented_combos');
|
||||
expect(res.body.available_channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NROUTE-003 — regular user does not see version_available in event_types', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).not.toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-004 — user preferences endpoint excludes version_available even for admins', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).not.toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-004b — admin notification preferences endpoint returns version_available', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-005 — all preferences default to true for new user with no stored prefs', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const { preferences } = res.body;
|
||||
for (const [, channels] of Object.entries(preferences)) {
|
||||
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
|
||||
expect(enabled).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
it('NROUTE-007 — disabling a preference persists and is reflected in subsequent GET', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const putRes = await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: false } });
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
});
|
||||
|
||||
it('NROUTE-008 — re-enabling a preference restores default state', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: true } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.preferences['trip_invite']['email']).toBe(true);
|
||||
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NROUTE-009 — partial update does not affect other preferences', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'booking_change', 'email');
|
||||
|
||||
await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: false } });
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.preferences['booking_change']['email']).toBe(false);
|
||||
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
expect(getRes.body.preferences['trip_reminder']['email']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('implemented_combos — in-app channel coverage', () => {
|
||||
it('NROUTE-010 — implemented_combos includes inapp for all event types', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const { implemented_combos } = res.body as { implemented_combos: Record<string, string[]> };
|
||||
const eventTypes = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||
for (const event of eventTypes) {
|
||||
expect(implemented_combos[event], `${event} should support inapp`).toContain('inapp');
|
||||
expect(implemented_combos[event], `${event} should support email`).toContain('email');
|
||||
expect(implemented_combos[event], `${event} should support webhook`).toContain('webhook');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification test endpoints', () => {
|
||||
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
@@ -165,13 +297,244 @@ describe('Notification test endpoints', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
|
||||
it('NOTIF-006 — POST /api/notifications/test-webhook returns 400 when url is missing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NOTIF-006b — POST /api/notifications/test-webhook returns 400 for invalid URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'not-a-url' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper: insert a boolean notification directly into the DB
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /in-app/:id/respond
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/notifications/in-app/:id/respond', () => {
|
||||
it('NROUTE-011 — valid positive response returns success and updated notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'positive' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.notification).toBeDefined();
|
||||
expect(res.body.notification.response).toBe('positive');
|
||||
});
|
||||
|
||||
it('NROUTE-012 — invalid response value returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'maybe' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NROUTE-013 — response on non-existent notification returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/in-app/99999/respond')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'positive' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NROUTE-014 — double response returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'positive' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'negative' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/already responded/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PUT /api/admin/notification-preferences
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/admin/notification-preferences', () => {
|
||||
it('NROUTE-015 — admin can disable email for version_available, persists in GET', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
|
||||
const putRes = await request(app)
|
||||
.put('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ version_available: { email: false } });
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.preferences['version_available']['email']).toBe(false);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.preferences['version_available']['email']).toBe(false);
|
||||
});
|
||||
|
||||
it('NROUTE-016 — non-admin is rejected with 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ version_available: { email: false } });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-app CRUD with actual notification data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('In-app notifications — CRUD with data', () => {
|
||||
it('NROUTE-017 — GET /in-app returns created notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.length).toBe(2);
|
||||
expect(res.body.total).toBe(2);
|
||||
expect(res.body.unread_count).toBe(2);
|
||||
});
|
||||
|
||||
it('NROUTE-018 — unread count reflects actual unread notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
});
|
||||
|
||||
it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const markRes = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/read`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(markRes.status).toBe(200);
|
||||
expect(markRes.body.success).toBe(true);
|
||||
|
||||
const countRes = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(countRes.body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('NROUTE-020 — mark-unread on a read notification succeeds', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
// Mark read first
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/unread`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number };
|
||||
expect(row.is_read).toBe(0);
|
||||
});
|
||||
|
||||
it('NROUTE-021 — DELETE on existing notification removes it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/notifications/in-app/${id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id1 = insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
// Mark first one read
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app?unread_only=true')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.length).toBe(1);
|
||||
expect(res.body.notifications[0].is_read).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Unit tests for in-app notification preference filtering in createNotification().
|
||||
* Covers INOTIF-001 to INOTIF-004.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available
|
||||
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
|
||||
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createNotification — preference filtering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createNotification — preference filtering', () => {
|
||||
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
// The admin scope targets all admins — create a second admin as the sender
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
|
||||
// Send to a specific user (user scope) without event_type
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
// no event_type
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
// Also verify the admin who disabled all prefs still gets messages without event_type
|
||||
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
|
||||
// admin still gets this since no event_type check
|
||||
const adminIds = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: admin.id,
|
||||
sender_id: sender.id,
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
expect(adminIds.length).toBe(1);
|
||||
});
|
||||
|
||||
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient1 } = createUser(testDb);
|
||||
const { user: recipient2 } = createUser(testDb);
|
||||
|
||||
// recipient2 has disabled inapp for trip_invite
|
||||
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
|
||||
|
||||
// Use a trip to target both members
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
|
||||
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: tripId,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
|
||||
// sender excluded, recipient1 included, recipient2 skipped (disabled pref)
|
||||
expect(ids.length).toBe(1);
|
||||
const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
|
||||
const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
|
||||
expect(r1).toBeDefined();
|
||||
expect(r2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
// No preferences stored for recipient — should default to enabled
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
const id = createNotificationForRecipient(
|
||||
{
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notif.trip_invite.title',
|
||||
text_key: 'notif.trip_invite.text',
|
||||
navigate_text_key: 'notif.action.view_trip',
|
||||
navigate_target: '/trips/99',
|
||||
},
|
||||
recipient.id,
|
||||
{ username: 'admin', avatar: null }
|
||||
);
|
||||
|
||||
expect(id).toBeTypeOf('number');
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.recipient_id).toBe(recipient.id);
|
||||
expect(row!.navigate_target).toBe('/trips/99');
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
|
||||
});
|
||||
|
||||
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
|
||||
// admin2 disables version_available inapp notifications
|
||||
disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');
|
||||
|
||||
const ids = createNotification({
|
||||
type: 'navigate',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
sender_id: null,
|
||||
event_type: 'version_available',
|
||||
title_key: 'notifications.versionAvailable.title',
|
||||
text_key: 'notifications.versionAvailable.text',
|
||||
navigate_text_key: 'notifications.versionAvailable.button',
|
||||
navigate_target: '/admin',
|
||||
});
|
||||
|
||||
// Only admin1 should receive it
|
||||
expect(ids.length).toBe(1);
|
||||
const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
|
||||
const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
|
||||
expect(admin1Row).toBeDefined();
|
||||
expect(admin2Row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// respondToBoolean
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, senderId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('respondToBoolean', () => {
|
||||
it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.notification).toBeDefined();
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('positive');
|
||||
expect(row.is_read).toBe(1);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
|
||||
});
|
||||
|
||||
it('INOTIF-006 — negative response sets response=negative', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('negative');
|
||||
});
|
||||
|
||||
it('INOTIF-007 — double-response prevention returns error on second call', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
await respondToBoolean(id, user.id, 'positive');
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/already responded/i);
|
||||
});
|
||||
|
||||
it('INOTIF-008 — response on a simple notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not a boolean/i);
|
||||
});
|
||||
|
||||
it('INOTIF-009 — response on a non-existent notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = await respondToBoolean(99999, user.id, 'positive');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('INOTIF-010 — response on notification belonging to another user returns error', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const id = insertBooleanNotification(owner.id);
|
||||
|
||||
const result = await respondToBoolean(id, other.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
234
server/tests/unit/services/migration.test.ts
Normal file
234
server/tests/unit/services/migration.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Unit tests for migration 69 (normalized notification preferences).
|
||||
* Covers MIGR-001 to MIGR-004.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
function buildFreshDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all migrations up to (but NOT including) migration 69, then return the db.
|
||||
* This allows us to set up old-schema data and test that migration 69 handles it.
|
||||
*
|
||||
* We do this by running only the schema tables that existed before migration 69,
|
||||
* seeding old data, then running migration 69 in isolation.
|
||||
*/
|
||||
function setupPreMigration69Db() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create schema_version and users table (bare minimum)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT INTO schema_version (version) VALUES (0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_trip_invite INTEGER DEFAULT 1,
|
||||
notify_booking_change INTEGER DEFAULT 1,
|
||||
notify_trip_reminder INTEGER DEFAULT 1,
|
||||
notify_vacay_invite INTEGER DEFAULT 1,
|
||||
notify_photos_shared INTEGER DEFAULT 1,
|
||||
notify_collab_message INTEGER DEFAULT 1,
|
||||
notify_packing_tagged INTEGER DEFAULT 1,
|
||||
notify_webhook INTEGER DEFAULT 1,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and run only migration 69 (index 68) from the migrations array.
|
||||
* We do this by importing migrations and calling the last one directly.
|
||||
*/
|
||||
function runMigration69(db: ReturnType<typeof Database>): void {
|
||||
// Migration 69 logic extracted inline for isolation
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
|
||||
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
|
||||
const eventCols: Record<string, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||
for (const [userId, eventType, channel, enabled] of rows) {
|
||||
insert.run(userId, eventType, channel, enabled);
|
||||
}
|
||||
});
|
||||
|
||||
for (const row of oldPrefs) {
|
||||
const userId = row.user_id as number;
|
||||
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||
const rows: Array<[number, string, string, number]> = [];
|
||||
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||
const emailEnabled = (row[col] as number) ?? 1;
|
||||
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||
}
|
||||
if (rows.length > 0) insertMany(rows);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO app_settings (key, value)
|
||||
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||
`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Migration 69 tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Migration 69 — normalized notification_channel_preferences', () => {
|
||||
it('MIGR-001 — notification_channel_preferences table exists after migration', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
runMigration69(db);
|
||||
|
||||
const table = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
|
||||
).get();
|
||||
expect(table).toBeDefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-002 — old notification_preferences rows with disabled events migrated as enabled=0', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Create a user
|
||||
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
|
||||
|
||||
// Simulate user who has disabled trip_invite and booking_change email
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences
|
||||
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
|
||||
`).run(userId);
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const tripInviteEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||
const bookingEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
|
||||
const reminderEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
|
||||
|
||||
// Disabled events should have enabled=0 rows
|
||||
expect(tripInviteEmail).toBeDefined();
|
||||
expect(tripInviteEmail!.enabled).toBe(0);
|
||||
expect(bookingEmail).toBeDefined();
|
||||
expect(bookingEmail!.enabled).toBe(0);
|
||||
// Enabled events should have no row (no-row = enabled)
|
||||
expect(reminderEmail).toBeUndefined();
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
|
||||
|
||||
// User has all email enabled but webhook disabled
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences
|
||||
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
|
||||
`).run(userId);
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||
for (const eventType of allEvents) {
|
||||
const row = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
|
||||
// Email rows should NOT exist (all email was enabled → no row needed)
|
||||
const emailRow = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, 'email');
|
||||
expect(emailRow).toBeUndefined();
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-004 — notification_channels key is created in app_settings from notification_channel value', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Simulate existing single-channel setting
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||
expect(plural).toBeDefined();
|
||||
expect(plural!.value).toBe('email');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-004b — notification_channels is not duplicated if already exists', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Both keys already set (e.g. partial migration or manual edit)
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook');
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
|
||||
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||
expect(plural!.value).toBe('email,webhook');
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Unit tests for notificationPreferencesService.
|
||||
* Covers NPREF-001 to NPREF-021.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: (v: string | null) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||
import {
|
||||
isEnabledForEvent,
|
||||
getPreferencesMatrix,
|
||||
setPreferences,
|
||||
setAdminPreferences,
|
||||
getAdminGlobalPref,
|
||||
getActiveChannels,
|
||||
getAvailableChannels,
|
||||
} from '../../../src/services/notificationPreferencesService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// isEnabledForEvent
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isEnabledForEvent', () => {
|
||||
it('NPREF-001 — returns true when no row exists (default enabled)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-002 — returns true when row exists with enabled=1', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
|
||||
).run(user.id, 'trip_invite', 'email');
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-003 — returns false when row exists with enabled=0', () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getPreferencesMatrix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getPreferencesMatrix', () => {
|
||||
it('NPREF-004 — regular user does not see version_available in event_types', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(event_types).not.toContain('version_available');
|
||||
expect(event_types.length).toBe(7);
|
||||
});
|
||||
|
||||
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
|
||||
expect(event_types).not.toContain('version_available');
|
||||
expect(event_types.length).toBe(7);
|
||||
});
|
||||
|
||||
it('NPREF-005b — admin scope returns only version_available', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||
expect(event_types).toContain('version_available');
|
||||
expect(event_types.length).toBe(1);
|
||||
});
|
||||
|
||||
it('NPREF-006 — returns default true for all preferences when no stored prefs', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { preferences } = getPreferencesMatrix(user.id, 'user');
|
||||
for (const [, channels] of Object.entries(preferences)) {
|
||||
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
|
||||
expect(enabled).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('NPREF-007 — reflects stored disabled preferences in the matrix', () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
|
||||
const { preferences } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(preferences['trip_invite']!['email']).toBe(false);
|
||||
expect(preferences['collab_message']!['webhook']).toBe(false);
|
||||
// Others unaffected
|
||||
expect(preferences['trip_invite']!['webhook']).toBe(true);
|
||||
expect(preferences['booking_change']!['email']).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-008 — available_channels.inapp is always true', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email');
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.email).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// No notification_channels set → defaults to none
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.email).toBe(false);
|
||||
});
|
||||
|
||||
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
|
||||
// All events now support all three channels
|
||||
expect(implemented_combos['trip_invite']).toContain('inapp');
|
||||
expect(implemented_combos['trip_invite']).toContain('email');
|
||||
expect(implemented_combos['trip_invite']).toContain('webhook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// setPreferences
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setPreferences', () => {
|
||||
it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setPreferences(user.id, { trip_invite: { email: false } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
});
|
||||
|
||||
it('NPREF-013 — re-enabling a preference removes the disabled row', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// First disable
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
// Then re-enable
|
||||
setPreferences(user.id, { trip_invite: { email: true } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email');
|
||||
// Row should be deleted — default is enabled
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-014 — bulk update handles multiple event+channel combos', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setPreferences(user.id, {
|
||||
trip_invite: { email: false, webhook: false },
|
||||
booking_change: { email: false },
|
||||
trip_reminder: { webhook: true },
|
||||
});
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
|
||||
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
||||
// trip_reminder webhook was set to true → no row, default enabled
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_reminder', 'webhook');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getActiveChannels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getActiveChannels', () => {
|
||||
it('NPREF-015 — returns [] when notification_channels is none', () => {
|
||||
setAppSetting(testDb, 'notification_channels', 'none');
|
||||
expect(getActiveChannels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('NPREF-016 — returns [email] when notification_channels is email', () => {
|
||||
setAppSetting(testDb, 'notification_channels', 'email');
|
||||
expect(getActiveChannels()).toEqual(['email']);
|
||||
});
|
||||
|
||||
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => {
|
||||
setAppSetting(testDb, 'notification_channels', 'email,webhook');
|
||||
expect(getActiveChannels()).toEqual(['email', 'webhook']);
|
||||
});
|
||||
|
||||
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => {
|
||||
// Only set the singular key
|
||||
setAppSetting(testDb, 'notification_channel', 'webhook');
|
||||
// No notification_channels key
|
||||
expect(getActiveChannels()).toEqual(['webhook']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getAvailableChannels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAvailableChannels', () => {
|
||||
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
|
||||
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.email).toBe(true);
|
||||
expect(channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => {
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.webhook).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
|
||||
const original = process.env.SMTP_HOST;
|
||||
process.env.SMTP_HOST = 'env-mail.example.com';
|
||||
try {
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.email).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.SMTP_HOST;
|
||||
else process.env.SMTP_HOST = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// setAdminPreferences
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setAdminPreferences', () => {
|
||||
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||
expect(row?.value).toBe('0');
|
||||
});
|
||||
|
||||
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
setAdminPreferences(user.id, { version_available: { inapp: false } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
// Global app_settings should NOT have an inapp key
|
||||
const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp');
|
||||
expect(globalRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
// First disable
|
||||
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
|
||||
// Then re-enable via setAdminPreferences
|
||||
setAdminPreferences(user.id, { version_available: { inapp: true } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'version_available', 'inapp');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
// First disable, then re-enable
|
||||
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
setAdminPreferences(user.id, { version_available: { email: true } });
|
||||
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||
expect(row?.value).toBe('1');
|
||||
});
|
||||
});
|
||||
459
server/tests/unit/services/notificationService.test.ts
Normal file
459
server/tests/unit/services/notificationService.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Unit tests for the unified notificationService.send().
|
||||
* Covers NSVC-001 to NSVC-014.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: (v: string | null) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({
|
||||
sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
|
||||
fetchMock: vi.fn(),
|
||||
broadcastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: sendMailMock,
|
||||
verify: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node-fetch', () => ({ default: fetchMock }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedAgent: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||
import { send } from '../../../src/services/notificationService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function setSmtp(): void {
|
||||
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
|
||||
setAppSetting(testDb, 'smtp_port', '587');
|
||||
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
|
||||
}
|
||||
|
||||
function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void {
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url);
|
||||
}
|
||||
|
||||
function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
|
||||
setAppSetting(testDb, 'admin_webhook_url', url);
|
||||
}
|
||||
|
||||
function getInAppNotifications(recipientId: number) {
|
||||
return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string | null;
|
||||
navigate_text_key: string | null;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function countAllNotifications(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
}
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
sendMailMock.mockClear();
|
||||
fetchMock.mockClear();
|
||||
broadcastMock.mockClear();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Multi-channel dispatch
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — multi-channel dispatch', () => {
|
||||
it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-003 — sends only email when only email channel is active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-user preference filtering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — per-user preference filtering', () => {
|
||||
it('NSVC-004 — skips email for a user who disabled trip_invite on email channel', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
// in-app still fires
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('NSVC-005 — skips in-app for a user who disabled the event on inapp channel', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });
|
||||
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
expect(countAllNotifications()).toBe(0);
|
||||
});
|
||||
|
||||
it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recipient resolution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — recipient resolution', () => {
|
||||
it('NSVC-007 — trip scope sends to owner + members, excludes actorId', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member1 } = createUser(testDb);
|
||||
const { user: member2 } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);
|
||||
|
||||
await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
// Owner, member1, member2 get it; actor is excluded
|
||||
expect(countAllNotifications()).toBe(3);
|
||||
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
expect(recipients).toContain(owner.id);
|
||||
expect(recipients).toContain(member1.id);
|
||||
expect(recipients).toContain(member2.id);
|
||||
expect(recipients).not.toContain(actor.id);
|
||||
});
|
||||
|
||||
it('NSVC-008 — user scope sends to exactly one user', async () => {
|
||||
const { user: target } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
|
||||
expect(notif.recipient_id).toBe(target.id);
|
||||
});
|
||||
|
||||
it('NSVC-009 — admin scope sends to all admins (not regular users)', async () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
createUser(testDb); // regular user — should NOT receive
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(2);
|
||||
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
expect(recipients).toContain(admin1.id);
|
||||
expect(recipients).toContain(admin2.id);
|
||||
});
|
||||
|
||||
it('NSVC-010 — admin scope fires admin webhook URL when set', async () => {
|
||||
createAdmin(testDb);
|
||||
setAdminWebhookUrl();
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||
|
||||
// Wait for fire-and-forget admin webhook
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = fetchMock.mock.calls[0][0];
|
||||
expect(callUrl).toBe('https://hooks.test.com/admin-webhook');
|
||||
});
|
||||
|
||||
it('NSVC-011 — does nothing when there are no recipients', async () => {
|
||||
// Trip with no members, sending as the trip owner (actor excluded from trip scope)
|
||||
const { user: owner } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
expect(countAllNotifications()).toBe(0);
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-app notification content
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — in-app notification content', () => {
|
||||
it('NSVC-012 — creates navigate in-app notification with correct title/text/navigate keys', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].type).toBe('navigate');
|
||||
expect(notifs[0].title_key).toBe('notif.trip_invite.title');
|
||||
expect(notifs[0].text_key).toBe('notif.trip_invite.text');
|
||||
expect(notifs[0].navigate_text_key).toBe('notif.action.view_trip');
|
||||
expect(notifs[0].navigate_target).toBe('/trips/42');
|
||||
});
|
||||
|
||||
it('NSVC-013 — creates simple in-app notification when no navigate target is available', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
// vacay_invite without planId → no navigate target → simple type
|
||||
await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } });
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].type).toBe('simple');
|
||||
expect(notifs[0].navigate_target).toBeNull();
|
||||
});
|
||||
|
||||
it('NSVC-014 — navigate_target uses /admin for version_available event', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
|
||||
|
||||
const notifs = getInAppNotifications(admin.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].navigate_target).toBe('/admin');
|
||||
expect(notifs[0].title_key).toBe('notif.version_available.title');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Email/webhook link generation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — email/webhook links', () => {
|
||||
it('NSVC-015 — email subject and body are localized per recipient language', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
// Set user language to French
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||
// French title for trip_invite should contain "Invitation"
|
||||
expect(mailArgs.subject).toContain('Invitation');
|
||||
});
|
||||
|
||||
it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
// Generic webhook — link should contain /trips/55
|
||||
expect(body.link).toContain('/trips/55');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Boolean in-app type
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — boolean in-app type', () => {
|
||||
it('NSVC-017 — creates boolean in-app notification with callbacks when inApp.type override is boolean', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({
|
||||
event: 'trip_invite',
|
||||
actorId: null,
|
||||
scope: 'user',
|
||||
targetId: user.id,
|
||||
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' },
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveTextKey: 'notif.action.accept',
|
||||
negativeTextKey: 'notif.action.decline',
|
||||
positiveCallback: { action: 'test_approve', payload: { tripId: 1 } },
|
||||
negativeCallback: { action: 'test_deny', payload: { tripId: 1 } },
|
||||
},
|
||||
});
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
const row = notifs[0] as any;
|
||||
expect(row.type).toBe('boolean');
|
||||
expect(row.positive_callback).toContain('test_approve');
|
||||
expect(row.negative_callback).toContain('test_deny');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Channel failure resilience
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — channel failure resilience', () => {
|
||||
it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
// Make email throw
|
||||
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
// In-app and webhook still fire despite email failure
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
// Make webhook throw
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
// In-app and email still fire despite webhook failure
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
|
||||
@@ -18,7 +18,15 @@ vi.mock('../../../src/services/auditLog', () => ({
|
||||
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
|
||||
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
|
||||
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedAgent: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications';
|
||||
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||
import { logError } from '../../../src/services/auditLog';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -193,3 +201,119 @@ describe('buildEmailHtml', () => {
|
||||
expect(unknown).toContain('notifications enabled in TREK');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
|
||||
|
||||
describe('buildEmailHtml XSS prevention (SEC-016)', () => {
|
||||
it('escapes HTML special characters in subject', () => {
|
||||
const html = buildEmailHtml('<script>alert(1)</script>', 'Body', 'en');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes HTML special characters in body', () => {
|
||||
const html = buildEmailHtml('Subject', '<img src=x onerror=alert(1)>', 'en');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
});
|
||||
|
||||
it('escapes double quotes in subject to prevent attribute injection', () => {
|
||||
const html = buildEmailHtml('He said "hello"', 'Body', 'en');
|
||||
expect(html).toContain('"');
|
||||
expect(html).not.toContain('"hello"');
|
||||
});
|
||||
|
||||
it('escapes ampersands in body', () => {
|
||||
const html = buildEmailHtml('Subject', 'a & b', 'en');
|
||||
expect(html).toContain('&');
|
||||
expect(html).not.toMatch(/>[^<]*a & b[^<]*</);
|
||||
});
|
||||
|
||||
it('escapes user-controlled actor and preview in collab_message body', () => {
|
||||
const { body } = getEventText('en', 'collab_message', {
|
||||
trip: 'MyTrip',
|
||||
actor: '<evil>',
|
||||
preview: '<script>xss()</script>',
|
||||
});
|
||||
const html = buildEmailHtml('Subject', body, 'en');
|
||||
expect(html).not.toContain('<evil>');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<evil>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SEC: SSRF protection in sendWebhook ──────────────────────────────────────
|
||||
|
||||
describe('sendWebhook SSRF protection (SEC-017)', () => {
|
||||
const payload = { event: 'test', title: 'T', body: 'B' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(logError).mockClear();
|
||||
});
|
||||
|
||||
it('allows a public URL and calls fetch', async () => {
|
||||
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
||||
|
||||
const result = await sendWebhook('https://example.com/hook', payload);
|
||||
expect(result).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks loopback address and returns false', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('http://localhost/secret', payload);
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
||||
});
|
||||
|
||||
it('blocks cloud metadata endpoint (169.254.169.254) and returns false', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '169.254.169.254',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('http://169.254.169.254/latest/meta-data', payload);
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
||||
});
|
||||
|
||||
it('blocks private network addresses and returns false', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
|
||||
error: 'Requests to private/internal network addresses are not allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('http://192.168.1.1/hook', payload);
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
||||
});
|
||||
|
||||
it('blocks non-HTTP protocols', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: false,
|
||||
error: 'Only HTTP and HTTPS URLs are allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('file:///etc/passwd', payload);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call fetch when SSRF check blocks the URL', async () => {
|
||||
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockClear();
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
||||
error: 'blocked',
|
||||
});
|
||||
|
||||
await sendWebhook('http://localhost/secret', payload);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
173
server/tests/unit/services/versionNotification.test.ts
Normal file
173
server/tests/unit/services/versionNotification.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Unit tests for checkAndNotifyVersion() in adminService.
|
||||
* Covers VNOTIF-001 to VNOTIF-007.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
// Mock MCP to avoid session side-effects
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createAdmin } from '../../helpers/factories';
|
||||
import { checkAndNotifyVersion } from '../../../src/services/adminService';
|
||||
|
||||
// Helper: mock the GitHub releases/latest endpoint
|
||||
function mockGitHubLatest(tagName: string, ok = true): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
|
||||
}));
|
||||
}
|
||||
|
||||
function mockGitHubFetchFailure(): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
}
|
||||
|
||||
function getLastNotifiedVersion(): string | undefined {
|
||||
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
}
|
||||
|
||||
function getNotificationCount(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// checkAndNotifyVersion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('checkAndNotifyVersion', () => {
|
||||
it('VNOTIF-001 — does nothing when no update is available', async () => {
|
||||
createAdmin(testDb);
|
||||
// GitHub reports same version as package.json (or older) → update_available: false
|
||||
const { version } = require('../../../package.json');
|
||||
mockGitHubLatest(`v${version}`);
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
mockGitHubLatest('v99.0.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
|
||||
expect(notifications.length).toBe(2);
|
||||
const recipientIds = notifications.map(n => n.recipient_id);
|
||||
expect(recipientIds).toContain(admin1.id);
|
||||
expect(recipientIds).toContain(admin2.id);
|
||||
expect(notifications[0].type).toBe('navigate');
|
||||
expect(notifications[0].scope).toBe('admin');
|
||||
});
|
||||
|
||||
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.1.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getLastNotifiedVersion()).toBe('99.1.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.2.0');
|
||||
|
||||
// First call notifies
|
||||
await checkAndNotifyVersion();
|
||||
const countAfterFirst = getNotificationCount();
|
||||
expect(countAfterFirst).toBe(1);
|
||||
|
||||
// Second call with same version — should not create another
|
||||
await checkAndNotifyVersion();
|
||||
expect(getNotificationCount()).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
||||
createAdmin(testDb);
|
||||
// Simulate having been notified about an older version
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
|
||||
mockGitHubLatest('v99.3.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(1);
|
||||
expect(getLastNotifiedVersion()).toBe('99.3.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.4.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
navigate_text_key: string;
|
||||
};
|
||||
expect(notif.type).toBe('navigate');
|
||||
expect(notif.scope).toBe('admin');
|
||||
expect(notif.navigate_target).toBe('/admin');
|
||||
expect(notif.title_key).toBe('notif.version_available.title');
|
||||
expect(notif.text_key).toBe('notif.version_available.text');
|
||||
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
|
||||
});
|
||||
|
||||
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubFetchFailure();
|
||||
|
||||
// Should not throw
|
||||
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user