Compare commits

..

22 Commits

Author SHA1 Message Date
github-actions[bot] 8342cf3010 chore: bump version to 3.0.8 [skip ci] 2026-04-23 17:49:49 +00:00
Julien G. 2a37eeccb3 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.
2026-04-23 19:49:36 +02:00
github-actions[bot] ae0e59d9f1 chore: bump version to 3.0.7 [skip ci] 2026-04-23 09:07:40 +00:00
Maurice 50bb7573fd [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>
2026-04-23 11:07:25 +02:00
github-actions[bot] b852317c84 chore: bump version to 3.0.6 [skip ci] 2026-04-23 08:53:44 +00:00
Julien G. 4436b6f673 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
2026-04-23 10:53:32 +02:00
github-actions[bot] 311647fd46 chore: bump version to 3.0.5 [skip ci] 2026-04-23 08:07:13 +00:00
Xre0uS 28dbd86d03 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.
2026-04-23 10:06:56 +02:00
github-actions[bot] 842d9760df chore: bump version to 3.0.4 [skip ci] 2026-04-23 07:13:48 +00:00
Julien G. 58218ff5f6 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
2026-04-23 09:13:35 +02:00
github-actions[bot] 83be5fc92a chore: bump version to 3.0.3 [skip ci] 2026-04-22 20:16:47 +00:00
Julien G. 7798d2a3fd 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
2026-04-22 22:16:33 +02:00
github-actions[bot] ec1ed60117 chore: bump version to 3.0.2 [skip ci] 2026-04-22 19:25:28 +00:00
Julien G. ed4c21eade Merge pull request #835 from mauriceboe/fix/oidc-issuer-trailing-slash
fix(oidc): normalize discovery doc issuer before trailing slash comparison
2026-04-22 21:25:15 +02:00
jubnl 9093948ff6 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.
2026-04-22 21:19:04 +02:00
jubnl 2cea4d73aa 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
2026-04-22 21:14:29 +02:00
github-actions[bot] a2a6f52e6e chore: bump version to 3.0.1 [skip ci] 2026-04-22 17:58:18 +00:00
Maurice 0978b40b6d Merge pull request #832 from mauriceboe/fix/reservations-day-id-mismatch
fix(reservations): restore correct day assignment for non-transport bookings
2026-04-22 19:58:03 +02:00
Maurice 6155b6dc86 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.
2026-04-22 19:47:22 +02:00
jubnl 314486325e fix: resolve dead wiki links across install and config pages 2026-04-22 19:21:53 +02:00
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
10 changed files with 25 additions and 610 deletions
@@ -24,10 +24,6 @@ const mockDayNotesState = vi.hoisted(() => ({
moveNote: vi.fn(),
}))
const mockPermissionsState = vi.hoisted(() => ({
canDo: true,
}))
// ── Module mocks ────────────────────────────────────────────────────────────
vi.mock('../../api/client', async (importOriginal) => {
@@ -83,7 +79,7 @@ vi.mock('../../store/permissionsStore', async (importOriginal) => {
const actual = await importOriginal() as any
return {
...actual,
useCanDo: () => () => mockPermissionsState.canDo,
useCanDo: () => () => true,
}
})
@@ -129,7 +125,6 @@ beforeEach(() => {
// Reset mutable day-notes state
mockDayNotesState.noteUi = {}
mockDayNotesState.dayNotes = {}
mockPermissionsState.canDo = true
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
@@ -899,136 +894,21 @@ describe('DayPlanSidebar', () => {
// ── 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()
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({
ok: true,
json: () => Promise.resolve({ token: 'existing-token' }),
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
} as any)
// Mock URL.createObjectURL
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(`${window.location.origin}/api/shared/existing-token/calendar.ics`)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete link' })).not.toBeInTheDocument()
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
fetchSpy.mockRestore()
createObjURL.mockRestore()
revokeObjURL.mockRestore()
})
// ── openAddNote button click ──────────────────────────────────────────
+16 -198
View File
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react'
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 }
import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -225,7 +225,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
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)
@@ -253,9 +252,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
const [dragOverDayId, setDragOverDayId] = 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)
useEffect(() => {
@@ -288,99 +284,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
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)
const getDragData = (e) => {
const dt = e?.dataTransfer
@@ -1090,7 +993,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<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)}
onMouseLeave={() => setIcsHover(false)}
style={{
@@ -2211,105 +2128,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
document.body
)}
{/* ICS subscription dialog */}
{icsDialog && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
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 */}
{transportDetail && ReactDOM.createPortal(
<div style={{
-12
View File
@@ -305,18 +305,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notificationsActive': 'Active channel',
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
'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.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
'share.createLink': 'Create link',
-16
View File
@@ -2130,22 +2130,6 @@ function runMigrations(db: Database.Database): void {
'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) {
-9
View File
@@ -202,15 +202,6 @@ function createTables(db: Database.Database): void {
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
-12
View File
@@ -58,16 +58,4 @@ router.get('/shared/:token', (req: Request, res: Response) => {
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;
-61
View File
@@ -36,7 +36,6 @@ import { listItems as listTodoItems } from '../services/todoService';
import { listBudgetItems } from '../services/budgetService';
import { listReservations } from '../services/reservationService';
import { listFiles } from '../services/fileService';
import { createOrUpdateCalendarShareLink, getCalendarShareLink, deleteCalendarShareLink } from '../services/shareService';
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;
-57
View File
@@ -1,7 +1,6 @@
import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
import { exportICS } from './tripService';
interface SharePermissions {
share_map?: boolean;
@@ -21,11 +20,6 @@ interface ShareTokenInfo {
share_collab: boolean;
}
interface CalendarShareTokenInfo {
token: string;
created_at: string;
}
/**
* 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.
@@ -85,57 +79,6 @@ export function deleteShareLink(tripId: string): void {
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
* permission flags. Returns null if the token is invalid or the trip is gone.
-18
View File
@@ -204,24 +204,6 @@ describe('Shared trip access', () => {
.send({});
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', () => {
-98
View File
@@ -855,104 +855,6 @@ describe('ICS export', () => {
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
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);
});
});
// ─────────────────────────────────────────────────────────────────────────────