Compare commits

..

5 Commits

Author SHA1 Message Date
Marek Maslowski 6fdf76952f Merge 41a1c82ee5 into 002ea91be8 2026-04-24 17:22:18 +00:00
Marek Maslowski 41a1c82ee5 fix for mobile 2026-04-24 19:22:13 +02:00
Marek Maslowski ff3a7ddbf0 adding permission check for creation and delete of links 2026-04-24 18:52:07 +02:00
Marek Maslowski 3a3267d998 v1 2026-04-24 18:52:07 +02:00
Julien G. 002ea91be8 Align dev (#869)
* chore: bump version to 3.0.0 [skip ci]

* fix: resolve dead wiki links across install and config pages

* fix(reservations): restore correct day assignment for non-transport bookings

v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.

* chore: bump version to 3.0.1 [skip ci]

* fix(oidc): normalize discovery doc issuer before comparison

Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834

* test(systemNotices): exclude v3 upgrade notices from login_count-only tests

Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.

* chore: bump version to 3.0.2 [skip ci]

* fix(oidc): normalize id_token iss claim before issuer comparison (#837)

jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834

* chore: bump version to 3.0.3 [skip ci]

* fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)

OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.

UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.

Closes #843
Closes #844

* chore: bump version to 3.0.4 [skip ci]

* fix(files): open attachments only in new tab (#840)

window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.

* chore: bump version to 3.0.5 [skip ci]

* fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848)

* fix(journey): make sort_order authoritative for within-day entry ordering

Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846

* fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)

Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.

* test(pdf): add missing day_id to transport reservation fixture

* chore: bump version to 3.0.6 [skip ci]

* [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849)

* fix: server/package.json & server/package-lock.json to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UUID-16133035

* fix: bump fast-xml-parser version

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: jubnl <jgunther021@gmail.com>

* chore: bump version to 3.0.7 [skip ci]

* fix: hot fixes 23-04-2026 (#856)

* fix(packing): resolve avatar URL path in bag and category assignees (#854)

packingService was returning raw avatar filenames from the DB instead of
the full /uploads/avatars/<filename> path, causing broken profile images
for users with uploaded avatars.

* fix(budget): use Map.get() to fix category rename no-op (#855)

* fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863)

- Change Helmet default from no-referrer to strict-origin-when-cross-origin
  so browsers send the origin on cross-origin requests, allowing Google Maps
  API key restrictions by HTTP referrer to work correctly
- Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts:
  .env.example, docker-compose.yml, README.md, unraid-template.xml,
  charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md

* fix(planner): prefetch budget items on trip page mount (#864)

Loads budgetItems alongside reservations when TripPlannerPage mounts so
the Budget category dropdown in ReservationModal and TransportModal shows
pre-existing categories on first open, regardless of whether the Budget
tab has been visited.

Closes #861

* fix(reservations): prevent Invalid Date when end time is set without end date (#866)

When reservation_end_time held a bare time string ("HH:MM"), fmtDate()
produced Invalid Date on the reservation card.

- Modal: when end date is blank but end time is filled, construct a
  same-day ISO datetime using the start date (prevents time-only strings
  from ever being persisted)
- Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD")
  still show the multi-day range, while bare time strings are skipped and
  handled correctly by the existing time column logic

Closes #860

* fix(planner): format reservation end time instead of rendering raw ISO string (#867)

Closes #859

* fix(planner): wire Route toggle into mobile day sidebar (#850) (#868)

The per-booking Route icon was missing on mobile because the mobile
DayPlanSidebar invocation in TripPlannerPage didn't pass
visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't
activate reservation map overlays without forcing desktop mode.

Also corrects the Map-Features wiki: fixes the setting name
("Booking route labels" not "Show connection labels"), documents the
route_calculation requirement for travel-time pills, and explains that
overlays are off by default and must be toggled per reservation.

* chore: bump version to 3.0.8 [skip ci]

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com>
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-04-23 19:57:50 +02:00
30 changed files with 697 additions and 107 deletions
+1
View File
@@ -400,6 +400,7 @@ 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` |
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.6 version: 3.0.8
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.6" appVersion: "3.0.8"
+3
View File
@@ -22,6 +22,9 @@ 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 }}
+2
View File
@@ -30,6 +30,8 @@ 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"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.6", "version": "3.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.6", "version": "3.0.8",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.6", "version": "3.0.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -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[oldName] || [] const items = grouped.get(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,7 +66,11 @@ 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) => formatTime12(v, is12h) const fmtTime = (v) => {
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,6 +24,10 @@ 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) => {
@@ -79,7 +83,7 @@ vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any const actual = await importOriginal() as any
return { return {
...actual, ...actual,
useCanDo: () => () => true, useCanDo: () => () => mockPermissionsState.canDo,
} }
}) })
@@ -125,6 +129,7 @@ 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)
@@ -894,23 +899,138 @@ describe('DayPlanSidebar', () => {
// ── ICS export click ───────────────────────────────────────────────── // ── ICS export click ─────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => { it('FE-PLANNER-DAYPLAN-058: clicking ICS button first asks link or download', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ if (!navigator.clipboard) {
ok: true, Object.defineProperty(navigator, 'clipboard', {
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), value: { writeText: vi.fn().mockResolvedValue(undefined) },
} as any) configurable: true,
// Mock URL.createObjectURL })
}
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 createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) 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/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))) await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
expect(createObjURL).toHaveBeenCalled()
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
fetchSpy.mockRestore() fetchSpy.mockRestore()
clipboardSpy.mockRestore()
createObjURL.mockRestore() createObjURL.mockRestore()
revokeObjURL.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({
ok: true,
json: () => Promise.resolve({ token: 'existing-token' }),
} as any)
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(`${window.location.origin}/api/shared/existing-token/calendar.ics`)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete link' })).not.toBeInTheDocument()
fetchSpy.mockRestore()
})
// ── openAddNote button click ────────────────────────────────────────── // ── openAddNote button click ──────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => { it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => {
+202 -17
View File
@@ -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 } 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, Link2, Copy } 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,6 +225,7 @@ 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)
@@ -252,6 +253,9 @@ 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(() => {
@@ -284,6 +288,99 @@ 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
@@ -993,21 +1090,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
onClick={async () => { onClick={handleIcsOpenDialog}
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={{
@@ -1576,7 +1659,10 @@ 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>
)} )}
{(() => { {(() => {
@@ -2125,6 +2211,105 @@ 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,6 +182,8 @@ 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,7 +236,16 @@ 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>
+12
View File
@@ -305,6 +305,18 @@ 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',
+5 -2
View File
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [tripId]) }, [tripId])
useEffect(() => { useEffect(() => {
if (tripId) tripActions.loadReservations(tripId) if (tripId) {
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
}, [tripId]) }, [tripId])
useTripWebSocket(tripId) useTripWebSocket(tripId)
@@ -1106,7 +1109,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} 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} 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} />
: <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>
+1
View File
@@ -24,6 +24,7 @@ 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.
+1
View File
@@ -13,6 +13,7 @@ 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.
+27 -62
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.6", "version": "3.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "3.0.6", "version": "3.0.8",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
@@ -30,7 +30,7 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"uuid": "^9.0.0", "uuid": "^14.0.0",
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -663,9 +663,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -682,9 +679,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -701,9 +695,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -720,9 +711,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -739,9 +727,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -758,9 +743,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -777,9 +759,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -796,9 +775,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -815,9 +791,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -840,9 +813,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -865,9 +835,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -890,9 +857,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -915,9 +879,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -940,9 +901,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -965,9 +923,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -990,9 +945,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1551,6 +1503,18 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@otplib/core": { "node_modules/@otplib/core": {
"version": "12.0.1", "version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
@@ -3767,9 +3731,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-builder": { "node_modules/fast-xml-builder": {
"version": "1.1.4", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3782,9 +3746,9 @@
} }
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.5.12", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
"integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==", "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3793,7 +3757,8 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-xml-builder": "^1.1.4", "@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.5",
"path-expression-matcher": "^1.5.0", "path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3" "strnum": "^2.2.3"
}, },
@@ -6481,16 +6446,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/vary": { "node_modules/vary": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.6", "version": "3.0.8",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
@@ -35,7 +35,7 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"uuid": "^9.0.0", "uuid": "^14.0.0",
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
+1
View File
@@ -124,6 +124,7 @@ 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) {
+16
View File
@@ -2130,6 +2130,22 @@ 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) {
+9
View File
@@ -202,6 +202,15 @@ 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,
+12
View File
@@ -58,4 +58,16 @@ 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;
+61
View File
@@ -36,6 +36,7 @@ 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();
@@ -355,4 +356,64 @@ 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;
+12 -6
View File
@@ -1,4 +1,5 @@
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'];
@@ -131,7 +132,10 @@ 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 => ({ ...b, members: membersByBag.get(b.id) || [] })); return bags.map(b => ({
...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[]) {
@@ -140,11 +144,12 @@ 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);
return db.prepare(` const rows = 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); `).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
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 }) {
@@ -260,7 +265,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: row.avatar }); assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
} }
return assignees; return assignees;
@@ -274,12 +279,13 @@ 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);
} }
return db.prepare(` const updated = 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); `).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
} }
// ── Reorder ──────────────────────────────────────────────────────────────── // ── Reorder ────────────────────────────────────────────────────────────────
+57
View File
@@ -1,6 +1,7 @@
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;
@@ -20,6 +21,11 @@ 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.
@@ -79,6 +85,57 @@ 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.
+18
View File
@@ -204,6 +204,24 @@ 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', () => {
+98
View File
@@ -855,6 +855,104 @@ 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);
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+1
View File
@@ -37,6 +37,7 @@
<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>
+1
View File
@@ -53,6 +53,7 @@ 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 |
+5 -3
View File
@@ -36,18 +36,20 @@ 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 are drawn as overlays between their endpoint places: 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 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. - **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.
- **Confirmed reservations** — solid line; **Pending** — dashed line - **Confirmed reservations** — solid line; **Pending** — dashed line
> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`). > **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`).
## Location button ## Location button