Merge branch 'dev' into test

This commit is contained in:
Marek Maslowski
2026-04-05 10:26:09 +02:00
63 changed files with 24436 additions and 18674 deletions

29
.gitattributes vendored Normal file
View 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

1
.gitignore vendored
View File

@@ -58,3 +58,4 @@ coverage
*.tgz
.scannerwork
test-data

View File

@@ -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 (

View File

@@ -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 = {

View File

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

View 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>
)
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -132,6 +132,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'الإعدادات',
'settings.subtitle': 'ضبط إعداداتك الشخصية',
'settings.tabs.display': 'العرض',
'settings.tabs.map': 'الخريطة',
'settings.tabs.notifications': 'الإشعارات',
'settings.tabs.integrations': 'التكاملات',
'settings.tabs.account': 'الحساب',
'settings.tabs.about': 'حول',
'settings.map': 'الخريطة',
'settings.mapTemplate': 'قالب الخريطة',
'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...',
@@ -513,8 +519,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
'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': 'القوائم',
'admin.addons.catalog.packing.description': 'قوائم التعبئة والمهام لرحلاتك',
'admin.addons.catalog.budget.name': 'الميزانية',
'admin.addons.catalog.budget.description': 'تتبع النفقات وخطط ميزانية الرحلة',
'admin.addons.catalog.documents.name': 'المستندات',
@@ -690,8 +696,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'إزالة',
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
'atlas.markVisited': 'تعيين كمُزار',
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.addPoi': 'إضافة مكان',
'atlas.searchCountry': 'ابحث عن دولة...',
@@ -741,8 +749,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'حجز',
'trip.tabs.packing': 'قائمة التجهيز',
'trip.tabs.packingShort': 'تجهيز',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'القوائم',
'trip.tabs.listsShort': 'القوائم',
'trip.tabs.budget': 'الميزانية',
'trip.tabs.files': 'الملفات',
'trip.loading': 'جارٍ تحميل الرحلة...',
@@ -938,11 +946,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'ربط بخطة اليوم',
'reservations.pickAssignment': 'اختر عنصرًا من خطتك...',
'reservations.noAssignment': 'بلا ربط',
'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': 'السعر',
'reservations.budgetCategory': 'فئة الميزانية',
'reservations.budgetCategoryPlaceholder': 'مثال: المواصلات، الإقامة',
'reservations.budgetCategoryAuto': 'تلقائي (حسب نوع الحجز)',
'reservations.budgetHint': 'سيتم إنشاء إدخال في الميزانية تلقائيًا عند الحفظ.',
'reservations.departureDate': 'المغادرة',
'reservations.arrivalDate': 'الوصول',
'reservations.departureTime': 'وقت المغادرة',
@@ -1574,38 +1582,105 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{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': 'قائمة الأمتعة',
'todo.subtab.todo': 'المهام',
'todo.completed': 'مكتمل',
'todo.filter.all': 'الكل',
'todo.filter.open': 'مفتوح',
'todo.filter.done': 'منجز',
'todo.uncategorized': 'بدون تصنيف',
'todo.namePlaceholder': 'اسم المهمة',
'todo.descriptionPlaceholder': 'وصف (اختياري)',
'todo.unassigned': 'غير مُسنَد',
'todo.noCategory': 'بدون فئة',
'todo.hasDescription': 'له وصف',
'todo.addItem': 'إضافة مهمة جديدة...',
'todo.newCategory': 'اسم الفئة',
'todo.addCategory': 'إضافة فئة',
'todo.newItem': 'مهمة جديدة',
'todo.empty': 'لا توجد مهام بعد. أضف مهمة للبدء!',
'todo.filter.my': 'مهامي',
'todo.filter.overdue': 'متأخرة',
'todo.sidebar.tasks': 'المهام',
'todo.sidebar.categories': 'الفئات',
'todo.detail.title': 'مهمة',
'todo.detail.description': 'وصف',
'todo.detail.category': 'فئة',
'todo.detail.dueDate': 'تاريخ الاستحقاق',
'todo.detail.assignedTo': 'مسند إلى',
'todo.detail.delete': 'حذف',
'todo.detail.save': 'حفظ التغييرات',
'todo.detail.create': 'إنشاء مهمة',
'todo.detail.priority': 'الأولوية',
'todo.detail.noPriority': 'لا شيء',
'todo.sortByPrio': 'الأولوية',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'إصدار جديد متاح',
'settings.notificationPreferences.noChannels': 'لم يتم تكوين قنوات إشعارات. اطلب من المسؤول إعداد إشعارات البريد الإلكتروني أو webhook.',
'settings.webhookUrl.label': 'رابط Webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.',
'settings.webhookUrl.save': 'حفظ',
'settings.webhookUrl.saved': 'تم حفظ رابط Webhook',
'settings.webhookUrl.test': 'اختبار',
'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.',
'admin.notifications.adminWebhookPanel.title': 'Webhook المسؤول',
'admin.notifications.adminWebhookPanel.hint': 'يُستخدم هذا الـ Webhook حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن Webhooks المستخدمين ويُرسل تلقائيًا عند تعيين رابط URL.',
'admin.notifications.adminWebhookPanel.saved': 'تم حفظ رابط Webhook المسؤول',
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'admin.tabs.notifications': 'الإشعارات',
'admin.tabs.notificationChannels': 'قنوات الإشعارات',
'admin.tabs.adminNotifications': 'إشعارات المسؤول',
'notifications.versionAvailable.title': 'تحديث متاح',
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
'notifications.versionAvailable.button': 'عرض التفاصيل',
'notif.test.title': '[اختبار] إشعار',
'notif.test.simple.text': 'هذا إشعار اختبار بسيط.',
'notif.test.boolean.text': 'هل تقبل هذا الإشعار الاختباري؟',
'notif.test.navigate.text': 'انقر أدناه للانتقال إلى لوحة التحكم.',
// Notifications
'notif.trip_invite.title': 'دعوة للرحلة',
'notif.trip_invite.text': '{actor} دعاك إلى {trip}',
'notif.booking_change.title': 'تم تحديث الحجز',
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}',
'notif.collab_message.title': 'رسالة جديدة',
'notif.collab_message.text': '{actor} أرسل رسالة في {trip}',
'notif.packing_tagged.title': 'مهمة التعبئة',
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
'notif.version_available.title': 'إصدار جديد متاح',
'notif.version_available.text': 'TREK {version} متاح الآن',
'notif.action.view_trip': 'عرض الرحلة',
'notif.action.view_collab': 'عرض الرسائل',
'notif.action.view_packing': 'عرض التعبئة',
'notif.action.view_photos': 'عرض الصور',
'notif.action.view_vacay': 'عرض Vacay',
'notif.action.view_admin': 'الذهاب للإدارة',
'notif.action.view': 'عرض',
'notif.action.accept': 'قبول',
'notif.action.decline': 'رفض',
'notif.generic.title': 'إشعار',
'notif.generic.text': 'لديك إشعار جديد',
'notif.dev.unknown_event.title': '[DEV] حدث غير معروف',
'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
}
export default ar

View File

@@ -127,6 +127,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Configurações',
'settings.subtitle': 'Ajuste suas preferências pessoais',
'settings.tabs.display': 'Exibição',
'settings.tabs.map': 'Mapa',
'settings.tabs.notifications': 'Notificações',
'settings.tabs.integrations': 'Integrações',
'settings.tabs.account': 'Conta',
'settings.tabs.about': 'Sobre',
'settings.map': 'Mapa',
'settings.mapTemplate': 'Modelo de mapa',
'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...',
@@ -490,8 +496,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.',
'admin.addons.catalog.memories.name': 'Memórias',
'admin.addons.catalog.memories.description': 'Álbuns de fotos compartilhados em cada viagem',
'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': 'Listas',
'admin.addons.catalog.packing.description': 'Listas de bagagem e tarefas a fazer para suas viagens',
'admin.addons.catalog.budget.name': 'Orçamento',
'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem',
'admin.addons.catalog.documents.name': 'Documentos',
@@ -672,8 +678,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Remover',
'atlas.confirmMark': 'Marcar este país como visitado?',
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?',
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados',
'atlas.addToBucket': 'Adicionar à lista de desejos',
'atlas.addPoi': 'Adicionar lugar',
'atlas.searchCountry': 'Buscar um país...',
@@ -723,8 +731,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de mala',
'trip.tabs.packingShort': 'Mala',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Listas',
'trip.tabs.listsShort': 'Listas',
'trip.tabs.budget': 'Orçamento',
'trip.tabs.files': 'Arquivos',
'trip.loading': 'Carregando viagem...',
@@ -919,11 +927,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Vincular à atribuição do dia',
'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...',
'reservations.noAssignment': 'Sem vínculo (avulsa)',
'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': 'Preço',
'reservations.budgetCategory': 'Categoria de orçamento',
'reservations.budgetCategoryPlaceholder': 'ex. Transporte, Acomodação',
'reservations.budgetCategoryAuto': 'Automático (pelo tipo de reserva)',
'reservations.budgetHint': 'Uma entrada de orçamento será criada automaticamente ao salvar.',
'reservations.departureDate': 'Partida',
'reservations.arrivalDate': 'Chegada',
'reservations.departureTime': 'Hora partida',
@@ -1569,38 +1577,105 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.tripText': 'Notificação de teste para a viagem "{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 de bagagem',
'todo.subtab.todo': 'A fazer',
'todo.completed': 'concluído(s)',
'todo.filter.all': 'Todos',
'todo.filter.open': 'Aberto',
'todo.filter.done': 'Concluído',
'todo.uncategorized': 'Sem categoria',
'todo.namePlaceholder': 'Nome da tarefa',
'todo.descriptionPlaceholder': 'Descrição (opcional)',
'todo.unassigned': 'Não atribuído',
'todo.noCategory': 'Sem categoria',
'todo.hasDescription': 'Com descrição',
'todo.addItem': 'Adicionar nova tarefa...',
'todo.newCategory': 'Nome da categoria',
'todo.addCategory': 'Adicionar categoria',
'todo.newItem': 'Nova tarefa',
'todo.empty': 'Nenhuma tarefa ainda. Adicione uma tarefa para começar!',
'todo.filter.my': 'Minhas tarefas',
'todo.filter.overdue': 'Atrasada',
'todo.sidebar.tasks': 'Tarefas',
'todo.sidebar.categories': 'Categorias',
'todo.detail.title': 'Tarefa',
'todo.detail.description': 'Descrição',
'todo.detail.category': 'Categoria',
'todo.detail.dueDate': 'Data de vencimento',
'todo.detail.assignedTo': 'Atribuído a',
'todo.detail.delete': 'Excluir',
'todo.detail.save': 'Salvar alterações',
'todo.detail.create': 'Criar tarefa',
'todo.detail.priority': 'Prioridade',
'todo.detail.noPriority': 'Nenhuma',
'todo.sortByPrio': 'Prioridade',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nova versão disponível',
'settings.notificationPreferences.noChannels': 'Nenhum canal de notificação configurado. Peça a um administrador para configurar notificações por e-mail ou webhook.',
'settings.webhookUrl.label': 'URL do webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Insira a URL do seu webhook do Discord, Slack ou personalizado para receber notificações.',
'settings.webhookUrl.save': 'Salvar',
'settings.webhookUrl.saved': 'URL do webhook salva',
'settings.webhookUrl.test': 'Testar',
'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso',
'settings.webhookUrl.testFailed': 'Falha no webhook de teste',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
'admin.notifications.adminWebhookPanel.hint': 'Este webhook é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos webhooks de usuários e dispara automaticamente quando uma URL está configurada.',
'admin.notifications.adminWebhookPanel.saved': 'URL do webhook de admin salva',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso',
'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada',
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
'admin.tabs.notifications': 'Notificações',
'admin.tabs.notificationChannels': 'Canais de notificação',
'admin.tabs.adminNotifications': 'Notificações de admin',
'notifications.versionAvailable.title': 'Atualização disponível',
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
'notifications.versionAvailable.button': 'Ver detalhes',
'notif.test.title': '[Teste] Notificação',
'notif.test.simple.text': 'Esta é uma notificação de teste simples.',
'notif.test.boolean.text': 'Você aceita esta notificação de teste?',
'notif.test.navigate.text': 'Clique abaixo para ir ao painel.',
// Notifications
'notif.trip_invite.title': 'Convite para viagem',
'notif.trip_invite.text': '{actor} convidou você para {trip}',
'notif.booking_change.title': 'Reserva atualizada',
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas',
'notif.photos_shared.text': '{actor} compartilhou {count} foto(s) em {trip}',
'notif.collab_message.title': 'Nova mensagem',
'notif.collab_message.text': '{actor} enviou uma mensagem em {trip}',
'notif.packing_tagged.title': 'Atribuição de bagagem',
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
'notif.version_available.title': 'Nova versão disponível',
'notif.version_available.text': 'TREK {version} está disponível',
'notif.action.view_trip': 'Ver viagem',
'notif.action.view_collab': 'Ver mensagens',
'notif.action.view_packing': 'Ver bagagem',
'notif.action.view_photos': 'Ver fotos',
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_admin': 'Ir para admin',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceitar',
'notif.action.decline': 'Recusar',
'notif.generic.title': 'Notificação',
'notif.generic.text': 'Você tem uma nova notificação',
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido',
'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG',
}
export default br

View File

@@ -128,6 +128,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Nastavení (Settings)
'settings.title': 'Nastavení',
'settings.subtitle': 'Upravte své osobní nastavení',
'settings.tabs.display': 'Zobrazení',
'settings.tabs.map': 'Mapa',
'settings.tabs.notifications': 'Oznámení',
'settings.tabs.integrations': 'Integrace',
'settings.tabs.account': 'Účet',
'settings.tabs.about': 'O aplikaci',
'settings.map': 'Mapy',
'settings.mapTemplate': 'Šablona mapy',
'settings.mapTemplatePlaceholder.select': 'Vyberte šablonu...',
@@ -490,8 +496,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
'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': 'Seznamy',
'admin.addons.catalog.packing.description': 'Balicí seznamy a úkoly pro vaše výlety',
'admin.addons.catalog.budget.name': 'Rozpočet',
'admin.addons.catalog.budget.description': 'Sledování výdajů a plánování rozpočtu cesty',
'admin.addons.catalog.documents.name': 'Dokumenty',
@@ -689,8 +695,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Odebrat',
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
'atlas.confirmUnmarkRegion': 'Odebrat tento region ze seznamu navštívených?',
'atlas.markVisited': 'Označit jako navštívené',
'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
'atlas.markRegionVisitedHint': 'Přidat tento region do seznamu navštívených',
'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)',
'atlas.addPoi': 'Přidat místo',
'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
@@ -739,8 +747,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Rez.',
'trip.tabs.packing': 'Seznam věcí',
'trip.tabs.packingShort': 'Balení',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Seznamy',
'trip.tabs.listsShort': 'Seznamy',
'trip.tabs.budget': 'Rozpočet',
'trip.tabs.files': 'Soubory',
'trip.loading': 'Načítání cesty...',
@@ -936,11 +944,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Propojit s přiřazením dne',
'reservations.pickAssignment': 'Vyberte přiřazení z vašeho plánu...',
'reservations.noAssignment': 'Bez propojení (samostatné)',
'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': 'Kategorie rozpočtu',
'reservations.budgetCategoryPlaceholder': 'např. Doprava, Ubytování',
'reservations.budgetCategoryAuto': 'Auto (podle typu rezervace)',
'reservations.budgetHint': 'Při ukládání bude automaticky vytvořena položka rozpočtu.',
'reservations.departureDate': 'Odlet',
'reservations.arrivalDate': 'Přílet',
'reservations.departureTime': 'Čas odletu',
@@ -1574,38 +1582,105 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.tripText': 'Testovací oznámení pro výlet "{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': 'Balicí seznam',
'todo.subtab.todo': 'Úkoly',
'todo.completed': 'dokončeno',
'todo.filter.all': 'Vše',
'todo.filter.open': 'Otevřené',
'todo.filter.done': 'Hotové',
'todo.uncategorized': 'Bez kategorie',
'todo.namePlaceholder': 'Název úkolu',
'todo.descriptionPlaceholder': 'Popis (volitelné)',
'todo.unassigned': 'Nepřiřazeno',
'todo.noCategory': 'Bez kategorie',
'todo.hasDescription': 'Má popis',
'todo.addItem': 'Přidat nový úkol...',
'todo.newCategory': 'Název kategorie',
'todo.addCategory': 'Přidat kategorii',
'todo.newItem': 'Nový úkol',
'todo.empty': 'Zatím žádné úkoly. Přidejte úkol a začněte!',
'todo.filter.my': 'Moje úkoly',
'todo.filter.overdue': 'Po termínu',
'todo.sidebar.tasks': 'Úkoly',
'todo.sidebar.categories': 'Kategorie',
'todo.detail.title': 'Úkol',
'todo.detail.description': 'Popis',
'todo.detail.category': 'Kategorie',
'todo.detail.dueDate': 'Termín splnění',
'todo.detail.assignedTo': 'Přiřazeno',
'todo.detail.delete': 'Smazat',
'todo.detail.save': 'Uložit změny',
'todo.detail.create': 'Vytvořit úkol',
'todo.detail.priority': 'Priorita',
'todo.detail.noPriority': 'Žádná',
'todo.sortByPrio': 'Priorita',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nová verze k dispozici',
'settings.notificationPreferences.noChannels': 'Nejsou nakonfigurovány žádné kanály oznámení. Požádejte správce o nastavení e-mailových nebo webhook oznámení.',
'settings.webhookUrl.label': 'URL webhooku',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Zadejte URL vašeho Discord, Slack nebo vlastního webhooku pro příjem oznámení.',
'settings.webhookUrl.save': 'Uložit',
'settings.webhookUrl.saved': 'URL webhooku uložena',
'settings.webhookUrl.test': 'Otestovat',
'settings.webhookUrl.testSuccess': 'Testovací webhook byl úspěšně odeslán',
'settings.webhookUrl.testFailed': 'Testovací webhook selhal',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-app oznámení jsou vždy aktivní a nelze je globálně vypnout.',
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
'admin.notifications.adminWebhookPanel.hint': 'Tento webhook se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislý na uživatelských webhooků a odesílá automaticky, pokud je nastavena URL.',
'admin.notifications.adminWebhookPanel.saved': 'URL admin webhooku uložena',
'admin.notifications.adminWebhookPanel.testSuccess': 'Testovací webhook byl úspěšně odeslán',
'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL',
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
'admin.tabs.notifications': 'Oznámení',
'admin.tabs.notificationChannels': 'Kanály oznámení',
'admin.tabs.adminNotifications': 'Admin oznámení',
'notifications.versionAvailable.title': 'Dostupná aktualizace',
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
'notifications.versionAvailable.button': 'Zobrazit podrobnosti',
'notif.test.title': '[Test] Oznámení',
'notif.test.simple.text': 'Toto je jednoduché testovací oznámení.',
'notif.test.boolean.text': 'Přijmete toto testovací oznámení?',
'notif.test.navigate.text': 'Klikněte níže pro přechod na přehled.',
// Notifications
'notif.trip_invite.title': 'Pozvánka na výlet',
'notif.trip_invite.text': '{actor} vás pozval na {trip}',
'notif.booking_change.title': 'Rezervace aktualizována',
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
'notif.trip_reminder.title': 'Připomínka výletu',
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
'notif.photos_shared.title': 'Fotky sdíleny',
'notif.photos_shared.text': '{actor} sdílel {count} foto v {trip}',
'notif.collab_message.title': 'Nová zpráva',
'notif.collab_message.text': '{actor} poslal zprávu v {trip}',
'notif.packing_tagged.title': 'Přiřazení balení',
'notif.packing_tagged.text': '{actor} vás přiřadil k {category} v {trip}',
'notif.version_available.title': 'Nová verze dostupná',
'notif.version_available.text': 'TREK {version} je nyní dostupný',
'notif.action.view_trip': 'Zobrazit výlet',
'notif.action.view_collab': 'Zobrazit zprávy',
'notif.action.view_packing': 'Zobrazit balení',
'notif.action.view_photos': 'Zobrazit fotky',
'notif.action.view_vacay': 'Zobrazit Vacay',
'notif.action.view_admin': 'Jít do adminu',
'notif.action.view': 'Zobrazit',
'notif.action.accept': 'Přijmout',
'notif.action.decline': 'Odmítnout',
'notif.generic.title': 'Oznámení',
'notif.generic.text': 'Máte nové oznámení',
'notif.dev.unknown_event.title': '[DEV] Neznámá událost',
'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG',
}
export default cs

View File

@@ -127,6 +127,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Einstellungen',
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
'settings.tabs.display': 'Anzeige',
'settings.tabs.map': 'Karte',
'settings.tabs.notifications': 'Benachrichtigungen',
'settings.tabs.integrations': 'Integrationen',
'settings.tabs.account': 'Konto',
'settings.tabs.about': 'Über',
'settings.map': 'Karte',
'settings.mapTemplate': 'Karten-Vorlage',
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
@@ -688,8 +694,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Entfernen',
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
'atlas.markVisited': 'Als besucht markieren',
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
'atlas.addToBucket': 'Zur Bucket List',
'atlas.addPoi': 'Ort hinzufügen',
'atlas.searchCountry': 'Land suchen...',
@@ -1603,6 +1611,73 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'todo.detail.priority': 'Priorität',
'todo.detail.noPriority': 'Keine',
'todo.detail.create': 'Aufgabe erstellen',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Neue Version verfügbar',
'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.',
'settings.webhookUrl.save': 'Speichern',
'settings.webhookUrl.saved': 'Webhook-URL gespeichert',
'settings.webhookUrl.test': 'Testen',
'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet',
'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.',
'admin.notifications.adminWebhookPanel.title': 'Admin-Webhook',
'admin.notifications.adminWebhookPanel.hint': 'Dieser Webhook wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Er ist unabhängig von den Benutzer-Webhooks und sendet automatisch, wenn eine URL konfiguriert ist.',
'admin.notifications.adminWebhookPanel.saved': 'Admin-Webhook-URL gespeichert',
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet',
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
'admin.tabs.notifications': 'Benachrichtigungen',
'admin.tabs.notificationChannels': 'Benachrichtigungskanäle',
'admin.tabs.adminNotifications': 'Admin-Benachrichtigungen',
'notifications.versionAvailable.title': 'Update verfügbar',
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
'notifications.versionAvailable.button': 'Details anzeigen',
'notif.test.title': '[Test] Benachrichtigung',
'notif.test.simple.text': 'Dies ist eine einfache Testbenachrichtigung.',
'notif.test.boolean.text': 'Akzeptierst du diese Testbenachrichtigung?',
'notif.test.navigate.text': 'Klicke unten, um zum Dashboard zu navigieren.',
// Notifications
'notif.trip_invite.title': 'Reiseeinladung',
'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen',
'notif.booking_change.title': 'Buchung aktualisiert',
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
'notif.trip_reminder.title': 'Reiseerinnerung',
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
'notif.photos_shared.title': 'Fotos geteilt',
'notif.photos_shared.text': '{actor} hat {count} Foto(s) in {trip} geteilt',
'notif.collab_message.title': 'Neue Nachricht',
'notif.collab_message.text': '{actor} hat eine Nachricht in {trip} gesendet',
'notif.packing_tagged.title': 'Packlistenzuweisung',
'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen',
'notif.version_available.title': 'Neue Version verfügbar',
'notif.version_available.text': 'TREK {version} ist jetzt verfügbar',
'notif.action.view_trip': 'Reise ansehen',
'notif.action.view_collab': 'Nachrichten ansehen',
'notif.action.view_packing': 'Packliste ansehen',
'notif.action.view_photos': 'Fotos ansehen',
'notif.action.view_vacay': 'Vacay ansehen',
'notif.action.view_admin': 'Zum Admin',
'notif.action.view': 'Ansehen',
'notif.action.accept': 'Annehmen',
'notif.action.decline': 'Ablehnen',
'notif.generic.title': 'Benachrichtigung',
'notif.generic.text': 'Du hast eine neue Benachrichtigung',
'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis',
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
}
export default de

View File

@@ -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

View File

@@ -128,6 +128,12 @@ const es: Record<string, string> = {
// Settings
'settings.title': 'Ajustes',
'settings.subtitle': 'Configura tus ajustes personales',
'settings.tabs.display': 'Pantalla',
'settings.tabs.map': 'Mapa',
'settings.tabs.notifications': 'Notificaciones',
'settings.tabs.integrations': 'Integraciones',
'settings.tabs.account': 'Cuenta',
'settings.tabs.about': 'Acerca de',
'settings.map': 'Mapa',
'settings.mapTemplate': 'Plantilla del mapa',
'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
@@ -700,8 +706,10 @@ const es: Record<string, string> = {
'atlas.unmark': 'Eliminar',
'atlas.confirmMark': '¿Marcar este país como visitado?',
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?',
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados',
'atlas.addToBucket': 'Añadir a lista de deseos',
'atlas.addPoi': 'Añadir lugar',
'atlas.searchCountry': 'Buscar un país...',
@@ -715,8 +723,8 @@ const es: Record<string, string> = {
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de equipaje',
'trip.tabs.packingShort': 'Equipaje',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Listas',
'trip.tabs.listsShort': 'Listas',
'trip.tabs.budget': 'Presupuesto',
'trip.tabs.files': 'Archivos',
'trip.loading': 'Cargando viaje...',
@@ -895,11 +903,11 @@ const es: Record<string, string> = {
'reservations.linkAssignment': 'Vincular a una asignación del día',
'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
'reservations.noAssignment': 'Sin vínculo (independiente)',
'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': 'Precio',
'reservations.budgetCategory': 'Categoría de presupuesto',
'reservations.budgetCategoryPlaceholder': 'ej. Transporte, Alojamiento',
'reservations.budgetCategoryAuto': 'Automático (según tipo de reserva)',
'reservations.budgetHint': 'Se crea automáticamente una entrada presupuestaria al guardar.',
'reservations.departureDate': 'Salida',
'reservations.arrivalDate': 'Llegada',
'reservations.departureTime': 'Hora salida',
@@ -1191,8 +1199,8 @@ const es: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA',
'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': 'Listas',
'admin.addons.catalog.packing.description': 'Listas de equipaje y tareas pendientes para tus viajes',
'admin.addons.catalog.budget.name': 'Presupuesto',
'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje',
'admin.addons.catalog.documents.name': 'Documentos',
@@ -1576,38 +1584,105 @@ const es: Record<string, string> = {
'notifications.test.tripText': 'Notificación de prueba para el viaje "{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 de equipaje',
'todo.subtab.todo': 'Por hacer',
'todo.completed': 'completado(s)',
'todo.filter.all': 'Todo',
'todo.filter.open': 'Abierto',
'todo.filter.done': 'Hecho',
'todo.uncategorized': 'Sin categoría',
'todo.namePlaceholder': 'Nombre de la tarea',
'todo.descriptionPlaceholder': 'Descripción (opcional)',
'todo.unassigned': 'Sin asignar',
'todo.noCategory': 'Sin categoría',
'todo.hasDescription': 'Con descripción',
'todo.addItem': 'Añadir nueva tarea...',
'todo.newCategory': 'Nombre de la categoría',
'todo.addCategory': 'Añadir categoría',
'todo.newItem': 'Nueva tarea',
'todo.empty': 'Aún no hay tareas. ¡Añade una tarea para empezar!',
'todo.filter.my': 'Mis tareas',
'todo.filter.overdue': 'Vencida',
'todo.sidebar.tasks': 'Tareas',
'todo.sidebar.categories': 'Categorías',
'todo.detail.title': 'Tarea',
'todo.detail.description': 'Descripción',
'todo.detail.category': 'Categoría',
'todo.detail.dueDate': 'Fecha límite',
'todo.detail.assignedTo': 'Asignado a',
'todo.detail.delete': 'Eliminar',
'todo.detail.save': 'Guardar cambios',
'todo.detail.create': 'Crear tarea',
'todo.detail.priority': 'Prioridad',
'todo.detail.noPriority': 'Ninguna',
'todo.sortByPrio': 'Prioridad',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nueva versión disponible',
'settings.notificationPreferences.noChannels': 'No hay canales de notificación configurados. Pide a un administrador que configure notificaciones por correo o webhook.',
'settings.webhookUrl.label': 'URL del webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Introduce tu URL de webhook de Discord, Slack o personalizada para recibir notificaciones.',
'settings.webhookUrl.save': 'Guardar',
'settings.webhookUrl.saved': 'URL del webhook guardada',
'settings.webhookUrl.test': 'Probar',
'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente',
'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'Las notificaciones in-app siempre están activas y no se pueden desactivar globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
'admin.notifications.adminWebhookPanel.hint': 'Este webhook se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los webhooks de usuario y se activa automáticamente si hay una URL configurada.',
'admin.notifications.adminWebhookPanel.saved': 'URL del webhook de admin guardada',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente',
'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada',
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
'admin.tabs.notifications': 'Notificaciones',
'admin.tabs.notificationChannels': 'Canales de notificación',
'admin.tabs.adminNotifications': 'Notificaciones de admin',
'notifications.versionAvailable.title': 'Actualización disponible',
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
'notifications.versionAvailable.button': 'Ver detalles',
'notif.test.title': '[Test] Notificación',
'notif.test.simple.text': 'Esta es una notificación de prueba simple.',
'notif.test.boolean.text': '¿Aceptas esta notificación de prueba?',
'notif.test.navigate.text': 'Haz clic abajo para ir al panel de control.',
// Notifications
'notif.trip_invite.title': 'Invitación al viaje',
'notif.trip_invite.text': '{actor} te invitó a {trip}',
'notif.booking_change.title': 'Reserva actualizada',
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
'notif.trip_reminder.title': 'Recordatorio de viaje',
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
'notif.photos_shared.title': 'Fotos compartidas',
'notif.photos_shared.text': '{actor} compartió {count} foto(s) en {trip}',
'notif.collab_message.title': 'Nuevo mensaje',
'notif.collab_message.text': '{actor} envió un mensaje en {trip}',
'notif.packing_tagged.title': 'Asignación de equipaje',
'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}',
'notif.version_available.title': 'Nueva versión disponible',
'notif.version_available.text': 'TREK {version} ya está disponible',
'notif.action.view_trip': 'Ver viaje',
'notif.action.view_collab': 'Ver mensajes',
'notif.action.view_packing': 'Ver equipaje',
'notif.action.view_photos': 'Ver fotos',
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_admin': 'Ir al admin',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceptar',
'notif.action.decline': 'Rechazar',
'notif.generic.title': 'Notificación',
'notif.generic.text': 'Tienes una nueva notificación',
'notif.dev.unknown_event.title': '[DEV] Evento desconocido',
'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG',
}
export default es

View File

@@ -127,6 +127,12 @@ const fr: Record<string, string> = {
// Settings
'settings.title': 'Paramètres',
'settings.subtitle': 'Configurez vos paramètres personnels',
'settings.tabs.display': 'Affichage',
'settings.tabs.map': 'Carte',
'settings.tabs.notifications': 'Notifications',
'settings.tabs.integrations': 'Intégrations',
'settings.tabs.account': 'Compte',
'settings.tabs.about': 'À propos',
'settings.map': 'Carte',
'settings.mapTemplate': 'Modèle de carte',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
@@ -491,8 +497,8 @@ const fr: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA',
'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': 'Listes',
'admin.addons.catalog.packing.description': 'Listes de bagages et tâches à faire pour vos voyages',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
'admin.addons.catalog.documents.name': 'Documents',
@@ -723,8 +729,10 @@ const fr: Record<string, string> = {
'atlas.unmark': 'Retirer',
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?',
'atlas.markVisited': 'Marquer comme visité',
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités',
'atlas.addToBucket': 'Ajouter à la bucket list',
'atlas.addPoi': 'Ajouter un lieu',
'atlas.searchCountry': 'Rechercher un pays…',
@@ -738,8 +746,8 @@ const fr: Record<string, string> = {
'trip.tabs.reservationsShort': 'Résa',
'trip.tabs.packing': 'Liste de bagages',
'trip.tabs.packingShort': 'Bagages',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Listes',
'trip.tabs.listsShort': 'Listes',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage…',
@@ -934,11 +942,11 @@ const fr: Record<string, string> = {
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
'reservations.noAssignment': 'Aucun lien (autonome)',
'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': 'Prix',
'reservations.budgetCategory': 'Catégorie budgétaire',
'reservations.budgetCategoryPlaceholder': 'ex. Transport, Hébergement',
'reservations.budgetCategoryAuto': 'Auto (selon le type de réservation)',
'reservations.budgetHint': 'Une entrée budgétaire sera créée automatiquement lors de l\'enregistrement.',
'reservations.departureDate': 'Départ',
'reservations.arrivalDate': 'Arrivée',
'reservations.departureTime': 'Heure dép.',
@@ -1570,38 +1578,105 @@ const fr: Record<string, string> = {
'notifications.test.tripText': 'Notification de test pour le voyage "{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.subtab.packing': 'Liste de bagages',
'todo.subtab.todo': 'À faire',
'todo.completed': 'terminé(s)',
'todo.filter.all': 'Tout',
'todo.filter.open': 'En cours',
'todo.filter.done': 'Terminé',
'todo.uncategorized': 'Sans catégorie',
'todo.namePlaceholder': 'Nom de la tâche',
'todo.descriptionPlaceholder': 'Description (facultative)',
'todo.unassigned': 'Non assigné',
'todo.noCategory': 'Aucune catégorie',
'todo.hasDescription': 'Avec description',
'todo.addItem': 'Ajouter une tâche...',
'todo.newCategory': 'Nom de la catégorie',
'todo.addCategory': 'Ajouter une catégorie',
'todo.newItem': 'Nouvelle tâche',
'todo.empty': 'Aucune tâche pour l\'instant. Ajoutez une tâche pour commencer !',
'todo.filter.my': 'Mes tâches',
'todo.filter.overdue': 'En retard',
'todo.sidebar.tasks': 'Tâches',
'todo.sidebar.categories': 'Catégories',
'todo.detail.title': 'Tâche',
'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.detail.category': 'Catégorie',
'todo.detail.dueDate': 'Date d\'échéance',
'todo.detail.assignedTo': 'Assigné à',
'todo.detail.delete': 'Supprimer',
'todo.detail.save': 'Enregistrer les modifications',
'todo.detail.create': 'Créer la tâche',
'todo.detail.priority': 'Priorité',
'todo.detail.noPriority': 'Aucune',
'todo.sortByPrio': 'Priorité',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nouvelle version disponible',
'settings.notificationPreferences.noChannels': 'Aucun canal de notification n\'est configuré. Demandez à un administrateur de configurer les notifications par e-mail ou webhook.',
'settings.webhookUrl.label': 'URL du webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Entrez votre URL de webhook Discord, Slack ou personnalisée pour recevoir des notifications.',
'settings.webhookUrl.save': 'Enregistrer',
'settings.webhookUrl.saved': 'URL du webhook enregistrée',
'settings.webhookUrl.test': 'Tester',
'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès',
'settings.webhookUrl.testFailed': 'Échec du webhook de test',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'Les notifications in-app sont toujours actives et ne peuvent pas être désactivées globalement.',
'admin.notifications.adminWebhookPanel.title': 'Webhook admin',
'admin.notifications.adminWebhookPanel.hint': 'Ce webhook est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des webhooks utilisateur et s\'active automatiquement si une URL est configurée.',
'admin.notifications.adminWebhookPanel.saved': 'URL du webhook admin enregistrée',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès',
'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée',
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
'admin.tabs.notifications': 'Notifications',
'admin.tabs.notificationChannels': 'Canaux de notification',
'admin.tabs.adminNotifications': 'Notifications admin',
'notifications.versionAvailable.title': 'Mise à jour disponible',
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
'notifications.versionAvailable.button': 'Voir les détails',
'notif.test.title': '[Test] Notification',
'notif.test.simple.text': 'Ceci est une simple notification de test.',
'notif.test.boolean.text': 'Acceptez-vous cette notification de test ?',
'notif.test.navigate.text': 'Cliquez ci-dessous pour accéder au tableau de bord.',
// Notifications
'notif.trip_invite.title': 'Invitation au voyage',
'notif.trip_invite.text': '{actor} vous a invité à {trip}',
'notif.booking_change.title': 'Réservation mise à jour',
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
'notif.trip_reminder.title': 'Rappel de voyage',
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
'notif.photos_shared.title': 'Photos partagées',
'notif.photos_shared.text': '{actor} a partagé {count} photo(s) dans {trip}',
'notif.collab_message.title': 'Nouveau message',
'notif.collab_message.text': '{actor} a envoyé un message dans {trip}',
'notif.packing_tagged.title': 'Affectation bagages',
'notif.packing_tagged.text': '{actor} vous a assigné à {category} dans {trip}',
'notif.version_available.title': 'Nouvelle version disponible',
'notif.version_available.text': 'TREK {version} est maintenant disponible',
'notif.action.view_trip': 'Voir le voyage',
'notif.action.view_collab': 'Voir les messages',
'notif.action.view_packing': 'Voir les bagages',
'notif.action.view_photos': 'Voir les photos',
'notif.action.view_vacay': 'Voir Vacay',
'notif.action.view_admin': 'Aller à l\'admin',
'notif.action.view': 'Voir',
'notif.action.accept': 'Accepter',
'notif.action.decline': 'Refuser',
'notif.generic.title': 'Notification',
'notif.generic.text': 'Vous avez une nouvelle notification',
'notif.dev.unknown_event.title': '[DEV] Événement inconnu',
'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG',
}
export default fr

View File

@@ -127,6 +127,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
// Beállítások
'settings.title': 'Beállítások',
'settings.subtitle': 'Személyes beállítások konfigurálása',
'settings.tabs.display': 'Megjelenés',
'settings.tabs.map': 'Térkép',
'settings.tabs.notifications': 'Értesítések',
'settings.tabs.integrations': 'Integrációk',
'settings.tabs.account': 'Fiók',
'settings.tabs.about': 'Névjegy',
'settings.map': 'Térkép',
'settings.mapTemplate': 'Térkép sablon',
'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...',
@@ -488,8 +494,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Bővítmények',
'admin.addons.title': 'Bővítmények',
'admin.addons.subtitle': 'Funkciók engedélyezése vagy letiltása a TREK testreszabásához.',
'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': 'Listák',
'admin.addons.catalog.packing.description': 'Csomagolási listák és teendők az utazásaidhoz',
'admin.addons.catalog.budget.name': 'Költségvetés',
'admin.addons.catalog.budget.description': 'Kiadások nyomon követése és az utazási költségvetés tervezése',
'admin.addons.catalog.documents.name': 'Dokumentumok',
@@ -688,8 +694,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Eltávolítás',
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?',
'atlas.markVisited': 'Megjelölés meglátogatottként',
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához',
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
'atlas.addPoi': 'Hely hozzáadása',
'atlas.searchCountry': 'Ország keresése...',
@@ -739,8 +747,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Foglalás',
'trip.tabs.packing': 'Csomagolási lista',
'trip.tabs.packingShort': 'Csomag',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Listák',
'trip.tabs.listsShort': 'Listák',
'trip.tabs.budget': 'Költségvetés',
'trip.tabs.files': 'Fájlok',
'trip.loading': 'Utazás betöltése...',
@@ -935,11 +943,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Összekapcsolás napi tervvel',
'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...',
'reservations.noAssignment': 'Nincs összekapcsolás (önálló)',
'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': 'Ár',
'reservations.budgetCategory': 'Költségvetési kategória',
'reservations.budgetCategoryPlaceholder': 'pl. Közlekedés, Szállás',
'reservations.budgetCategoryAuto': 'Automatikus (foglalás típusa alapján)',
'reservations.budgetHint': 'Mentéskor automatikusan létrejön egy költségvetési tétel.',
'reservations.departureDate': 'Indulás',
'reservations.arrivalDate': 'Érkezés',
'reservations.departureTime': 'Indulási idő',
@@ -1571,38 +1579,105 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.tripText': 'Teszt értesítés a(z) "{trip}" utazáshoz.',
// 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': 'Csomagolási lista',
'todo.subtab.todo': 'Teendők',
'todo.completed': 'kész',
'todo.filter.all': 'Mind',
'todo.filter.open': 'Nyitott',
'todo.filter.done': 'Kész',
'todo.uncategorized': 'Kategória nélküli',
'todo.namePlaceholder': 'Feladat neve',
'todo.descriptionPlaceholder': 'Leírás (opcionális)',
'todo.unassigned': 'Nem hozzárendelt',
'todo.noCategory': 'Nincs kategória',
'todo.hasDescription': 'Van leírás',
'todo.addItem': 'Új feladat hozzáadása...',
'todo.newCategory': 'Kategória neve',
'todo.addCategory': 'Kategória hozzáadása',
'todo.newItem': 'Új feladat',
'todo.empty': 'Még nincsenek feladatok. Adj hozzá egyet a kezdéshez!',
'todo.filter.my': 'Saját feladataim',
'todo.filter.overdue': 'Lejárt',
'todo.sidebar.tasks': 'Feladatok',
'todo.sidebar.categories': 'Kategóriák',
'todo.detail.title': 'Feladat',
'todo.detail.description': 'Leírás',
'todo.detail.category': 'Kategória',
'todo.detail.dueDate': 'Határidő',
'todo.detail.assignedTo': 'Hozzárendelve',
'todo.detail.delete': 'Törlés',
'todo.detail.save': 'Módosítások mentése',
'todo.detail.create': 'Feladat létrehozása',
'todo.detail.priority': 'Prioritás',
'todo.detail.noPriority': 'Nincs',
'todo.sortByPrio': 'Prioritás',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Új verzió elérhető',
'settings.notificationPreferences.noChannels': 'Nincsenek értesítési csatornák beállítva. Kérd meg a rendszergazdát, hogy állítson be e-mail vagy webhook értesítéseket.',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Adja meg a Discord, Slack vagy egyéni webhook URL-jét az értesítések fogadásához.',
'settings.webhookUrl.save': 'Mentés',
'settings.webhookUrl.saved': 'Webhook URL mentve',
'settings.webhookUrl.test': 'Teszt',
'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve',
'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'Az alkalmazáson belüli értesítések mindig aktívak, és globálisan nem kapcsolhatók ki.',
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
'admin.notifications.adminWebhookPanel.hint': 'Ez a webhook kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói webhookoktól, és automatikusan küld, ha URL van beállítva.',
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL mentve',
'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve',
'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva',
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
'admin.tabs.notifications': 'Értesítések',
'admin.tabs.notificationChannels': 'Értesítési csatornák',
'admin.tabs.adminNotifications': 'Admin értesítések',
'notifications.versionAvailable.title': 'Elérhető frissítés',
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
'notifications.versionAvailable.button': 'Részletek megtekintése',
'notif.test.title': '[Teszt] Értesítés',
'notif.test.simple.text': 'Ez egy egyszerű teszt értesítés.',
'notif.test.boolean.text': 'Elfogadod ezt a teszt értesítést?',
'notif.test.navigate.text': 'Kattints alább az irányítópultra navigáláshoz.',
// Notifications
'notif.trip_invite.title': 'Utazásra meghívó',
'notif.trip_invite.text': '{actor} meghívott a(z) {trip} utazásra',
'notif.booking_change.title': 'Foglalás frissítve',
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
'notif.trip_reminder.title': 'Utazás emlékeztető',
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
'notif.photos_shared.title': 'Fotók megosztva',
'notif.photos_shared.text': '{actor} {count} fotót osztott meg a(z) {trip} utazásban',
'notif.collab_message.title': 'Új üzenet',
'notif.collab_message.text': '{actor} üzenetet küldött a(z) {trip} utazásban',
'notif.packing_tagged.title': 'Csomagolási feladat',
'notif.packing_tagged.text': '{actor} hozzárendelte Önt a {category} kategóriához a(z) {trip} utazásban',
'notif.version_available.title': 'Új verzió elérhető',
'notif.version_available.text': 'A TREK {version} elérhető',
'notif.action.view_trip': 'Utazás megtekintése',
'notif.action.view_collab': 'Üzenetek megtekintése',
'notif.action.view_packing': 'Csomagolás megtekintése',
'notif.action.view_photos': 'Fotók megtekintése',
'notif.action.view_vacay': 'Vacay megtekintése',
'notif.action.view_admin': 'Admin megnyitása',
'notif.action.view': 'Megtekintés',
'notif.action.accept': 'Elfogadás',
'notif.action.decline': 'Elutasítás',
'notif.generic.title': 'Értesítés',
'notif.generic.text': 'Új értesítésed érkezett',
'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény',
'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban',
}
export default hu

View File

@@ -127,6 +127,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Impostazioni',
'settings.subtitle': 'Configura le tue impostazioni personali',
'settings.tabs.display': 'Visualizzazione',
'settings.tabs.map': 'Mappa',
'settings.tabs.notifications': 'Notifiche',
'settings.tabs.integrations': 'Integrazioni',
'settings.tabs.account': 'Account',
'settings.tabs.about': 'Informazioni',
'settings.map': 'Mappa',
'settings.mapTemplate': 'Modello Mappa',
'settings.mapTemplatePlaceholder.select': 'Seleziona modello...',
@@ -487,8 +493,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Moduli',
'admin.addons.title': 'Moduli',
'admin.addons.subtitle': 'Abilita o disabilita le funzionalità per personalizzare la tua esperienza 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': 'Liste',
'admin.addons.catalog.packing.description': 'Liste di imballaggio e attività da svolgere per i tuoi viaggi',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Tieni traccia delle spese e pianifica il budget del tuo viaggio',
'admin.addons.catalog.documents.name': 'Documenti',
@@ -688,8 +694,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Rimuovi',
'atlas.confirmMark': 'Segnare questo paese come visitato?',
'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
'atlas.confirmUnmarkRegion': 'Rimuovere questa regione dalla tua lista dei visitati?',
'atlas.markVisited': 'Segna come visitato',
'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
'atlas.markRegionVisitedHint': 'Aggiungi questa regione alla tua lista dei visitati',
'atlas.addToBucket': 'Aggiungi alla lista desideri',
'atlas.addPoi': 'Aggiungi luogo',
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
@@ -739,8 +747,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Pren.',
'trip.tabs.packing': 'Lista valigia',
'trip.tabs.packingShort': 'Valigia',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Liste',
'trip.tabs.listsShort': 'Liste',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'File',
'trip.loading': 'Caricamento viaggio...',
@@ -935,11 +943,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Collega all\'assegnazione del giorno',
'reservations.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...',
'reservations.noAssignment': 'Nessun collegamento (autonomo)',
'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': 'Prezzo',
'reservations.budgetCategory': 'Categoria budget',
'reservations.budgetCategoryPlaceholder': 'es. Trasporto, Alloggio',
'reservations.budgetCategoryAuto': 'Auto (dal tipo di prenotazione)',
'reservations.budgetHint': 'Una voce di budget verrà creata automaticamente al salvataggio.',
'reservations.departureDate': 'Partenza',
'reservations.arrivalDate': 'Arrivo',
'reservations.departureTime': 'Ora part.',
@@ -1000,7 +1008,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Files
'files.title': 'File',
'files.count': '{count} file',
'files.countSingular': '1 file',
'files.countSingular': '1 documento',
'files.uploaded': '{count} caricati',
'files.uploadError': 'Caricamento non riuscito',
'files.dropzone': 'Trascina qui i file',
@@ -1571,38 +1579,105 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.tripText': 'Notifica di test per il viaggio "{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 di imballaggio',
'todo.subtab.todo': 'Da fare',
'todo.completed': 'completato/i',
'todo.filter.all': 'Tutti',
'todo.filter.open': 'Aperto',
'todo.filter.done': 'Fatto',
'todo.uncategorized': 'Senza categoria',
'todo.namePlaceholder': 'Nome attività',
'todo.descriptionPlaceholder': 'Descrizione (facoltativa)',
'todo.unassigned': 'Non assegnato',
'todo.noCategory': 'Nessuna categoria',
'todo.hasDescription': 'Ha descrizione',
'todo.addItem': 'Aggiungi nuova attività...',
'todo.newCategory': 'Nome categoria',
'todo.addCategory': 'Aggiungi categoria',
'todo.newItem': 'Nuova attività',
'todo.empty': 'Nessuna attività ancora. Aggiungi un\'attività per iniziare!',
'todo.filter.my': 'Le mie attività',
'todo.filter.overdue': 'Scaduta',
'todo.sidebar.tasks': 'Attività',
'todo.sidebar.categories': 'Categorie',
'todo.detail.title': 'Attività',
'todo.detail.description': 'Descrizione',
'todo.detail.category': 'Categoria',
'todo.detail.dueDate': 'Scadenza',
'todo.detail.assignedTo': 'Assegnato a',
'todo.detail.delete': 'Elimina',
'todo.detail.save': 'Salva modifiche',
'todo.detail.create': 'Crea attività',
'todo.detail.priority': 'Priorità',
'todo.detail.noPriority': 'Nessuna',
'todo.sortByPrio': 'Priorità',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nuova versione disponibile',
'settings.notificationPreferences.noChannels': 'Nessun canale di notifica configurato. Chiedi a un amministratore di configurare notifiche via e-mail o webhook.',
'settings.webhookUrl.label': 'URL webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Inserisci il tuo URL webhook Discord, Slack o personalizzato per ricevere notifiche.',
'settings.webhookUrl.save': 'Salva',
'settings.webhookUrl.saved': 'URL webhook salvato',
'settings.webhookUrl.test': 'Test',
'settings.webhookUrl.testSuccess': 'Webhook di test inviato con successo',
'settings.webhookUrl.testFailed': 'Invio webhook di test fallito',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'Le notifiche in-app sono sempre attive e non possono essere disabilitate globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook admin',
'admin.notifications.adminWebhookPanel.hint': 'Questo webhook viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dai webhook utente e si attiva automaticamente quando è configurato un URL.',
'admin.notifications.adminWebhookPanel.saved': 'URL webhook admin salvato',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook di test inviato con successo',
'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL',
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
'admin.tabs.notifications': 'Notifications',
'admin.tabs.notificationChannels': 'Canali di notifica',
'admin.tabs.adminNotifications': 'Notifiche admin',
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
'notifications.versionAvailable.button': 'Visualizza dettagli',
'notif.test.title': '[Test] Notifica',
'notif.test.simple.text': 'Questa è una semplice notifica di test.',
'notif.test.boolean.text': 'Accetti questa notifica di test?',
'notif.test.navigate.text': 'Clicca qui sotto per accedere alla dashboard.',
// Notifications
'notif.trip_invite.title': 'Invito al viaggio',
'notif.trip_invite.text': '{actor} ti ha invitato a {trip}',
'notif.booking_change.title': 'Prenotazione aggiornata',
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
'notif.trip_reminder.title': 'Promemoria viaggio',
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
'notif.vacay_invite.title': 'Invito Vacay Fusion',
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
'notif.photos_shared.title': 'Foto condivise',
'notif.photos_shared.text': '{actor} ha condiviso {count} foto in {trip}',
'notif.collab_message.title': 'Nuovo messaggio',
'notif.collab_message.text': '{actor} ha inviato un messaggio in {trip}',
'notif.packing_tagged.title': 'Assegnazione bagagli',
'notif.packing_tagged.text': '{actor} ti ha assegnato a {category} in {trip}',
'notif.version_available.title': 'Nuova versione disponibile',
'notif.version_available.text': 'TREK {version} è ora disponibile',
'notif.action.view_trip': 'Vedi viaggio',
'notif.action.view_collab': 'Vedi messaggi',
'notif.action.view_packing': 'Vedi bagagli',
'notif.action.view_photos': 'Vedi foto',
'notif.action.view_vacay': 'Vedi Vacay',
'notif.action.view_admin': 'Vai all\'admin',
'notif.action.view': 'Vedi',
'notif.action.accept': 'Accetta',
'notif.action.decline': 'Rifiuta',
'notif.generic.title': 'Notifica',
'notif.generic.text': 'Hai una nuova notifica',
'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto',
'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG',
}
export default it

View File

@@ -127,6 +127,12 @@ const nl: Record<string, string> = {
// Settings
'settings.title': 'Instellingen',
'settings.subtitle': 'Configureer je persoonlijke instellingen',
'settings.tabs.display': 'Weergave',
'settings.tabs.map': 'Kaart',
'settings.tabs.notifications': 'Meldingen',
'settings.tabs.integrations': 'Integraties',
'settings.tabs.account': 'Account',
'settings.tabs.about': 'Over',
'settings.map': 'Kaart',
'settings.mapTemplate': 'Kaartsjabloon',
'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...',
@@ -492,8 +498,8 @@ const nl: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie',
'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': 'Lijsten',
'admin.addons.catalog.packing.description': 'Paklijsten en to-dotaken voor je reizen',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget',
'admin.addons.catalog.documents.name': 'Documenten',
@@ -723,8 +729,10 @@ const nl: Record<string, string> = {
'atlas.unmark': 'Verwijderen',
'atlas.confirmMark': 'Dit land als bezocht markeren?',
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?',
'atlas.markVisited': 'Markeren als bezocht',
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst',
'atlas.addToBucket': 'Aan bucket list toevoegen',
'atlas.addPoi': 'Plaats toevoegen',
'atlas.searchCountry': 'Zoek een land...',
@@ -738,8 +746,8 @@ const nl: Record<string, string> = {
'trip.tabs.reservationsShort': 'Boek',
'trip.tabs.packing': 'Paklijst',
'trip.tabs.packingShort': 'Inpakken',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Lijsten',
'trip.tabs.listsShort': 'Lijsten',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Bestanden',
'trip.loading': 'Reis laden...',
@@ -934,11 +942,11 @@ const nl: Record<string, string> = {
'reservations.linkAssignment': 'Koppelen aan dagtoewijzing',
'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...',
'reservations.noAssignment': 'Geen koppeling (zelfstandig)',
'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': 'Prijs',
'reservations.budgetCategory': 'Budgetcategorie',
'reservations.budgetCategoryPlaceholder': 'bijv. Transport, Accommodatie',
'reservations.budgetCategoryAuto': 'Automatisch (op basis van boekingstype)',
'reservations.budgetHint': 'Er wordt automatisch een budgetpost aangemaakt bij het opslaan.',
'reservations.departureDate': 'Vertrek',
'reservations.arrivalDate': 'Aankomst',
'reservations.departureTime': 'Vertrektijd',
@@ -1404,7 +1412,7 @@ const nl: Record<string, string> = {
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Polls',
'collab.tabs.polls': 'Peilingen',
'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen',
@@ -1450,7 +1458,7 @@ const nl: Record<string, string> = {
'collab.notes.attachFiles': 'Bestanden bijvoegen',
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
'collab.polls.title': 'Polls',
'collab.polls.title': 'Peilingen',
'collab.polls.new': 'Nieuwe poll',
'collab.polls.empty': 'Nog geen polls',
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
@@ -1570,38 +1578,105 @@ const nl: Record<string, string> = {
'notifications.test.tripText': 'Testmelding voor reis "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.subtab.packing': 'Paklijst',
'todo.subtab.todo': 'Taken',
'todo.completed': 'voltooid',
'todo.filter.all': 'Alles',
'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.filter.done': 'Klaar',
'todo.uncategorized': 'Zonder categorie',
'todo.namePlaceholder': 'Taaknaam',
'todo.descriptionPlaceholder': 'Beschrijving (optioneel)',
'todo.unassigned': 'Niet toegewezen',
'todo.noCategory': 'Geen categorie',
'todo.hasDescription': 'Heeft beschrijving',
'todo.addItem': 'Nieuwe taak toevoegen...',
'todo.newCategory': 'Categorienaam',
'todo.addCategory': 'Categorie toevoegen',
'todo.newItem': 'Nieuwe taak',
'todo.empty': 'Nog geen taken. Voeg een taak toe om te beginnen!',
'todo.filter.my': 'Mijn taken',
'todo.filter.overdue': 'Verlopen',
'todo.sidebar.tasks': 'Taken',
'todo.sidebar.categories': 'Categorieën',
'todo.detail.title': 'Taak',
'todo.detail.description': 'Beschrijving',
'todo.detail.category': 'Categorie',
'todo.detail.dueDate': 'Vervaldatum',
'todo.detail.assignedTo': 'Toegewezen aan',
'todo.detail.delete': 'Verwijderen',
'todo.detail.save': 'Wijzigingen opslaan',
'todo.detail.create': 'Taak aanmaken',
'todo.detail.priority': 'Prioriteit',
'todo.detail.noPriority': 'Geen',
'todo.sortByPrio': 'Prioriteit',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nieuwe versie beschikbaar',
'settings.notificationPreferences.noChannels': 'Er zijn geen meldingskanalen geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te stellen.',
'settings.webhookUrl.label': 'Webhook-URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Voer je Discord-, Slack- of aangepaste webhook-URL in om meldingen te ontvangen.',
'settings.webhookUrl.save': 'Opslaan',
'settings.webhookUrl.saved': 'Webhook-URL opgeslagen',
'settings.webhookUrl.test': 'Testen',
'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden',
'settings.webhookUrl.testFailed': 'Test-webhook mislukt',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-app-meldingen zijn altijd actief en kunnen niet globaal worden uitgeschakeld.',
'admin.notifications.adminWebhookPanel.title': 'Admin-webhook',
'admin.notifications.adminWebhookPanel.hint': 'Deze webhook wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Hij staat los van gebruikerswebhooks en verstuurt automatisch als er een URL is ingesteld.',
'admin.notifications.adminWebhookPanel.saved': 'Admin-webhook-URL opgeslagen',
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden',
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
'admin.tabs.notifications': 'Meldingen',
'admin.tabs.notificationChannels': 'Meldingskanalen',
'admin.tabs.adminNotifications': 'Admin-meldingen',
'notifications.versionAvailable.title': 'Update beschikbaar',
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
'notifications.versionAvailable.button': 'Details bekijken',
'notif.test.title': '[Test] Melding',
'notif.test.simple.text': 'Dit is een eenvoudige testmelding.',
'notif.test.boolean.text': 'Accepteer je deze testmelding?',
'notif.test.navigate.text': 'Klik hieronder om naar het dashboard te gaan.',
// Notifications
'notif.trip_invite.title': 'Reisuitnodiging',
'notif.trip_invite.text': '{actor} heeft je uitgenodigd voor {trip}',
'notif.booking_change.title': 'Boeking bijgewerkt',
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
'notif.trip_reminder.title': 'Reisherinnering',
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
'notif.photos_shared.title': 'Foto\'s gedeeld',
'notif.photos_shared.text': '{actor} heeft {count} foto(\'s) gedeeld in {trip}',
'notif.collab_message.title': 'Nieuw bericht',
'notif.collab_message.text': '{actor} heeft een bericht gestuurd in {trip}',
'notif.packing_tagged.title': 'Paklijsttaak',
'notif.packing_tagged.text': '{actor} heeft je toegewezen aan {category} in {trip}',
'notif.version_available.title': 'Nieuwe versie beschikbaar',
'notif.version_available.text': 'TREK {version} is nu beschikbaar',
'notif.action.view_trip': 'Reis bekijken',
'notif.action.view_collab': 'Berichten bekijken',
'notif.action.view_packing': 'Paklijst bekijken',
'notif.action.view_photos': 'Foto\'s bekijken',
'notif.action.view_vacay': 'Vacay bekijken',
'notif.action.view_admin': 'Naar admin',
'notif.action.view': 'Bekijken',
'notif.action.accept': 'Accepteren',
'notif.action.decline': 'Weigeren',
'notif.generic.title': 'Melding',
'notif.generic.text': 'Je hebt een nieuwe melding',
'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis',
'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG',
}
export default nl

View File

@@ -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 kategor',
'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

View File

@@ -127,6 +127,12 @@ const ru: Record<string, string> = {
// Settings
'settings.title': 'Настройки',
'settings.subtitle': 'Настройте свои персональные параметры',
'settings.tabs.display': 'Дисплей',
'settings.tabs.map': 'Карта',
'settings.tabs.notifications': 'Уведомления',
'settings.tabs.integrations': 'Интеграции',
'settings.tabs.account': 'Аккаунт',
'settings.tabs.about': 'О приложении',
'settings.map': 'Карта',
'settings.mapTemplate': 'Шаблон карты',
'settings.mapTemplatePlaceholder.select': 'Выберите шаблон...',
@@ -492,8 +498,8 @@ const ru: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами',
'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': 'Списки',
'admin.addons.catalog.packing.description': 'Списки вещей и задачи для ваших поездок',
'admin.addons.catalog.budget.name': 'Бюджет',
'admin.addons.catalog.budget.description': 'Отслеживайте расходы и планируйте бюджет поездки',
'admin.addons.catalog.documents.name': 'Документы',
@@ -723,8 +729,10 @@ const ru: Record<string, string> = {
'atlas.unmark': 'Удалить',
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?',
'atlas.markVisited': 'Отметить как посещённую',
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых',
'atlas.addToBucket': 'В список желаний',
'atlas.addPoi': 'Добавить место',
'atlas.searchCountry': 'Поиск страны...',
@@ -738,8 +746,8 @@ const ru: Record<string, string> = {
'trip.tabs.reservationsShort': 'Брони',
'trip.tabs.packing': 'Список вещей',
'trip.tabs.packingShort': 'Вещи',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': 'Списки',
'trip.tabs.listsShort': 'Списки',
'trip.tabs.budget': 'Бюджет',
'trip.tabs.files': 'Файлы',
'trip.loading': 'Загрузка поездки...',
@@ -934,11 +942,11 @@ const ru: Record<string, string> = {
'reservations.linkAssignment': 'Привязать к назначению дня',
'reservations.pickAssignment': 'Выберите назначение из вашего плана...',
'reservations.noAssignment': 'Без привязки (самостоятельное)',
'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': 'Цена',
'reservations.budgetCategory': 'Категория бюджета',
'reservations.budgetCategoryPlaceholder': 'напр. Транспорт, Проживание',
'reservations.budgetCategoryAuto': 'Авто (по типу бронирования)',
'reservations.budgetHint': 'При сохранении будет автоматически создана запись бюджета.',
'reservations.departureDate': 'Вылет',
'reservations.arrivalDate': 'Прилёт',
'reservations.departureTime': 'Время вылета',
@@ -1570,38 +1578,105 @@ const ru: Record<string, string> = {
'notifications.test.tripText': 'Тестовое уведомление для поездки "{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': 'Список вещей',
'todo.subtab.todo': 'Задачи',
'todo.completed': 'выполнено',
'todo.filter.all': 'Все',
'todo.filter.open': 'Открытые',
'todo.filter.done': 'Выполненные',
'todo.uncategorized': 'Без категории',
'todo.namePlaceholder': 'Название задачи',
'todo.descriptionPlaceholder': 'Описание (необязательно)',
'todo.unassigned': 'Не назначено',
'todo.noCategory': 'Без категории',
'todo.hasDescription': 'Есть описание',
'todo.addItem': 'Добавить новую задачу...',
'todo.newCategory': 'Название категории',
'todo.addCategory': 'Добавить категорию',
'todo.newItem': 'Новая задача',
'todo.empty': 'Задач пока нет. Добавьте задачу, чтобы начать!',
'todo.filter.my': 'Мои задачи',
'todo.filter.overdue': 'Просроченные',
'todo.sidebar.tasks': 'Задачи',
'todo.sidebar.categories': 'Категории',
'todo.detail.title': 'Задача',
'todo.detail.description': 'Описание',
'todo.detail.category': 'Категория',
'todo.detail.dueDate': 'Срок выполнения',
'todo.detail.assignedTo': 'Назначено',
'todo.detail.delete': 'Удалить',
'todo.detail.save': 'Сохранить изменения',
'todo.detail.create': 'Создать задачу',
'todo.detail.priority': 'Приоритет',
'todo.detail.noPriority': 'Нет',
'todo.sortByPrio': 'Приоритет',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Доступна новая версия',
'settings.notificationPreferences.noChannels': 'Каналы уведомлений не настроены. Попросите администратора настроить уведомления по электронной почте или через webhook.',
'settings.webhookUrl.label': 'URL вебхука',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.',
'settings.webhookUrl.save': 'Сохранить',
'settings.webhookUrl.saved': 'URL вебхука сохранён',
'settings.webhookUrl.test': 'Тест',
'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен',
'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'Уведомления в приложении всегда активны и не могут быть отключены глобально.',
'admin.notifications.adminWebhookPanel.title': 'Вебхук администратора',
'admin.notifications.adminWebhookPanel.hint': 'Этот вебхук используется исключительно для уведомлений администратора (например, оповещения о версиях). Он независим от пользовательских вебхуков и отправляется автоматически при наличии URL.',
'admin.notifications.adminWebhookPanel.saved': 'URL вебхука администратора сохранён',
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
'admin.tabs.notifications': 'Уведомления',
'admin.tabs.notificationChannels': 'Каналы уведомлений',
'admin.tabs.adminNotifications': 'Уведомления администратора',
'notifications.versionAvailable.title': 'Доступно обновление',
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
'notifications.versionAvailable.button': 'Подробнее',
'notif.test.title': '[Тест] Уведомление',
'notif.test.simple.text': 'Это простое тестовое уведомление.',
'notif.test.boolean.text': 'Вы принимаете это тестовое уведомление?',
'notif.test.navigate.text': 'Нажмите ниже для перехода на панель управления.',
// Notifications
'notif.trip_invite.title': 'Приглашение в поездку',
'notif.trip_invite.text': '{actor} пригласил вас в {trip}',
'notif.booking_change.title': 'Бронирование обновлено',
'notif.booking_change.text': '{actor} обновил бронирование в {trip}',
'notif.trip_reminder.title': 'Напоминание о поездке',
'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!',
'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
'notif.photos_shared.title': 'Фото опубликованы',
'notif.photos_shared.text': '{actor} поделился {count} фото в {trip}',
'notif.collab_message.title': 'Новое сообщение',
'notif.collab_message.text': '{actor} отправил сообщение в {trip}',
'notif.packing_tagged.title': 'Задание для упаковки',
'notif.packing_tagged.text': '{actor} назначил вас в {category} в {trip}',
'notif.version_available.title': 'Доступна новая версия',
'notif.version_available.text': 'TREK {version} теперь доступен',
'notif.action.view_trip': 'Открыть поездку',
'notif.action.view_collab': 'Открыть сообщения',
'notif.action.view_packing': 'Открыть упаковку',
'notif.action.view_photos': 'Открыть фото',
'notif.action.view_vacay': 'Открыть Vacay',
'notif.action.view_admin': 'Перейти в админ',
'notif.action.view': 'Открыть',
'notif.action.accept': 'Принять',
'notif.action.decline': 'Отклонить',
'notif.generic.title': 'Уведомление',
'notif.generic.text': 'У вас новое уведомление',
'notif.dev.unknown_event.title': '[DEV] Неизвестное событие',
'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG',
}
export default ru

View File

@@ -127,6 +127,12 @@ const zh: Record<string, string> = {
// Settings
'settings.title': '设置',
'settings.subtitle': '配置你的个人设置',
'settings.tabs.display': '显示',
'settings.tabs.map': '地图',
'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '集成',
'settings.tabs.account': '账户',
'settings.tabs.about': '关于',
'settings.map': '地图',
'settings.mapTemplate': '地图模板',
'settings.mapTemplatePlaceholder.select': '选择模板...',
@@ -492,8 +498,8 @@ const zh: Record<string, string> = {
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议',
'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': '列表',
'admin.addons.catalog.packing.description': '行程打包清单与待办任务',
'admin.addons.catalog.budget.name': '预算',
'admin.addons.catalog.budget.description': '跟踪支出并规划旅行预算',
'admin.addons.catalog.documents.name': '文档',
@@ -723,8 +729,10 @@ const zh: Record<string, string> = {
'atlas.unmark': '移除',
'atlas.confirmMark': '将此国家标记为已访问?',
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?',
'atlas.markVisited': '标记为已访问',
'atlas.markVisitedHint': '将此国家添加到已访问列表',
'atlas.markRegionVisitedHint': '将此地区添加到已访问列表',
'atlas.addToBucket': '添加到心愿单',
'atlas.addPoi': '添加地点',
'atlas.searchCountry': '搜索国家...',
@@ -738,8 +746,8 @@ const zh: Record<string, string> = {
'trip.tabs.reservationsShort': '预订',
'trip.tabs.packing': '行李清单',
'trip.tabs.packingShort': '行李',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.lists': '列表',
'trip.tabs.listsShort': '列表',
'trip.tabs.budget': '预算',
'trip.tabs.files': '文件',
'trip.loading': '加载旅行中...',
@@ -934,11 +942,11 @@ const zh: Record<string, string> = {
'reservations.linkAssignment': '关联日程分配',
'reservations.pickAssignment': '从计划中选择一个分配...',
'reservations.noAssignment': '无关联(独立)',
'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': '价格',
'reservations.budgetCategory': '预算类别',
'reservations.budgetCategoryPlaceholder': '例:交通、住宿',
'reservations.budgetCategoryAuto': '自动(按预订类型)',
'reservations.budgetHint': '保存时将自动创建预算条目。',
'reservations.departureDate': '出发',
'reservations.arrivalDate': '到达',
'reservations.departureTime': '出发时间',
@@ -1570,38 +1578,105 @@ const zh: Record<string, string> = {
'notifications.test.tripText': '行程"{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': '行李清单',
'todo.subtab.todo': '待办事项',
'todo.completed': '已完成',
'todo.filter.all': '全部',
'todo.filter.open': '进行中',
'todo.filter.done': '已完成',
'todo.uncategorized': '未分类',
'todo.namePlaceholder': '任务名称',
'todo.descriptionPlaceholder': '描述(可选)',
'todo.unassigned': '未分配',
'todo.noCategory': '无分类',
'todo.hasDescription': '有描述',
'todo.addItem': '添加新任务...',
'todo.newCategory': '分类名称',
'todo.addCategory': '添加分类',
'todo.newItem': '新任务',
'todo.empty': '暂无任务,添加一个任务开始吧!',
'todo.filter.my': '我的任务',
'todo.filter.overdue': '已逾期',
'todo.sidebar.tasks': '任务',
'todo.sidebar.categories': '分类',
'todo.detail.title': '任务',
'todo.detail.description': '描述',
'todo.detail.category': '分类',
'todo.detail.dueDate': '截止日期',
'todo.detail.assignedTo': '分配给',
'todo.detail.delete': '删除',
'todo.detail.save': '保存更改',
'todo.detail.create': '创建任务',
'todo.detail.priority': '优先级',
'todo.detail.noPriority': '',
'todo.sortByPrio': '优先级',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': '有新版本可用',
'settings.notificationPreferences.noChannels': '未配置通知渠道。请联系管理员设置电子邮件或 Webhook 通知。',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。',
'settings.webhookUrl.save': '保存',
'settings.webhookUrl.saved': 'Webhook URL 已保存',
'settings.webhookUrl.test': '测试',
'settings.webhookUrl.testSuccess': '测试 Webhook 发送成功',
'settings.webhookUrl.testFailed': '测试 Webhook 失败',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': '应用内通知始终处于活跃状态,无法全局禁用。',
'admin.notifications.adminWebhookPanel.title': '管理员 Webhook',
'admin.notifications.adminWebhookPanel.hint': '此 Webhook 专用于管理员通知(如版本更新提醒)。它与用户 Webhook 相互独立,配置 URL 后自动触发。',
'admin.notifications.adminWebhookPanel.saved': '管理员 Webhook URL 已保存',
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后Webhook 将自动触发。',
'admin.tabs.notifications': '通知',
'admin.tabs.notificationChannels': '通知渠道',
'admin.tabs.adminNotifications': '管理员通知',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
'notifications.versionAvailable.button': '查看详情',
'notif.test.title': '[测试] 通知',
'notif.test.simple.text': '这是一条简单的测试通知。',
'notif.test.boolean.text': '您是否接受此测试通知?',
'notif.test.navigate.text': '点击下方前往控制台。',
// Notifications
'notif.trip_invite.title': '旅行邀请',
'notif.trip_invite.text': '{actor} 邀请您加入 {trip}',
'notif.booking_change.title': '预订已更新',
'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订',
'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!',
'notif.vacay_invite.title': 'Vacay 融合邀请',
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
'notif.photos_shared.title': '照片已分享',
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 张照片',
'notif.collab_message.title': '新消息',
'notif.collab_message.text': '{actor} 在 {trip} 中发送了消息',
'notif.packing_tagged.title': '行李分配',
'notif.packing_tagged.text': '{actor} 将您分配到 {trip} 中的 {category}',
'notif.version_available.title': '新版本可用',
'notif.version_available.text': 'TREK {version} 现已可用',
'notif.action.view_trip': '查看旅行',
'notif.action.view_collab': '查看消息',
'notif.action.view_packing': '查看行李',
'notif.action.view_photos': '查看照片',
'notif.action.view_vacay': '查看 Vacay',
'notif.action.view_admin': '前往管理',
'notif.action.view': '查看',
'notif.action.accept': '接受',
'notif.action.decline': '拒绝',
'notif.generic.title': '通知',
'notif.generic.text': '您有一条新通知',
'notif.dev.unknown_event.title': '[DEV] 未知事件',
'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册',
}
export default zh

View File

@@ -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} />}

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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'"],

View File

@@ -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) {

View File

@@ -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);
`);
}

View File

@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
}
scheduler.start();
scheduler.startTripReminders();
scheduler.startVersionCheck();
scheduler.startDemoReset();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();

View File

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

View File

@@ -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) => {

View File

@@ -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(() => {});
});
});

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -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(() => {});
});
}
});

View File

@@ -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(() => {});
});
});

View File

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

View File

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

View File

@@ -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 };

View File

@@ -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() {

View File

@@ -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 ────────────────────────────────────────────────────────

View File

@@ -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();

View File

@@ -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 };

View File

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

View 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}`);
});
}
}
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── 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' };
}
}

View File

@@ -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) {

View File

@@ -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 {};

View File

@@ -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);

View File

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

View File

@@ -56,6 +56,7 @@ const RESET_TABLES = [
'vacay_plans',
'atlas_visited_countries',
'atlas_bucket_list',
'notification_channel_preferences',
'notifications',
'audit_log',
'user_settings',

View File

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

View 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);
});
});

View 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();
});
});

View File

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

View 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);
});
});

View File

@@ -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('&lt;script&gt;');
});
it('escapes HTML special characters in body', () => {
const html = buildEmailHtml('Subject', '<img src=x onerror=alert(1)>', 'en');
expect(html).toContain('&lt;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('&quot;');
expect(html).not.toContain('"hello"');
});
it('escapes ampersands in body', () => {
const html = buildEmailHtml('Subject', 'a & b', 'en');
expect(html).toContain('&amp;');
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('&lt;evil&gt;');
expect(html).toContain('&lt;script&gt;');
});
});
// ── 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();
});
});

View 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();
});
});