mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
20 Commits
6fdf76952f
..
v3.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b |
@@ -400,7 +400,6 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.8
|
version: 3.0.7
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.8"
|
appVersion: "3.0.7"
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ data:
|
|||||||
{{- if .Values.env.FORCE_HTTPS }}
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
|
||||||
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ env:
|
|||||||
# Also used as the base URL for links in email notifications and other external links.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
|
||||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.8",
|
"version": "3.0.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.8",
|
"version": "3.0.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.8",
|
"version": "3.0.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
const items = grouped.get(oldName) || []
|
const items = grouped[oldName] || []
|
||||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||||
}
|
}
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
const fmtTime = (v) => {
|
const fmtTime = (v) => formatTime12(v, is12h)
|
||||||
if (!v) return v
|
|
||||||
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
|
||||||
return formatTime12(v, is12h)
|
|
||||||
}
|
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const collapsed = collapsedProp
|
const collapsed = collapsedProp
|
||||||
const toggleCollapse = () => onToggleCollapse?.()
|
const toggleCollapse = () => onToggleCollapse?.()
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ const mockDayNotesState = vi.hoisted(() => ({
|
|||||||
moveNote: vi.fn(),
|
moveNote: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockPermissionsState = vi.hoisted(() => ({
|
|
||||||
canDo: true,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock('../../api/client', async (importOriginal) => {
|
vi.mock('../../api/client', async (importOriginal) => {
|
||||||
@@ -83,7 +79,7 @@ vi.mock('../../store/permissionsStore', async (importOriginal) => {
|
|||||||
const actual = await importOriginal() as any
|
const actual = await importOriginal() as any
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useCanDo: () => () => mockPermissionsState.canDo,
|
useCanDo: () => () => true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -129,7 +125,6 @@ beforeEach(() => {
|
|||||||
// Reset mutable day-notes state
|
// Reset mutable day-notes state
|
||||||
mockDayNotesState.noteUi = {}
|
mockDayNotesState.noteUi = {}
|
||||||
mockDayNotesState.dayNotes = {}
|
mockDayNotesState.dayNotes = {}
|
||||||
mockPermissionsState.canDo = true
|
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
|
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
|
||||||
@@ -899,136 +894,21 @@ describe('DayPlanSidebar', () => {
|
|||||||
|
|
||||||
// ── ICS export click ─────────────────────────────────────────────────
|
// ── ICS export click ─────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button first asks link or download', async () => {
|
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
if (!navigator.clipboard) {
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
||||||
const url = String(input)
|
|
||||||
const method = (init?.method || 'GET').toUpperCase()
|
|
||||||
|
|
||||||
if (url === '/api/trips/1/subscribe.ics' && method === 'GET') {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ token: null }),
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === '/api/trips/1/subscribe.ics' && method === 'POST') {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({
|
|
||||||
url: 'https://example.com/api/shared/token/calendar.ics',
|
|
||||||
webcal_url: 'webcal://example.com/api/shared/token/calendar.ics',
|
|
||||||
}),
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === '/api/trips/1/subscribe.ics' && method === 'DELETE') {
|
|
||||||
return { ok: true } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === '/api/trips/1/export.ics' && method === 'GET') {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unexpected fetch call: ${method} ${url}`)
|
|
||||||
})
|
|
||||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
|
||||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
|
||||||
|
|
||||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
|
||||||
await user.click(screen.getByText('ICS').closest('button')!)
|
|
||||||
|
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
|
||||||
expect(await screen.findByText('Calendar share')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Create a subscription link for calendar apps, or download the ICS file.')).toBeInTheDocument()
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Create link' }))
|
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' })))
|
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('https://example.com/api/shared/token/calendar.ics')).toBeInTheDocument()
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Copy' }))
|
|
||||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith('https://example.com/api/shared/token/calendar.ics'))
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Delete link' }))
|
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'DELETE' })))
|
|
||||||
expect(screen.getByRole('button', { name: 'Create link' })).toBeInTheDocument()
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
|
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
|
||||||
expect(createObjURL).toHaveBeenCalled()
|
|
||||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
|
||||||
|
|
||||||
fetchSpy.mockRestore()
|
|
||||||
clipboardSpy.mockRestore()
|
|
||||||
createObjURL.mockRestore()
|
|
||||||
revokeObjURL.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-097: opening ICS dialog shows existing generated link when present', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const expectedUrl = `${window.location.origin}/api/shared/existing-token/calendar.ics`
|
|
||||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ token: 'existing-token' }),
|
|
||||||
} as any)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
|
||||||
} as any)
|
|
||||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
|
||||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
|
||||||
|
|
||||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
|
||||||
await user.click(screen.getByText('ICS').closest('button')!)
|
|
||||||
|
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
|
||||||
expect(await screen.findByDisplayValue(expectedUrl)).toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('button', { name: 'Create link' })).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Copy' }))
|
|
||||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith(expectedUrl))
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
|
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
|
||||||
expect(createObjURL).toHaveBeenCalled()
|
|
||||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' }))
|
|
||||||
|
|
||||||
fetchSpy.mockRestore()
|
|
||||||
clipboardSpy.mockRestore()
|
|
||||||
createObjURL.mockRestore()
|
|
||||||
revokeObjURL.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-099: ICS dialog hides delete link button without share_manage permission', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
mockPermissionsState.canDo = false
|
|
||||||
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ token: 'existing-token' }),
|
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||||
} as any)
|
} as any)
|
||||||
|
// Mock URL.createObjectURL
|
||||||
|
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||||
|
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||||
await user.click(screen.getByText('ICS').closest('button')!)
|
await user.click(screen.getByText('ICS').closest('button')!)
|
||||||
|
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
|
||||||
expect(await screen.findByDisplayValue(`${window.location.origin}/api/shared/existing-token/calendar.ics`)).toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('button', { name: 'Delete link' })).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
fetchSpy.mockRestore()
|
fetchSpy.mockRestore()
|
||||||
|
createObjURL.mockRestore()
|
||||||
|
revokeObjURL.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── openAddNote button click ──────────────────────────────────────────
|
// ── openAddNote button click ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon, Link2, Copy } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
@@ -225,7 +225,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const tripActions = useRef(useTripStore.getState()).current
|
const tripActions = useRef(useTripStore.getState()).current
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditDays = can('day_edit', trip)
|
const canEditDays = can('day_edit', trip)
|
||||||
const canManageShare = can('share_manage', trip)
|
|
||||||
|
|
||||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||||
|
|
||||||
@@ -253,9 +252,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||||
const [transportDetail, setTransportDetail] = useState(null)
|
const [transportDetail, setTransportDetail] = useState(null)
|
||||||
const [icsDialog, setIcsDialog] = useState<{ url: string; webcal_url: string; creating: boolean } | null>(null)
|
|
||||||
const [icsCopied, setIcsCopied] = useState(false)
|
|
||||||
const icsCopyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,99 +284,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
|
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const closeIcsDialog = () => setIcsDialog(null)
|
|
||||||
|
|
||||||
const handleIcsOpenDialog = async () => {
|
|
||||||
setIcsCopied(false)
|
|
||||||
setIcsDialog({ url: '', webcal_url: '', creating: true })
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { credentials: 'include' })
|
|
||||||
if (!res.ok) throw new Error()
|
|
||||||
const data = await res.json() as { token?: string | null }
|
|
||||||
if (data.token) {
|
|
||||||
const url = `${window.location.origin}/api/shared/${encodeURIComponent(data.token)}/calendar.ics`
|
|
||||||
const webcal_url = url.replace(/^https?:\/\//, 'webcal://')
|
|
||||||
setIcsDialog({ url, webcal_url, creating: false })
|
|
||||||
} else {
|
|
||||||
setIcsDialog({ url: '', webcal_url: '', creating: false })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setIcsDialog({ url: '', webcal_url: '', creating: false })
|
|
||||||
toast.error(t('dayplan.calendarLinkFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIcsCreateLink = async () => {
|
|
||||||
if (icsDialog?.creating) return
|
|
||||||
setIcsDialog(prev => prev ? { ...prev, creating: true } : { url: '', webcal_url: '', creating: true })
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { method: 'POST', credentials: 'include' })
|
|
||||||
if (!res.ok) throw new Error()
|
|
||||||
const data = await res.json() as { url?: string; webcal_url?: string }
|
|
||||||
const shareUrl = data.url
|
|
||||||
const openUrl = data.webcal_url || data.url
|
|
||||||
if (!shareUrl || !openUrl) throw new Error()
|
|
||||||
setIcsDialog({ url: shareUrl, webcal_url: openUrl, creating: false })
|
|
||||||
} catch {
|
|
||||||
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
|
|
||||||
toast.error(t('dayplan.calendarLinkFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIcsCopyLink = async () => {
|
|
||||||
if (!icsDialog?.url) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(icsDialog.url)
|
|
||||||
setIcsCopied(true)
|
|
||||||
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
|
|
||||||
icsCopyTimerRef.current = setTimeout(() => setIcsCopied(false), 2000)
|
|
||||||
} catch {
|
|
||||||
toast.error(t('dayplan.calendarCopyFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIcsDeleteLink = async () => {
|
|
||||||
if (!icsDialog || icsDialog.creating) return
|
|
||||||
setIcsDialog(prev => prev ? { ...prev, creating: true } : prev)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error()
|
|
||||||
setIcsCopied(false)
|
|
||||||
setIcsDialog(prev => prev ? { ...prev, url: '', webcal_url: '', creating: false } : prev)
|
|
||||||
toast.success(t('dayplan.calendarLinkDeleted'))
|
|
||||||
} catch {
|
|
||||||
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
|
|
||||||
toast.error(t('dayplan.calendarDeleteFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIcsDownload = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
|
||||||
if (!res.ok) throw new Error()
|
|
||||||
const blob = await res.blob()
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `${trip?.title || 'trip'}.ics`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
toast.success(t('dayplan.calendarDownloaded'))
|
|
||||||
closeIcsDialog()
|
|
||||||
} catch {
|
|
||||||
toast.error(t('dayplan.calendarExportFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||||
const getDragData = (e) => {
|
const getDragData = (e) => {
|
||||||
const dt = e?.dataTransfer
|
const dt = e?.dataTransfer
|
||||||
@@ -1090,7 +993,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleIcsOpenDialog}
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${trip?.title || 'trip'}.ics`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||||
|
}}
|
||||||
onMouseEnter={() => setIcsHover(true)}
|
onMouseEnter={() => setIcsHover(true)}
|
||||||
onMouseLeave={() => setIcsHover(false)}
|
onMouseLeave={() => setIcsHover(false)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1659,10 +1576,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{res.reservation_time?.includes('T') && (
|
{res.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontWeight: 400 }}>
|
<span style={{ fontWeight: 400 }}>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
{res.reservation_end_time && ` – ${(() => {
|
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
|
||||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
|
||||||
})()}`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -2211,105 +2125,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ICS subscription dialog */}
|
|
||||||
{icsDialog && ReactDOM.createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 10000,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
|
||||||
}} onClick={closeIcsDialog}>
|
|
||||||
<div style={{
|
|
||||||
width: 420, maxWidth: '92vw', background: 'var(--bg-card)', borderRadius: 16,
|
|
||||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 12, position: 'relative',
|
|
||||||
}} onClick={e => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={closeIcsDialog}
|
|
||||||
aria-label="Close"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 10,
|
|
||||||
right: 10,
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '1px solid var(--border-faint)',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
|
||||||
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
|
||||||
{t('dayplan.calendarShareTitle')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
|
|
||||||
{t('dayplan.calendarShareDescription')}
|
|
||||||
</p>
|
|
||||||
{icsDialog.url ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
|
||||||
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
|
||||||
}}>
|
|
||||||
<input type="text" value={icsDialog.url} readOnly style={{
|
|
||||||
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
|
||||||
outline: 'none', fontFamily: 'monospace',
|
|
||||||
}} />
|
|
||||||
<button onClick={handleIcsCopyLink} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
|
||||||
border: 'none', background: icsCopied ? '#16a34a' : 'var(--accent)', color: icsCopied ? 'white' : 'var(--accent-text)',
|
|
||||||
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
|
||||||
}}>
|
|
||||||
{icsCopied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{canManageShare && (
|
|
||||||
<button onClick={handleIcsDeleteLink} style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
<Trash2 size={11} /> {t('dayplan.calendarDeleteLink')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={handleIcsCreateLink}
|
|
||||||
disabled={!canManageShare}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
||||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
|
||||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
{canManageShare ? <><Link2 size={12} /> {t('dayplan.calendarCreateLink')}</> : <>{t('dayplan.calendarCreateLinkNoPermission')}</>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleIcsDownload}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
||||||
width: '100%', padding: '8px 0', borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 12, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('dayplan.calendarDownloadFile')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Transport-Detail-Modal */}
|
{/* Transport-Detail-Modal */}
|
||||||
{transportDetail && ReactDOM.createPortal(
|
{transportDetail && ReactDOM.createPortal(
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -182,8 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
let combinedEndTime = form.reservation_end_time
|
let combinedEndTime = form.reservation_end_time
|
||||||
if (form.end_date) {
|
if (form.end_date) {
|
||||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||||
} else if (form.reservation_end_time && form.reservation_time) {
|
|
||||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
if (isBudgetEnabled) {
|
||||||
if (form.price) metadata.price = form.price
|
if (form.price) metadata.price = form.price
|
||||||
|
|||||||
@@ -236,16 +236,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtDate(r.reservation_time)}
|
{fmtDate(r.reservation_time)}
|
||||||
{(() => {
|
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||||
const endDatePart = r.reservation_end_time
|
|
||||||
? r.reservation_end_time.includes('T')
|
|
||||||
? r.reservation_end_time.split('T')[0]
|
|
||||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
|
||||||
? r.reservation_end_time
|
|
||||||
: null
|
|
||||||
: null
|
|
||||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
|
||||||
})() && (
|
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
<> – {fmtDate(r.reservation_end_time)}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -305,18 +305,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notificationsActive': 'Active channel',
|
'settings.notificationsActive': 'Active channel',
|
||||||
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
||||||
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||||
'dayplan.calendarShareTitle': 'Calendar share',
|
|
||||||
'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.',
|
|
||||||
'dayplan.calendarCreateLink': 'Create link',
|
|
||||||
'dayplan.calendarCreateLinkNoPermission': 'You do not have permission to create calendar links for this trip.',
|
|
||||||
'dayplan.calendarDeleteLink': 'Delete link',
|
|
||||||
'dayplan.calendarDownloadFile': 'Download ICS file',
|
|
||||||
'dayplan.calendarLinkFailed': 'Calendar link failed',
|
|
||||||
'dayplan.calendarDeleteFailed': 'Delete link failed',
|
|
||||||
'dayplan.calendarCopyFailed': 'Copy failed',
|
|
||||||
'dayplan.calendarDownloaded': 'ICS downloaded',
|
|
||||||
'dayplan.calendarExportFailed': 'ICS export failed',
|
|
||||||
'dayplan.calendarLinkDeleted': 'Calendar link deleted',
|
|
||||||
'share.linkTitle': 'Public Link',
|
'share.linkTitle': 'Public Link',
|
||||||
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||||
'share.createLink': 'Create link',
|
'share.createLink': 'Create link',
|
||||||
|
|||||||
@@ -343,10 +343,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) {
|
if (tripId) tripActions.loadReservations(tripId)
|
||||||
tripActions.loadReservations(tripId)
|
|
||||||
tripActions.loadBudgetItems?.(tripId)
|
|
||||||
}
|
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
@@ -1109,7 +1106,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ services:
|
|||||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
|
||||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||||
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
|
|||||||
|
|
||||||
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||||
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
||||||
# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
|
|
||||||
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
||||||
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
||||||
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.8",
|
"version": "3.0.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.8",
|
"version": "3.0.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.8",
|
"version": "3.0.7",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export function createApp(): express.Application {
|
|||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (shouldForceHttps) {
|
if (shouldForceHttps) {
|
||||||
|
|||||||
@@ -2130,22 +2130,6 @@ function runMigrations(db: Database.Database): void {
|
|||||||
'ON journey_entries(journey_id, entry_date, sort_order)'
|
'ON journey_entries(journey_id, entry_date, sort_order)'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
() => {
|
|
||||||
// Dedicated calendar subscription tokens for trips
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
trip_id INTEGER NOT NULL,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
created_by INTEGER NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
|
||||||
UNIQUE(trip_id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_share_trip ON calendar_share_tokens(trip_id)');
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -202,15 +202,6 @@ function createTables(db: Database.Database): void {
|
|||||||
UNIQUE(trip_id, user_id)
|
UNIQUE(trip_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(trip_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS day_notes (
|
CREATE TABLE IF NOT EXISTS day_notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -58,16 +58,4 @@ router.get('/shared/:token', (req: Request, res: Response) => {
|
|||||||
res.json(data);
|
res.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public calendar subscription payload (no auth required)
|
|
||||||
router.get('/shared/:token/calendar.ics', (req: Request, res: Response) => {
|
|
||||||
const { token } = req.params;
|
|
||||||
const exported = shareService.getSharedTripICS(token);
|
|
||||||
if (!exported) return res.status(404).json({ error: 'Invalid or expired link' });
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
|
||||||
// Inline lets calendar clients subscribe/fetch from URL instead of forced download.
|
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${exported.filename}"`);
|
|
||||||
res.send(exported.ics);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import { listItems as listTodoItems } from '../services/todoService';
|
|||||||
import { listBudgetItems } from '../services/budgetService';
|
import { listBudgetItems } from '../services/budgetService';
|
||||||
import { listReservations } from '../services/reservationService';
|
import { listReservations } from '../services/reservationService';
|
||||||
import { listFiles } from '../services/fileService';
|
import { listFiles } from '../services/fileService';
|
||||||
import { createOrUpdateCalendarShareLink, getCalendarShareLink, deleteCalendarShareLink } from '../services/shareService';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -356,64 +355,4 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ICS calendar subscription link ───────────────────────────────────────
|
|
||||||
|
|
||||||
router.get('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
if (!canAccessTrip(req.params.id, authReq.user.id)) {
|
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getCalendarShareLink(req.params.id);
|
|
||||||
const token = existing?.token ?? null;
|
|
||||||
|
|
||||||
const host = req.get('host');
|
|
||||||
if (!host) return res.status(500).json({ error: 'Host header missing' });
|
|
||||||
|
|
||||||
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|
||||||
? req.headers['x-forwarded-proto'].split(',')[0].trim()
|
|
||||||
: null;
|
|
||||||
const protocol = forwardedProto || req.protocol;
|
|
||||||
const url = token ? `${protocol}://${host}/api/shared/${encodeURIComponent(token)}/calendar.ics` : null;
|
|
||||||
const webcal_url = url ? url.replace(/^https?:\/\//, 'webcal://') : null;
|
|
||||||
|
|
||||||
res.json({ url, webcal_url, token, created: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
|
||||||
if (!access) {
|
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
|
||||||
}
|
|
||||||
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
|
|
||||||
return res.status(403).json({ error: 'No permission' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id);
|
|
||||||
const host = req.get('host');
|
|
||||||
if (!host) return res.status(500).json({ error: 'Host header missing' });
|
|
||||||
|
|
||||||
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|
||||||
? req.headers['x-forwarded-proto'].split(',')[0].trim()
|
|
||||||
: null;
|
|
||||||
const protocol = forwardedProto || req.protocol;
|
|
||||||
const url = `${protocol}://${host}/api/shared/${encodeURIComponent(result.token)}/calendar.ics`;
|
|
||||||
const webcal_url = url.replace(/^https?:\/\//, 'webcal://');
|
|
||||||
|
|
||||||
res.status(result.created ? 201 : 200).json({ url, webcal_url, token: result.token, created: result.created });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
|
||||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
|
||||||
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
|
|
||||||
return res.status(403).json({ error: 'No permission' });
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteCalendarShareLink(req.params.id);
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
import { avatarUrl } from './authService';
|
|
||||||
|
|
||||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||||
|
|
||||||
@@ -132,10 +131,7 @@ export function listBags(tripId: string | number) {
|
|||||||
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
||||||
membersByBag.get(m.bag_id)!.push(m);
|
membersByBag.get(m.bag_id)!.push(m);
|
||||||
}
|
}
|
||||||
return bags.map(b => ({
|
return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
|
||||||
...b,
|
|
||||||
members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||||
@@ -144,12 +140,11 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
|
|||||||
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
||||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||||
for (const uid of userIds) ins.run(bagId, uid);
|
for (const uid of userIds) ins.run(bagId, uid);
|
||||||
const rows = db.prepare(`
|
return db.prepare(`
|
||||||
SELECT bm.user_id, u.username, u.avatar
|
SELECT bm.user_id, u.username, u.avatar
|
||||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.bag_id = ?
|
WHERE bm.bag_id = ?
|
||||||
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
|
`).all(bagId);
|
||||||
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
||||||
@@ -265,7 +260,7 @@ export function getCategoryAssignees(tripId: string | number) {
|
|||||||
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
||||||
for (const row of rows as any[]) {
|
for (const row of rows as any[]) {
|
||||||
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
||||||
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
|
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignees;
|
return assignees;
|
||||||
@@ -279,13 +274,12 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
|
|||||||
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare(`
|
return db.prepare(`
|
||||||
SELECT pca.user_id, u.username, u.avatar
|
SELECT pca.user_id, u.username, u.avatar
|
||||||
FROM packing_category_assignees pca
|
FROM packing_category_assignees pca
|
||||||
JOIN users u ON pca.user_id = u.id
|
JOIN users u ON pca.user_id = u.id
|
||||||
WHERE pca.trip_id = ? AND pca.category_name = ?
|
WHERE pca.trip_id = ? AND pca.category_name = ?
|
||||||
`).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
|
`).all(tripId, categoryName);
|
||||||
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reorder ────────────────────────────────────────────────────────────────
|
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||||
import { exportICS } from './tripService';
|
|
||||||
|
|
||||||
interface SharePermissions {
|
interface SharePermissions {
|
||||||
share_map?: boolean;
|
share_map?: boolean;
|
||||||
@@ -21,11 +20,6 @@ interface ShareTokenInfo {
|
|||||||
share_collab: boolean;
|
share_collab: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalendarShareTokenInfo {
|
|
||||||
token: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new share link or updates the permissions on an existing one.
|
* Creates a new share link or updates the permissions on an existing one.
|
||||||
* Returns an object with the token string and whether it was newly created.
|
* Returns an object with the token string and whether it was newly created.
|
||||||
@@ -85,57 +79,6 @@ export function deleteShareLink(tripId: string): void {
|
|||||||
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates or returns a dedicated calendar subscription link for a trip.
|
|
||||||
*/
|
|
||||||
export function createOrUpdateCalendarShareLink(
|
|
||||||
tripId: string,
|
|
||||||
createdBy: number,
|
|
||||||
): { token: string; created: boolean } {
|
|
||||||
const existing = db.prepare('SELECT token FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
|
||||||
if (existing) {
|
|
||||||
return { token: existing.token, created: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = crypto.randomBytes(24).toString('base64url');
|
|
||||||
db.prepare('INSERT INTO calendar_share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)')
|
|
||||||
.run(tripId, token, createdBy);
|
|
||||||
return { token, created: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the calendar subscription link for a trip, or null if none exists.
|
|
||||||
*/
|
|
||||||
export function getCalendarShareLink(tripId: string): CalendarShareTokenInfo | null {
|
|
||||||
const row = db.prepare('SELECT * FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as any;
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
token: row.token,
|
|
||||||
created_at: row.created_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the calendar subscription link for a trip.
|
|
||||||
*/
|
|
||||||
export function deleteCalendarShareLink(tripId: string): void {
|
|
||||||
db.prepare('DELETE FROM calendar_share_tokens WHERE trip_id = ?').run(tripId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a shared token to ICS calendar content.
|
|
||||||
* Returns null when token or trip is invalid.
|
|
||||||
*/
|
|
||||||
export function getSharedTripICS(token: string): { ics: string; filename: string } | null {
|
|
||||||
const shareRow = db.prepare('SELECT trip_id FROM calendar_share_tokens WHERE token = ?').get(token) as { trip_id: number } | undefined;
|
|
||||||
if (!shareRow) return null;
|
|
||||||
try {
|
|
||||||
return exportICS(shareRow.trip_id);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the full public trip data for a share token, filtered by the token's
|
* Loads the full public trip data for a share token, filtered by the token's
|
||||||
* permission flags. Returns null if the token is invalid or the trip is gone.
|
* permission flags. Returns null if the token is invalid or the trip is gone.
|
||||||
|
|||||||
@@ -204,24 +204,6 @@ describe('Shared trip access', () => {
|
|||||||
.send({});
|
.send({});
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SHARE-009 — GET /shared/:token/calendar.ics returns public calendar payload', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, user.id, { title: 'Rome Calendar' });
|
|
||||||
|
|
||||||
const create = await request(app)
|
|
||||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(user.id))
|
|
||||||
.send({});
|
|
||||||
const token = create.body.token;
|
|
||||||
|
|
||||||
const res = await request(app).get(`/api/shared/${token}/calendar.ics`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers['content-type']).toMatch(/text\/calendar/);
|
|
||||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
|
||||||
expect(res.text).toContain('END:VCALENDAR');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Shared trip — day assignments and notes', () => {
|
describe('Shared trip — day assignments and notes', () => {
|
||||||
|
|||||||
@@ -855,104 +855,6 @@ describe('ICS export', () => {
|
|||||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
|
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TRIP-025 — GET /api/trips/:id/subscribe.ics returns null before a calendar link exists', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.url).toBeNull();
|
|
||||||
expect(res.body.webcal_url).toBeNull();
|
|
||||||
expect(res.body.token).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('TRIP-025 — POST /api/trips/:id/subscribe.ics creates shareable http+webcal links', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.url).toMatch(/^http:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
|
|
||||||
expect(res.body.webcal_url).toMatch(/^webcal:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
|
|
||||||
expect(typeof res.body.token).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('TRIP-025 — DELETE /api/trips/:id/subscribe.ics removes calendar token', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, user.id, { title: 'Delete Calendar Token' });
|
|
||||||
await request(app)
|
|
||||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
|
|
||||||
const del = await request(app)
|
|
||||||
.delete(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
|
|
||||||
expect(del.status).toBe(200);
|
|
||||||
expect(del.body.success).toBe(true);
|
|
||||||
|
|
||||||
const status = await request(app)
|
|
||||||
.get(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(status.body.token).toBeNull();
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
it('TRIP-025 — non-member cannot get subscribe link → 404', async () => {
|
|
||||||
const { user: owner } = createUser(testDb);
|
|
||||||
const { user: stranger } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Cookie', authCookie(stranger.id));
|
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('TRIP-025 — member without share_manage cannot create subscribe link → 403', async () => {
|
|
||||||
const { user: owner } = createUser(testDb);
|
|
||||||
const { user: member } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
|
||||||
addTripMember(testDb, trip.id, member.id);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(member.id));
|
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('TRIP-025 — member without share_manage cannot delete subscribe link → 403', async () => {
|
|
||||||
const { user: owner } = createUser(testDb);
|
|
||||||
const { user: member } = createUser(testDb);
|
|
||||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
|
||||||
addTripMember(testDb, trip.id, member.id);
|
|
||||||
|
|
||||||
await request(app)
|
|
||||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Host', 'trek.example.com')
|
|
||||||
.set('Cookie', authCookie(owner.id));
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.delete(`/api/trips/${trip.id}/subscribe.ics`)
|
|
||||||
.set('Cookie', authCookie(member.id));
|
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
<Config Name="HSTS_INCLUDE_SUBDOMAINS" Target="HSTS_INCLUDE_SUBDOMAINS" Default="false" Mode="" Description="When true: adds includeSubDomains to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
|
||||||
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
||||||
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
||||||
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ These three variables work together behind a TLS-terminating reverse proxy. See
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
|
||||||
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
|
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
|
||||||
|
|
||||||
|
|||||||
@@ -36,20 +36,18 @@ When you have a day selected, a dark dashed line connects consecutive places in
|
|||||||
|
|
||||||
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
|
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
|
||||||
|
|
||||||
> **Requires:** Settings → Display → **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level.
|
|
||||||
|
|
||||||
## Reservation and transport overlay
|
## Reservation and transport overlay
|
||||||
|
|
||||||
Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it.
|
Flights, trains, cars, and cruises are drawn as overlays between their endpoint places:
|
||||||
|
|
||||||
- **Flights and cruises** — geodesic great-circle arcs
|
- **Flights and cruises** — geodesic great-circle arcs
|
||||||
- **Trains and cars** — straight lines
|
- **Trains and cars** — straight lines
|
||||||
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
|
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
|
||||||
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
|
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
|
||||||
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON.
|
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights.
|
||||||
- **Confirmed reservations** — solid line; **Pending** — dashed line
|
- **Confirmed reservations** — solid line; **Pending** — dashed line
|
||||||
|
|
||||||
> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`).
|
> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`).
|
||||||
|
|
||||||
## Location button
|
## Location button
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user