mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-14 09:25:59 +00:00
harden calendar feeds: absolute URLs, real disable, folding, schema sync
- Resolve feed URLs against the request host when APP_URL is unset, so the webcal:// / Add-to-Google links work on a default install (not just behind a configured reverse proxy). - Give the public link a real off switch: POST enables, PUT rotates, DELETE clears the token (feed_token = NULL). The subscribe dialog no longer mints a token just from being opened — the user opts in explicitly. - Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download and feed both stay RFC 5545-compliant for long/non-ASCII summaries. - Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex that user text could truncate. - URL-encode the Google Calendar cid; mirror feed_token into schema.ts. - Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
This commit is contained in:
@@ -909,7 +909,7 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── ICS export click ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-058: ICS menu "Download ICS" calls fetch for .ics export', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -919,7 +919,10 @@ describe('DayPlanSidebar', () => {
|
||||
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')!)
|
||||
// The ICS button now opens a hover menu (Download / Subscribe) instead of
|
||||
// downloading on direct click.
|
||||
await user.hover(screen.getByText('ICS').closest('button')!)
|
||||
await user.click(await screen.findByText('Download ICS'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
fetchSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
@@ -1550,14 +1553,14 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── ICS hover tooltip ─────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows tooltip', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const icsBtn = screen.getByRole('button', { name: /ICS/i })
|
||||
const icsBtn = screen.getByText('ICS').closest('button')!
|
||||
await user.hover(icsBtn)
|
||||
await waitFor(() => {
|
||||
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
|
||||
expect(tooltips.length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -178,7 +178,12 @@ export function DayPlanSidebarToolbar({
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && (
|
||||
<IcsSubscribeModal tripId={tripId} onClose={() => setSubscribeOpen(false)} />
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title="Subscribe to calendar"
|
||||
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
|
||||
@@ -1,60 +1,64 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, RefreshCw, Calendar } from 'lucide-react'
|
||||
import { X, RefreshCw, Calendar, Power } from 'lucide-react'
|
||||
import { SubscribeLinks } from './SubscribeLinks'
|
||||
|
||||
interface IcsSubscribeModalProps {
|
||||
tripId: number
|
||||
/** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */
|
||||
endpoint: string
|
||||
title: string
|
||||
description: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
|
||||
// A server that has no APP_URL configured hands back a host-relative path; the
|
||||
// webcal:// handoff and Google deep link need an absolute URL, so resolve it
|
||||
// against the current origin as a fallback.
|
||||
function absolutize(url: string): string {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (url.startsWith('/')) return window.location.origin + url
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it
|
||||
* only *reads* the current token — it never mints one silently. The user
|
||||
* explicitly enables the public link, and can rotate or fully turn it off.
|
||||
*/
|
||||
export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) {
|
||||
const tokenUrl = `${endpoint}/token`
|
||||
const [feedUrl, setFeedUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const httpsUrl = feedUrl ?? ''
|
||||
const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
const httpsUrl = feedUrl ? absolutize(feedUrl) : ''
|
||||
const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
|
||||
const loadToken = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Try to get existing token first
|
||||
let res = await fetch(`/api/trips/${tripId}/feed/token`, { credentials: 'include' })
|
||||
if (!res.ok) { setLoading(false); return }
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
if (data.feed_url) {
|
||||
const res = await fetch(tokenUrl, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
setFeedUrl(data.feed_url)
|
||||
} else {
|
||||
// Lazily generate on first open
|
||||
res = await fetch(`/api/trips/${tripId}/feed/token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const gen = await res.json() as { feed_url: string }
|
||||
setFeedUrl(gen.feed_url)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [tripId])
|
||||
}, [tokenUrl])
|
||||
|
||||
useEffect(() => { loadToken() }, [loadToken])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const regenerate = async () => {
|
||||
setRegenerating(true)
|
||||
const mutate = async (method: 'POST' | 'PUT' | 'DELETE') => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/feed/token`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
const res = await fetch(tokenUrl, { method, credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string }
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
setFeedUrl(data.feed_url)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setRegenerating(false)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
@@ -83,7 +87,7 @@ export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to Calendar</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{title}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -97,41 +101,72 @@ export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour.
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
Generating link…
|
||||
Loading…
|
||||
</div>
|
||||
) : !feedUrl ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
Could not generate feed link.
|
||||
</div>
|
||||
<>
|
||||
<button
|
||||
onClick={() => mutate('POST')}
|
||||
disabled={busy}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
width: '100%', padding: '9px 14px', borderRadius: 9, border: 'none',
|
||||
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
|
||||
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Calendar size={14} strokeWidth={2} />
|
||||
Enable calendar subscription
|
||||
</button>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
|
||||
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={regenerate}
|
||||
disabled={regenerating}
|
||||
onClick={() => mutate('PUT')}
|
||||
disabled={busy}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 7, padding: '5px 10px',
|
||||
fontSize: 11, color: 'var(--text-muted)',
|
||||
cursor: regenerating ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
|
||||
Regenerate link
|
||||
<RefreshCw size={11} strokeWidth={2} style={{ animation: busy ? 'spin 0.8s linear infinite' : 'none' }} />
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mutate('DELETE')}
|
||||
disabled={busy}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 7, padding: '5px 10px',
|
||||
fontSize: 11, color: 'var(--danger, #dc2626)',
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Power size={11} strokeWidth={2} />
|
||||
Turn off
|
||||
</button>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
|
||||
Regenerating creates a new link and invalidates the old one.
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
|
||||
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,8 @@ export function SubscribeLinks({ httpsUrl, webcalUrl }: SubscribeLinksProps) {
|
||||
const [copied, setCopied] = useState<'https' | 'webcal' | null>(null)
|
||||
|
||||
// Google Calendar's add-by-URL deep link. The cid must carry the webcal://
|
||||
// scheme (not https) and the feed must be served over HTTPS.
|
||||
const googleUrl = `https://www.google.com/calendar/render?cid=${webcalUrl}`
|
||||
// scheme (not https), URL-encoded, and the feed must be served over HTTPS.
|
||||
const googleUrl = `https://www.google.com/calendar/render?cid=${encodeURIComponent(webcalUrl)}`
|
||||
|
||||
const copy = async (url: string, which: 'https' | 'webcal') => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DemoBanner from '../components/Layout/DemoBanner'
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X, CalendarPlus,
|
||||
} from 'lucide-react'
|
||||
import { SubscribeLinks } from '../components/Planner/SubscribeLinks'
|
||||
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -166,7 +165,14 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{allSubOpen && <AllTripsSubscribeModal onClose={() => setAllSubOpen(false)} />}
|
||||
{allSubOpen && (
|
||||
<IcsSubscribeModal
|
||||
endpoint="/api/feed/user"
|
||||
title="Subscribe to all trips"
|
||||
description="One calendar feed for all your active trips, kept in sync automatically. Excludes archived trips and trips that ended more than 90 days ago."
|
||||
onClose={() => setAllSubOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
||||
<div className="trips-empty">
|
||||
@@ -755,101 +761,3 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── All-trips subscribe modal ────────────────────────────────────────────────
|
||||
function AllTripsSubscribeModal({ onClose }: { onClose: () => void }) {
|
||||
const [feedUrl, setFeedUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
|
||||
const httpsUrl = feedUrl ?? ''
|
||||
const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
|
||||
const loadToken = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
let res = await fetch('/api/feed/user/token', { credentials: 'include' })
|
||||
if (!res.ok) { setLoading(false); return }
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
if (data.feed_url) {
|
||||
setFeedUrl(data.feed_url)
|
||||
} else {
|
||||
res = await fetch('/api/feed/user/token', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const gen = await res.json() as { feed_url: string }
|
||||
setFeedUrl(gen.feed_url)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadToken() }, [loadToken])
|
||||
|
||||
const regenerate = async () => {
|
||||
setRegenerating(true)
|
||||
try {
|
||||
const res = await fetch('/api/feed/user/token', { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string }
|
||||
setFeedUrl(data.feed_url)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setRegenerating(false)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 14, padding: '22px 24px',
|
||||
width: '100%', maxWidth: 420,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CalendarPlus size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to All Trips</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-muted)', borderRadius: 6, display: 'flex' }}>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
Subscribe to all your active trips in one calendar feed. Updates automatically. Excludes archived trips and trips that ended more than 90 days ago.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>Generating link…</div>
|
||||
) : !feedUrl ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>Could not generate feed link.</div>
|
||||
) : (
|
||||
<>
|
||||
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button onClick={regenerate} disabled={regenerating} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 7, padding: '5px 10px', fontSize: 11, color: 'var(--text-muted)', cursor: regenerating ? 'default' : 'pointer', fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1 }}>
|
||||
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
|
||||
Regenerate link
|
||||
</button>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>Regenerating creates a new link and invalidates the old one.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user