mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
v3.0.22 Bug Fixes & Improvements (#1041)
Bundles the v3.0.22 bug fixes and improvements. See the release notes for the full list.
This commit is contained in:
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ export const oauthApi = {
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
@@ -407,8 +407,20 @@ export const journeyApi = {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
|
||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -162,6 +163,7 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
@@ -442,6 +444,35 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Travel-time pills between consecutive places. The GL map accepted the
|
||||
// routeSegments prop but never drew anything, so the labels that Leaflet
|
||||
// shows were missing here (#850). Render them as HTML markers, matching the
|
||||
// Leaflet pill styling.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
||||
routeLabelMarkersRef.current = []
|
||||
for (const seg of routeSegments) {
|
||||
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue
|
||||
const el = document.createElement('div')
|
||||
el.style.pointerEvents = 'none'
|
||||
el.innerHTML = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
|
||||
</div>`
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([seg.mid[1], seg.mid[0]])
|
||||
.addTo(map)
|
||||
routeLabelMarkersRef.current.push(m)
|
||||
}
|
||||
return () => {
|
||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
||||
routeLabelMarkersRef.current = []
|
||||
}
|
||||
}, [routeSegments, mapReady])
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
|
||||
rightWidth?: number
|
||||
collapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
mobile?: boolean
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -173,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -288,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
{/* ── Reservations for this day's assignments ── */}
|
||||
{(() => {
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
||||
const dayReservations = reservations.filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
|
||||
return r.day_id === day.id
|
||||
})
|
||||
if (dayReservations.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
@@ -305,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||
</div>
|
||||
{r.reservation_time?.includes('T') && (
|
||||
{(() => {
|
||||
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||
if (!startTime && !endTime) return null
|
||||
return (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
@@ -1487,15 +1487,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
{(() => {
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!st && !et) return null
|
||||
return (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{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' })
|
||||
})()}`}
|
||||
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
@@ -1722,18 +1724,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{res.title}
|
||||
</span>
|
||||
{displayTime?.includes('T') && (
|
||||
{(() => {
|
||||
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!dispTime && !endTime) return null
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
||||
})()}
|
||||
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
|
||||
{spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
@@ -1782,8 +1786,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
// New place dropped onto a note: insert it among the
|
||||
// assignments at the note's position (after the places
|
||||
// above it), so it lands right where the note sits.
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
onAssignToDay?.(parseInt(placeId), day.id, pos)
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
@@ -2094,13 +2107,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||
{res.reservation_time?.includes('T')
|
||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
{(() => {
|
||||
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const dateStr = date
|
||||
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||
const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||
const parts: string[] = []
|
||||
if (dateStr) parts.push(dateStr)
|
||||
if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
|
||||
return parts.join(', ')
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -347,7 +348,7 @@ export default function PlaceInspector({
|
||||
{/* Description / Summary */}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -381,21 +382,29 @@ export default function PlaceInspector({
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{res.reservation_time && (
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
{(startTime || endTime) && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
|
||||
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
|
||||
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
|
||||
const day = buildDay({ date: null, day_number: 25 } as any);
|
||||
const r = buildReservation({
|
||||
title: 'Cruise test',
|
||||
type: 'cruise',
|
||||
status: 'pending',
|
||||
reservation_time: 'T10:00',
|
||||
reservation_end_time: 'T18:00',
|
||||
day_id: day.id,
|
||||
end_day_id: day.id,
|
||||
} as any);
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/10:00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
|
||||
const day = buildDay({ date: null, day_number: 3 } as any);
|
||||
const r = buildReservation({
|
||||
title: 'Car rental',
|
||||
type: 'car',
|
||||
status: 'pending',
|
||||
reservation_time: '09:00',
|
||||
reservation_end_time: '17:00',
|
||||
day_id: day.id,
|
||||
end_day_id: day.id,
|
||||
} as any);
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/09:00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
|
||||
const day = buildDay({ date: '2026-07-15', day_number: 1 });
|
||||
const r = buildReservation({
|
||||
title: 'Flight out',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time: '2026-07-15T08:30',
|
||||
reservation_end_time: '2026-07-15T10:45',
|
||||
day_id: day.id,
|
||||
} as any);
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/08:30/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
dayNumber: number
|
||||
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const fmtDate = (str) => {
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
}
|
||||
const startDt = splitReservationDateTime(r.reservation_time)
|
||||
const endDt = splitReservationDateTime(r.reservation_end_time)
|
||||
const fmtDate = (date: string) =>
|
||||
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const hasDate = !!r.reservation_time
|
||||
const hasTime = r.reservation_time?.includes('T')
|
||||
const hasDate = !!startDt.date
|
||||
const hasTime = !!(startDt.time || endDt.time)
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
)}
|
||||
{/* Date / Time row */}
|
||||
{(hasDate || hasTime) && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{(() => {
|
||||
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(startDt.date!)}
|
||||
{endDt.date && endDt.date !== startDt.date && (
|
||||
<> – {fmtDate(endDt.date)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasTime && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
{formatTime(startDt.time, locale, timeFormat)}
|
||||
{endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
@@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
status: reservation.status || 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
@@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||
if (!time) return null
|
||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
||||
return day?.date ? `${day.date}T${time}` : time
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {}
|
||||
|
||||
@@ -69,6 +69,7 @@ interface OAuthClient {
|
||||
client_id: string
|
||||
redirect_uris: string[]
|
||||
allowed_scopes: string[]
|
||||
allows_client_credentials: boolean
|
||||
created_at: string
|
||||
client_secret?: string // only present on create
|
||||
}
|
||||
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const [oauthRotating, setOauthRotating] = useState(false)
|
||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||
const [oauthIsMachine, setOauthIsMachine] = useState(false)
|
||||
|
||||
// MCP sub-tab state
|
||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateOAuthClient = async () => {
|
||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
||||
if (!oauthNewName.trim()) return
|
||||
if (!oauthIsMachine && !oauthNewUris.trim()) return
|
||||
setOauthCreating(true)
|
||||
try {
|
||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
||||
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({
|
||||
name: oauthNewName.trim(),
|
||||
redirect_uris: uris,
|
||||
allowed_scopes: oauthNewScopes,
|
||||
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
|
||||
})
|
||||
setOauthCreatedClient(d.client)
|
||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||
setOauthNewName('')
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
setOauthIsMachine(false)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} finally {
|
||||
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||
</button>
|
||||
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||
{client.allows_client_credentials && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
|
||||
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
|
||||
{t('settings.oauth.badge.machine')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.clientId')}: {client.client_id}
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||
@@ -616,6 +633,16 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!oauthIsMachine && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||
@@ -625,6 +652,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateOAuthClient}
|
||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
||||
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||
</button>
|
||||
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauthCreatedClient?.allows_client_credentials && (
|
||||
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.modal.machineClientUsage')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||
|
||||
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
|
||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const imageUrlFailed = useRef(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
|
||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
alt={place.name}
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
onError={() => {
|
||||
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
|
||||
imageUrlFailed.current = true
|
||||
const photoId = place.google_place_id || place.osm_id!
|
||||
const cacheKey = `refetch:${photoId}`
|
||||
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
|
||||
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||
)
|
||||
} else {
|
||||
setPhotoSrc(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -330,6 +330,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
|
||||
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
|
||||
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
|
||||
'settings.oauth.modal.machineClient': 'عميل آلي (بدون تسجيل دخول عبر المتصفح)',
|
||||
'settings.oauth.modal.machineClientHint': 'استخدام منحة client_credentials — لا تحتاج إلى عناوين إعادة التوجيه. يُصدر الرمز المميز مباشرةً عبر client_id + client_secret ويعمل بصلاحياتك ضمن النطاقات المحددة.',
|
||||
'settings.oauth.modal.machineClientUsage': 'للحصول على رمز مميز: POST /oauth/token مع grant_type=client_credentials وclient_id وclient_secret. بدون متصفح، بدون رمز تحديث.',
|
||||
'settings.oauth.badge.machine': 'آلي',
|
||||
'settings.account': 'الحساب',
|
||||
'settings.about': 'حول',
|
||||
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
||||
@@ -1709,6 +1713,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
'journey.editor.addAnother': 'إضافة آخر',
|
||||
'journey.editor.makeFirst': 'جعله الأول',
|
||||
|
||||
@@ -402,6 +402,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sessão revogada',
|
||||
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
|
||||
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
|
||||
'settings.oauth.modal.machineClient': 'Cliente de máquina (sem login no navegador)',
|
||||
'settings.oauth.modal.machineClientHint': 'Usa o grant client_credentials — sem URIs de redirecionamento. O token é emitido diretamente via client_id + client_secret e age como você dentro dos escopos selecionados.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Obter token: POST /oauth/token com grant_type=client_credentials, client_id e client_secret. Sem navegador, sem refresh token.',
|
||||
'settings.oauth.badge.machine': 'máquina',
|
||||
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
||||
|
||||
// Login
|
||||
@@ -2080,6 +2084,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos falharam — salve novamente para tentar',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||
'journey.editor.writeStory': 'Escreva sua história...',
|
||||
|
||||
@@ -281,6 +281,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Relace odvolána',
|
||||
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
|
||||
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
|
||||
'settings.oauth.modal.machineClient': 'Strojový klient (bez přihlášení v prohlížeči)',
|
||||
'settings.oauth.modal.machineClientHint': 'Používá grant client_credentials — bez URI pro přesměrování. Token je vydán přímo přes client_id + client_secret a funguje jako vy v rámci vybraných oborů.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Získat token: POST /oauth/token s grant_type=client_credentials, client_id a client_secret. Bez prohlížeče, bez obnovovacího tokenu.',
|
||||
'settings.oauth.badge.machine': 'strojový',
|
||||
'settings.account': 'Účet',
|
||||
'settings.about': 'O aplikaci',
|
||||
'settings.about.reportBug': 'Nahlásit chybu',
|
||||
@@ -2085,6 +2089,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.uploadingProgress': 'Nahrávání {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} z {total} fotek selhalo — uložte znovu pro opakování',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
||||
'journey.editor.writeStory': 'Napište svůj příběh...',
|
||||
|
||||
@@ -330,6 +330,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Session widerrufen',
|
||||
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
|
||||
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
|
||||
'settings.oauth.modal.machineClient': 'Maschineller Client (kein Browser-Login)',
|
||||
'settings.oauth.modal.machineClientHint': 'Verwendet den client_credentials Grant — keine Redirect-URIs erforderlich. Das Token wird direkt über client_id + client_secret ausgestellt und handelt in Ihrem Namen innerhalb der gewählten Scopes.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Token abrufen: POST /oauth/token mit grant_type=client_credentials, client_id und client_secret. Kein Browser, kein Refresh-Token.',
|
||||
'settings.oauth.badge.machine': 'Maschine',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'Über',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
@@ -2088,6 +2092,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.uploadingProgress': 'Hochladen {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} von {total} Fotos fehlgeschlagen — erneut speichern zum Wiederholen',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
||||
|
||||
@@ -403,6 +403,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Session revoked',
|
||||
'settings.oauth.toast.revokeError': 'Failed to revoke session',
|
||||
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
|
||||
'settings.oauth.modal.machineClient': 'Machine client (no browser login)',
|
||||
'settings.oauth.modal.machineClientHint': 'Use client_credentials grant — no redirect URIs needed. The token is issued directly via client_id + client_secret and acts as you within the selected scopes.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Get a token: POST /oauth/token with grant_type=client_credentials, client_id, and client_secret. No browser, no refresh token.',
|
||||
'settings.oauth.badge.machine': 'machine',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'About',
|
||||
'settings.about.reportBug': 'Report a Bug',
|
||||
@@ -2114,6 +2118,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.uploadingProgress': 'Uploading {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
'journey.editor.allPhotosAdded': 'All photos already added',
|
||||
'journey.editor.writeStory': 'Write your story...',
|
||||
|
||||
@@ -326,6 +326,10 @@ const es: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Sesión revocada',
|
||||
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
||||
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
||||
'settings.oauth.modal.machineClient': 'Cliente de máquina (sin inicio de sesión en el navegador)',
|
||||
'settings.oauth.modal.machineClientHint': 'Usa el grant client_credentials — sin URIs de redirección. El token se emite directamente vía client_id + client_secret y actúa como tú dentro de los alcances seleccionados.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Obtener token: POST /oauth/token con grant_type=client_credentials, client_id y client_secret. Sin navegador, sin token de actualización.',
|
||||
'settings.oauth.badge.machine': 'máquina',
|
||||
'settings.account': 'Cuenta',
|
||||
'settings.about': 'Acerca de',
|
||||
'settings.about.reportBug': 'Reportar un error',
|
||||
@@ -2087,6 +2091,8 @@ const es: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||
|
||||
@@ -325,6 +325,10 @@ const fr: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Session révoquée',
|
||||
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
|
||||
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
|
||||
'settings.oauth.modal.machineClient': 'Client machine (sans connexion navigateur)',
|
||||
'settings.oauth.modal.machineClientHint': 'Utilise le grant client_credentials — aucune URI de redirection requise. Le token est émis directement via client_id + client_secret et agit en votre nom dans les portées sélectionnées.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Obtenir un token : POST /oauth/token avec grant_type=client_credentials, client_id et client_secret. Sans navigateur, sans token de rafraîchissement.',
|
||||
'settings.oauth.badge.machine': 'machine',
|
||||
'settings.account': 'Compte',
|
||||
'settings.about': 'À propos',
|
||||
'settings.about.reportBug': 'Signaler un bug',
|
||||
@@ -2081,6 +2085,8 @@ const fr: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Échec du téléversement des photos',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.uploadingProgress': 'Téléversement {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} sur {total} photos ont échoué — sauvegardez à nouveau pour réessayer',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||
|
||||
@@ -280,6 +280,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
|
||||
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
|
||||
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
|
||||
'settings.oauth.modal.machineClient': 'Gépi kliens (böngészős bejelentkezés nélkül)',
|
||||
'settings.oauth.modal.machineClientHint': 'client_credentials grant használata — nincs szükség átirányítási URI-kra. A token közvetlenül client_id + client_secret segítségével kerül kiállításra, és a kiválasztott hatókörökön belül az Ön nevében jár el.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Token lekérése: POST /oauth/token a grant_type=client_credentials, client_id és client_secret értékekkel. Böngésző és frissítési token nélkül.',
|
||||
'settings.oauth.badge.machine': 'gépi',
|
||||
'settings.account': 'Fiók',
|
||||
'settings.about': 'Névjegy',
|
||||
'settings.about.reportBug': 'Hiba bejelentése',
|
||||
@@ -2082,6 +2086,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.uploadingProgress': 'Feltöltés {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} / {total} fotó sikertelen — mentsd el újra a próbálkozáshoz',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||
'journey.editor.writeStory': 'Írd meg a történeted...',
|
||||
|
||||
@@ -387,6 +387,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sesi dicabut',
|
||||
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
|
||||
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
|
||||
'settings.oauth.modal.machineClient': 'Klien mesin (tanpa login browser)',
|
||||
'settings.oauth.modal.machineClientHint': 'Menggunakan grant client_credentials — tidak perlu URI pengalihan. Token diterbitkan langsung melalui client_id + client_secret dan bertindak sebagai Anda dalam cakupan yang dipilih.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Dapatkan token: POST /oauth/token dengan grant_type=client_credentials, client_id, dan client_secret. Tanpa browser, tanpa refresh token.',
|
||||
'settings.oauth.badge.machine': 'mesin',
|
||||
'settings.account': 'Akun',
|
||||
'settings.about': 'Tentang',
|
||||
'settings.about.reportBug': 'Laporkan Bug',
|
||||
@@ -2097,6 +2101,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'journey.editor.uploading': 'Mengunggah...',
|
||||
'journey.editor.uploadingProgress': 'Mengunggah {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} dari {total} foto gagal — simpan lagi untuk mencoba ulang',
|
||||
'journey.editor.fromGallery': 'Dari Galeri',
|
||||
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
||||
'journey.editor.writeStory': 'Tulis kisahmu...',
|
||||
|
||||
@@ -280,6 +280,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sessione revocata',
|
||||
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
|
||||
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
|
||||
'settings.oauth.modal.machineClient': 'Client macchina (senza login nel browser)',
|
||||
'settings.oauth.modal.machineClientHint': 'Usa il grant client_credentials — nessun URI di reindirizzamento necessario. Il token viene emesso direttamente tramite client_id + client_secret e agisce come te negli ambiti selezionati.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Ottieni token: POST /oauth/token con grant_type=client_credentials, client_id e client_secret. Senza browser, senza token di aggiornamento.',
|
||||
'settings.oauth.badge.machine': 'macchina',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Informazioni',
|
||||
'settings.about.reportBug': 'Segnala un bug',
|
||||
@@ -2082,6 +2086,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.uploadingProgress': 'Caricamento {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} di {total} foto non riuscite — salva di nuovo per riprovare',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
||||
|
||||
@@ -325,6 +325,10 @@ const nl: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
|
||||
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
|
||||
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
|
||||
'settings.oauth.modal.machineClient': 'Machineclient (zonder browserinlog)',
|
||||
'settings.oauth.modal.machineClientHint': "Gebruikt de client_credentials grant — geen redirect-URI's nodig. Het token wordt direct verstrekt via client_id + client_secret en handelt namens jou binnen de geselecteerde scopes.",
|
||||
'settings.oauth.modal.machineClientUsage': 'Token ophalen: POST /oauth/token met grant_type=client_credentials, client_id en client_secret. Geen browser, geen vernieuwingstoken.',
|
||||
'settings.oauth.badge.machine': 'machine',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Over',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
@@ -2081,6 +2085,8 @@ const nl: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.uploadingProgress': 'Uploaden {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} van {total} foto\'s mislukt — sla opnieuw op om het opnieuw te proberen',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||
|
||||
@@ -295,6 +295,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sesja unieważniona',
|
||||
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
|
||||
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
|
||||
'settings.oauth.modal.machineClient': 'Klient maszynowy (bez logowania przez przeglądarkę)',
|
||||
'settings.oauth.modal.machineClientHint': 'Używa grantu client_credentials — nie są potrzebne URI przekierowania. Token jest wystawiany bezpośrednio przez client_id + client_secret i działa w Twoim imieniu w ramach wybranych zakresów.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Pobierz token: POST /oauth/token z grant_type=client_credentials, client_id i client_secret. Bez przeglądarki, bez tokenu odświeżania.',
|
||||
'settings.oauth.badge.machine': 'maszynowy',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'O aplikacji',
|
||||
'settings.about.reportBug': 'Zgłoś błąd',
|
||||
@@ -2074,6 +2078,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.uploadingProgress': 'Przesyłanie {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} z {total} zdjęć nie powiodło się — zapisz ponownie, aby spróbować',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||
'journey.editor.writeStory': 'Napisz swoją historię...',
|
||||
|
||||
@@ -325,6 +325,10 @@ const ru: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Сессия отозвана',
|
||||
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
|
||||
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
|
||||
'settings.oauth.modal.machineClient': 'Машинный клиент (без входа через браузер)',
|
||||
'settings.oauth.modal.machineClientHint': 'Использует грант client_credentials — URI перенаправления не требуются. Токен выдаётся напрямую через client_id + client_secret и действует от вашего имени в пределах выбранных областей.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Получить токен: POST /oauth/token с grant_type=client_credentials, client_id и client_secret. Без браузера, без токена обновления.',
|
||||
'settings.oauth.badge.machine': 'машинный',
|
||||
'settings.account': 'Аккаунт',
|
||||
'settings.about': 'О приложении',
|
||||
'settings.about.reportBug': 'Сообщить об ошибке',
|
||||
@@ -2081,6 +2085,8 @@ const ru: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||
|
||||
@@ -325,6 +325,10 @@ const zh: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': '会话已撤销',
|
||||
'settings.oauth.toast.revokeError': '撤销会话失败',
|
||||
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
|
||||
'settings.oauth.modal.machineClient': '机器客户端(无需浏览器登录)',
|
||||
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授权——无需重定向 URI。令牌通过 client_id + client_secret 直接颁发,并在所选范围内以您的身份运行。',
|
||||
'settings.oauth.modal.machineClientUsage': '获取令牌:向 /oauth/token 发送 POST 请求,携带 grant_type=client_credentials、client_id 和 client_secret。无需浏览器,无刷新令牌。',
|
||||
'settings.oauth.badge.machine': '机器',
|
||||
'settings.account': '账户',
|
||||
'settings.about': '关于',
|
||||
'settings.about.reportBug': '报告错误',
|
||||
@@ -2081,6 +2085,8 @@ const zh: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': '照片上传失败',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||
'journey.editor.writeStory': '写下你的故事...',
|
||||
|
||||
@@ -384,6 +384,10 @@ const zhTw: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
||||
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
||||
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
|
||||
'settings.oauth.modal.machineClient': '機器客戶端(無需瀏覽器登入)',
|
||||
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授權——無需重新導向 URI。令牌透過 client_id + client_secret 直接簽發,並在所選範圍內以您的身份運行。',
|
||||
'settings.oauth.modal.machineClientUsage': '取得令牌:向 /oauth/token 發送 POST 請求,攜帶 grant_type=client_credentials、client_id 和 client_secret。無需瀏覽器,無重整令牌。',
|
||||
'settings.oauth.badge.machine': '機器',
|
||||
'settings.account': '賬戶',
|
||||
'settings.about': '關於',
|
||||
'settings.about.reportBug': '回報錯誤',
|
||||
@@ -2039,6 +2043,8 @@ const zhTw: Record<string, string> = {
|
||||
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||
'journey.editor.writeStory': '寫下你的故事...',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -748,8 +749,8 @@ export default function JourneyDetailPage() {
|
||||
}
|
||||
return entryId
|
||||
}}
|
||||
onUploadPhotos={async (entryId, formData) => {
|
||||
return await uploadPhotos(entryId, formData)
|
||||
onUploadPhotos={async (entryId, files, cbs) => {
|
||||
return await uploadPhotos(entryId, files, cbs)
|
||||
}}
|
||||
onDone={() => {
|
||||
setEditingEntry(null)
|
||||
@@ -987,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
||||
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const galleryUploading = galleryProgress !== null
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
@@ -1027,18 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const formData = new FormData()
|
||||
for (const f of normalized) formData.append('photos', f)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
}
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||
} finally {
|
||||
setGalleryUploading(false)
|
||||
setGalleryProgress(null)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
@@ -1083,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{galleryUploading ? (
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={12} /> {t('common.upload')}</>
|
||||
)}
|
||||
@@ -1772,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -2172,7 +2178,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -2195,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
@@ -2248,12 +2254,20 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const formData = new FormData()
|
||||
for (const f of pendingFiles) formData.append('photos', f)
|
||||
const filesToUpload = pendingFiles
|
||||
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||
try {
|
||||
await onUploadPhotos(entryId, formData)
|
||||
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
setPendingFiles(failed)
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
}
|
||||
// link gallery photos that were picked before save
|
||||
@@ -2309,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
disabled={saving}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
{uploadProgress ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||
) : (
|
||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
|
||||
@@ -219,7 +220,7 @@ export default function SharedTripPage() {
|
||||
const r = item.data
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||
let sub = ''
|
||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
@@ -276,8 +277,9 @@ export default function SharedTripPage() {
|
||||
{(reservations || []).map((r: any) => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
|
||||
const time = rTime ?? ''
|
||||
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
return (
|
||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
|
||||
@@ -1003,6 +1003,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
collapsed={dayDetailCollapsed}
|
||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||
mobile={isMobile}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
@@ -1116,7 +1117,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{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) }} 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} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <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) }} 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) }} 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} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
: <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} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyApi } from '../api/client';
|
||||
import { useJourneyStore } from './journeyStore';
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||
|
||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.json({ photos: [newPhoto] })
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(91);
|
||||
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||
// layer directly so this test exercises store state management only.
|
||||
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
|
||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(91);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(2);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||
const entry = buildEntry({ id: 100, photos: [] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||
expect(result.succeeded).toHaveLength(0);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0]).toBe(file);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||
const entry = buildEntry({ id: 100, photos: [] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||
let callCount = 0;
|
||||
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
|
||||
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
|
||||
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return { photos: [photo1] } as any;
|
||||
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
|
||||
});
|
||||
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(1);
|
||||
void photo2; // referenced to avoid lint warning
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
|
||||
export interface Journey {
|
||||
id: number
|
||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
||||
deleteEntry: (entryId: number) => Promise<void>
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||
|
||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
@@ -237,9 +238,15 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
uploadPhotos: async (entryId, formData) => {
|
||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
||||
const photos = data.photos || []
|
||||
uploadPhotos: async (entryId, files, cbs) => {
|
||||
return uploadFilesResilient<JourneyPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||
const photos: JourneyPhoto[] = data.photos || []
|
||||
const gallery: GalleryPhoto[] = data.gallery || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
@@ -248,15 +255,23 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||
gallery: [...(s.current.gallery || []), ...gallery],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||
return uploadFilesResilient<GalleryPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
@@ -264,6 +279,9 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
|
||||
|
||||
@@ -57,11 +57,27 @@ describe('getTransportForDay', () => {
|
||||
{ id: 3, day_number: 3 },
|
||||
]
|
||||
|
||||
it('excludes non-transport types', () => {
|
||||
it('excludes hotel (rendered via accommodation path)', () => {
|
||||
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes tour booking on the correct day', () => {
|
||||
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes restaurant, event, and other bookings by day_id', () => {
|
||||
const reservations = [
|
||||
{ id: 30, type: 'restaurant', day_id: 2 },
|
||||
{ id: 31, type: 'event', day_id: 2 },
|
||||
{ id: 32, type: 'other', day_id: 2 },
|
||||
]
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes single-day transport on the correct day', () => {
|
||||
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
|
||||
@@ -55,7 +55,7 @@ export function getTransportForDay(opts: {
|
||||
const thisDayOrder = getDayOrder(dayId)
|
||||
|
||||
return reservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||
if (r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
|
||||
const startDayId = r.day_id
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime } from './formatters'
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
})
|
||||
|
||||
it('parses full datetime with seconds', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
})
|
||||
|
||||
it('parses date-only string', () => {
|
||||
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
|
||||
})
|
||||
|
||||
it('parses bare HH:MM (new dateless format)', () => {
|
||||
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
|
||||
})
|
||||
|
||||
it('parses bare single-digit hour time', () => {
|
||||
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
|
||||
})
|
||||
|
||||
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
|
||||
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
|
||||
})
|
||||
|
||||
it('returns null date for T-prefixed without valid date', () => {
|
||||
const result = splitReservationDateTime('T23:59')
|
||||
expect(result.date).toBeNull()
|
||||
expect(result.time).toBe('23:59')
|
||||
})
|
||||
|
||||
it('returns nulls for null input', () => {
|
||||
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
|
||||
})
|
||||
|
||||
it('returns nulls for undefined input', () => {
|
||||
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
|
||||
})
|
||||
|
||||
it('returns nulls for empty string', () => {
|
||||
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
|
||||
})
|
||||
|
||||
it('returns nulls for unrecognized string', () => {
|
||||
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
|
||||
} catch { return timeStr }
|
||||
}
|
||||
|
||||
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
|
||||
if (!value) return { date: null, time: null }
|
||||
const isoDate = /^\d{4}-\d{2}-\d{2}$/
|
||||
if (value.includes('T')) {
|
||||
const [d, t] = value.split('T')
|
||||
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
|
||||
}
|
||||
if (isoDate.test(value)) return { date: value, time: null }
|
||||
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
|
||||
return { date: null, time: null }
|
||||
}
|
||||
|
||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
|
||||
export interface UploadProgress {
|
||||
done: number
|
||||
total: number
|
||||
failed: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface ResilientResult<T> {
|
||||
succeeded: T[]
|
||||
failed: File[]
|
||||
}
|
||||
|
||||
export interface UploadOpts {
|
||||
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const status = (err as { response?: { status?: number } }).response?.status
|
||||
if (status !== undefined && status >= 400 && status < 500) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function uploadFilesResilient<T>(
|
||||
files: File[],
|
||||
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||
cbs?: {
|
||||
concurrency?: number
|
||||
retries?: number
|
||||
onProgress?: (p: UploadProgress) => void
|
||||
onUploaded?: (items: T[]) => void
|
||||
},
|
||||
): Promise<ResilientResult<T>> {
|
||||
const concurrency = cbs?.concurrency ?? 3
|
||||
const maxRetries = cbs?.retries ?? 2
|
||||
|
||||
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||
const loadedMap = new Map<number, number>()
|
||||
let doneCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
const emitProgress = () => {
|
||||
if (!cbs?.onProgress) return
|
||||
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||
}
|
||||
|
||||
const succeeded: T[] = []
|
||||
const failedFiles: File[] = []
|
||||
|
||||
let idx = 0
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const i = idx++
|
||||
if (i >= files.length) break
|
||||
const file = files[i]
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
loadedMap.set(i, 0)
|
||||
|
||||
let items: T[] | null = null
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) await sleep(400 * attempt)
|
||||
try {
|
||||
items = await uploadOne(file, {
|
||||
idempotencyKey,
|
||||
onUploadProgress: (e) => {
|
||||
loadedMap.set(i, e.loaded)
|
||||
emitProgress()
|
||||
},
|
||||
})
|
||||
break
|
||||
} catch (err) {
|
||||
if (!isRetryable(err) || attempt === maxRetries) {
|
||||
items = null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items !== null) {
|
||||
succeeded.push(...items)
|
||||
cbs?.onUploaded?.(items)
|
||||
loadedMap.set(i, file.size)
|
||||
doneCount++
|
||||
} else {
|
||||
failedFiles.push(file)
|
||||
loadedMap.set(i, 0)
|
||||
failedCount++
|
||||
}
|
||||
emitProgress()
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return { succeeded, failed: failedFiles }
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export default defineConfig({
|
||||
],
|
||||
build: {
|
||||
sourcemap: false,
|
||||
modulePreload: { polyfill: false },
|
||||
modulePreload: { polyfill: true },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
+1
-1
@@ -397,7 +397,7 @@ export function createApp(): express.Application {
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
|
||||
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
||||
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
||||
},
|
||||
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
|
||||
// clients to skip the browser consent flow entirely and obtain tokens directly
|
||||
// via client_id + client_secret. Flag is immutable after creation so existing
|
||||
// authorization-code clients are not silently upgraded.
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
|
||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Drop stale atlas cache rows for territories that used to resolve to their
|
||||
// surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
|
||||
// etc.) before their own bounding boxes existed. The next atlas stats request
|
||||
// re-resolves any place inside these boxes with the corrected country code.
|
||||
() => {
|
||||
const enclaveBoxes: [number, number, number, number][] = [
|
||||
[113.83, 22.15, 114.43, 22.56], // HK
|
||||
[113.53, 22.10, 113.60, 22.21], // MO
|
||||
[12.40, 43.89, 12.52, 43.99], // SM
|
||||
[12.44, 41.90, 12.46, 41.91], // VA
|
||||
[7.40, 43.72, 7.44, 43.75], // MC
|
||||
[9.47, 47.05, 9.64, 47.27], // LI
|
||||
[-5.36, 36.11, -5.33, 36.16], // GI
|
||||
[-67.30, 17.88, -65.22, 18.53], // PR
|
||||
];
|
||||
try {
|
||||
const del = db.prepare(
|
||||
`DELETE FROM place_regions WHERE place_id IN (
|
||||
SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
|
||||
)`
|
||||
);
|
||||
for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
|
||||
del.run(minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('no such table')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
server.registerTool(
|
||||
'create_place_accommodation',
|
||||
{
|
||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
|
||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
if (W) server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
if (W) server.registerTool(
|
||||
'create_and_assign_place',
|
||||
{
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createReservation, getReservation, updateReservation, deleteReservation,
|
||||
updatePositions as updateReservationPositions,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
|
||||
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
const metadata = price != null ? { price: String(price) } : undefined;
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
safeBroadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
}
|
||||
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
server.registerTool(
|
||||
'create_transport',
|
||||
{
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
|
||||
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||
if (price != null) meta.price = String(price);
|
||||
|
||||
const { reservation } = createReservation(tripId, {
|
||||
title,
|
||||
type,
|
||||
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
day_id: start_day_id,
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata,
|
||||
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||
endpoints,
|
||||
needs_review,
|
||||
});
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
}
|
||||
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
consumeAuthCode,
|
||||
saveConsent,
|
||||
issueTokens,
|
||||
issueClientCredentialsToken,
|
||||
refreshTokens,
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
AuthorizeParams,
|
||||
} from '../services/oauthService';
|
||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal in-file rate limiter (same pattern as auth.ts)
|
||||
@@ -151,6 +153,48 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.json(result.tokens);
|
||||
}
|
||||
|
||||
// ---- client_credentials grant ----
|
||||
if (grant_type === 'client_credentials') {
|
||||
if (!client_secret) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
|
||||
}
|
||||
|
||||
const client = authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
|
||||
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
|
||||
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
|
||||
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
}
|
||||
|
||||
// Scope: use requested subset or fall back to all allowed scopes.
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
let grantedScopes: string[];
|
||||
if (body.scope) {
|
||||
const requested = body.scope.split(' ').filter(Boolean);
|
||||
const invalid = requested.filter(s => !allowedScopes.includes(s));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
|
||||
}
|
||||
grantedScopes = requested;
|
||||
} else {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
|
||||
// Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
|
||||
// token passes audience binding in mcp/index.ts without extra configuration.
|
||||
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
|
||||
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
});
|
||||
|
||||
@@ -327,13 +371,14 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
|
||||
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const { name, redirect_uris, allowed_scopes } = req.body as {
|
||||
const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
|
||||
name: string;
|
||||
redirect_uris: string[];
|
||||
redirect_uris?: string[];
|
||||
allowed_scopes: string[];
|
||||
allows_client_credentials?: boolean;
|
||||
};
|
||||
|
||||
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
|
||||
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.status(201).json(result);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
updateReservation,
|
||||
deleteReservation,
|
||||
} from '../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -55,13 +55,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
// Auto-create budget entry if price was provided
|
||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||
try {
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: create_budget_entry.category || type || 'Other',
|
||||
total_price: create_budget_entry.total_price,
|
||||
});
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
|
||||
budgetItem.reservation_id = reservation.id;
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create budget entry:', err);
|
||||
|
||||
@@ -100,6 +100,12 @@ export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
|
||||
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
|
||||
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
|
||||
// Territories with their own ISO code that sit inside a larger country's box.
|
||||
// Listed so getCountryFromCoords()'s smallest-box match picks them over the host
|
||||
// (e.g. Hong Kong/Macau over China, San Marino/Vatican over Italy).
|
||||
HK:[113.83,22.15,114.43,22.56],MO:[113.53,22.10,113.60,22.21],SM:[12.40,43.89,12.52,43.99],
|
||||
VA:[12.44,41.90,12.46,41.91],MC:[7.40,43.72,7.44,43.75],LI:[9.47,47.05,9.64,47.27],
|
||||
GI:[-5.36,36.11,-5.33,36.16],PR:[-67.30,17.88,-65.22,18.53],
|
||||
};
|
||||
|
||||
export const NAME_TO_CODE: Record<string, string> = {
|
||||
@@ -144,6 +150,9 @@ export const NAME_TO_CODE: Record<string, string> = {
|
||||
'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW',
|
||||
'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI',
|
||||
'somalia':'SO','papua new guinea':'PG','brunei':'BN',
|
||||
'hong kong':'HK','hong kong sar':'HK','macau':'MO','macao':'MO','macau sar':'MO',
|
||||
'san marino':'SM','vatican':'VA','vatican city':'VA','holy see':'VA','monaco':'MC',
|
||||
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
|
||||
};
|
||||
|
||||
export const CONTINENT_MAP: Record<string, string> = {
|
||||
@@ -167,6 +176,7 @@ export const CONTINENT_MAP: Record<string, string> = {
|
||||
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
|
||||
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
|
||||
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
|
||||
HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America',
|
||||
};
|
||||
|
||||
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
||||
@@ -366,11 +376,17 @@ export async function getStats(userId: number) {
|
||||
for (const place of places) {
|
||||
if (place.address) {
|
||||
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||
if (raw) {
|
||||
const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
|
||||
if (city) citySet.add(city);
|
||||
// The last part is the country; the city is usually right before it, but a
|
||||
// full formatted address can have a postal code sitting between them
|
||||
// (e.g. "Bucharest, 010071, Romania"). Walk back from the country and take
|
||||
// the first part that still has letters once digits/postal noise is stripped.
|
||||
const candidates = parts.length >= 2 ? parts.slice(0, -1) : parts;
|
||||
let city = '';
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const cleaned = candidates[i].replace(/[\d\-\u2212\u3012]+/g, '').trim();
|
||||
if (cleaned) { city = cleaned.toLowerCase(); break; }
|
||||
}
|
||||
if (city) citySet.add(city);
|
||||
}
|
||||
}
|
||||
const totalCities = citySet.size;
|
||||
|
||||
@@ -96,6 +96,17 @@ export function createBudgetItem(
|
||||
return item;
|
||||
}
|
||||
|
||||
export function linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
) {
|
||||
const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
|
||||
item.reservation_id = reservationId;
|
||||
return item;
|
||||
}
|
||||
|
||||
export function updateBudgetItem(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
|
||||
@@ -60,6 +60,7 @@ interface OAuthClientRow {
|
||||
created_at: string;
|
||||
is_public: number; // 0 | 1 (SQLite boolean)
|
||||
created_via: string; // 'settings_ui' | 'browser-registration'
|
||||
allows_client_credentials: number; // 0 | 1
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
@@ -106,11 +107,12 @@ function generateRefreshToken(): string {
|
||||
|
||||
export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as OAuthClientRow[];
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
is_public: Boolean(r.is_public),
|
||||
allows_client_credentials: Boolean(r.allows_client_credentials),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
@@ -132,11 +134,12 @@ export function createOAuthClient(
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string },
|
||||
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
|
||||
): { error?: string; status?: number; client?: Record<string, unknown> } {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
|
||||
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
const isMachineClient = Boolean(options?.allowsClientCredentials);
|
||||
if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
@@ -164,7 +167,8 @@ export function createOAuthClient(
|
||||
if (count >= 500) return { error: 'server_error', status: 503 };
|
||||
}
|
||||
|
||||
const isPublic = options?.isPublic ?? false;
|
||||
// Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
|
||||
const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
|
||||
const createdVia = options?.createdVia ?? 'settings_ui';
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
@@ -173,14 +177,14 @@ export function createOAuthClient(
|
||||
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
@@ -192,6 +196,7 @@ export function createOAuthClient(
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
is_public: Boolean(row.is_public),
|
||||
allows_client_credentials: Boolean(row.allows_client_credentials),
|
||||
created_via: row.created_via,
|
||||
// client_secret only present for confidential clients — shown once, not stored in plain text
|
||||
...(rawSecret ? { client_secret: rawSecret } : {}),
|
||||
@@ -330,6 +335,43 @@ export function issueTokens(
|
||||
};
|
||||
}
|
||||
|
||||
// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
|
||||
// Used exclusively for the client_credentials grant. A random opaque hash is
|
||||
// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
|
||||
// can never be presented as a valid refresh token (same precedent as public
|
||||
// client secret hashes stored in client_secret_hash).
|
||||
export function issueClientCredentialsToken(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
audience: string,
|
||||
): {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
const rawAccess = generateAccessToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const placeholderHash = randomBytes(32).toString('hex');
|
||||
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO oauth_tokens
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token verification (used by MCP handler on every request)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -506,6 +506,11 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
// Reservations as events
|
||||
for (const r of reservations) {
|
||||
if (!r.reservation_time) continue;
|
||||
// Skip time-only values (no calendar date — occurs on relative "Day N" trips)
|
||||
const hasDate = r.reservation_time.includes('T')
|
||||
? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
|
||||
if (!hasDate) continue;
|
||||
const hasTime = r.reservation_time.includes('T');
|
||||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createOAuthClient, createAuthCode } from '../../src/services/oauthService';
|
||||
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -1286,3 +1286,140 @@ describe('C3 — Refresh token replay detection', () => {
|
||||
expect(t4.body.error).toBe('invalid_grant');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /oauth/token — client_credentials grant
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /oauth/token — client_credentials grant', () => {
|
||||
it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access_token).toBeDefined();
|
||||
expect(res.body.token_type).toBe('Bearer');
|
||||
expect(typeof res.body.expires_in).toBe('number');
|
||||
expect(res.body.scope).toBe('trips:read');
|
||||
expect(res.body.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const info = getUserByAccessToken(res.body.access_token);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.user.id).toBe(user.id);
|
||||
expect(info!.scopes).toEqual(['trips:read']);
|
||||
});
|
||||
|
||||
it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: 'trekcs_wrong',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('unauthorized_client');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
scope: 'trips:read',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scope).toBe('trips:read');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: r.client!.client_id,
|
||||
client_secret: r.client!.client_secret,
|
||||
scope: 'places:write',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
|
||||
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.client).toBeDefined();
|
||||
expect(r.client!.allows_client_credentials).toBe(true);
|
||||
expect((r.client!.redirect_uris as string[]).length).toBe(0);
|
||||
expect(r.client!.client_secret).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,16 @@ Once connected, an AI assistant can work with your TREK data in a single convers
|
||||
|
||||
Changes made through MCP are broadcast to all connected clients in real-time — exactly like changes made in the web UI.
|
||||
|
||||
## Authentication options
|
||||
|
||||
| Use case | Method |
|
||||
|---|---|
|
||||
| Interactive client (Claude.ai, Cursor, VS Code…) | OAuth 2.1 with browser consent — TREK issues tokens after you approve scopes in a consent screen |
|
||||
| AI agent or script running unattended | Machine client (client_credentials) — token obtained directly via `client_id` + `client_secret`, no browser ever opened |
|
||||
| Legacy setups | Static API token — deprecated, full access, no scopes |
|
||||
|
||||
See [MCP-Setup](MCP-Setup) for step-by-step instructions for each method.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **MCP addon enabled** — an administrator must enable the MCP addon (`mcp`) from the Admin Panel before the `/mcp` endpoint becomes available and the MCP section appears in user settings.
|
||||
|
||||
+39
-26
@@ -1,6 +1,6 @@
|
||||
# MCP Setup
|
||||
|
||||
This page explains how to connect an AI assistant to your TREK instance. TREK supports two authentication methods: OAuth 2.1 (recommended) and static API tokens (deprecated).
|
||||
This page explains how to connect an AI assistant to your TREK instance. TREK supports three authentication methods: OAuth 2.1 with browser consent (recommended for interactive clients), machine clients with no browser login (recommended for AI agents and scripts), and static API tokens (deprecated).
|
||||
|
||||
<!-- TODO: screenshot: OAuth client registration form -->
|
||||
|
||||
@@ -23,25 +23,12 @@ Claude.ai (web) supports native MCP connections — no JSON config file required
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Claude Desktop connects via `mcp-remote`. After creating an OAuth client using the **Claude Desktop** preset (redirect URI: `http://localhost`), add the following to your Claude Desktop config:
|
||||
Claude Desktop supports native MCP connections — no JSON config file required:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://<your-trek-instance>/mcp",
|
||||
"--static-oauth-client-info",
|
||||
"{\"client_id\": \"<your_client_id>\", \"client_secret\": \"<your_client_secret>\"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow.
|
||||
1. In TREK, go to **Settings → Integrations → MCP → OAuth Clients** and click **Create**.
|
||||
2. Select the **Claude Desktop** preset. This fills in the redirect URI and a default scope set.
|
||||
3. Give the client a name, adjust scopes if needed, and save. Copy the client ID and client secret — the secret is shown only once.
|
||||
4. In Claude Desktop, open Settings → MCP and add a new server using your TREK URL (`https://<your-trek-instance>/mcp`). Claude Desktop will open your browser to complete the OAuth consent flow.
|
||||
|
||||
### Cursor, VS Code, Windsurf, and Zed
|
||||
|
||||
@@ -99,9 +86,34 @@ Create a client in TREK using the appropriate preset (Cursor, VS Code, Windsurf,
|
||||
|
||||
Each user can have up to **10 OAuth clients**.
|
||||
|
||||
## Option B: Static API token (deprecated)
|
||||
## Option B: Machine client — no browser login (for AI agents and scripts)
|
||||
|
||||
> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1.
|
||||
Use this when your AI agent or automation script needs to authenticate silently without any browser interaction. Instead of going through an OAuth consent flow, the client exchanges a `client_id` and `client_secret` directly for an access token ([RFC 6749 §4.4 — Client Credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).
|
||||
|
||||
**Why this exists:** browser-based OAuth flows break when an AI agent runs unattended. The agent may fire multiple concurrent token refreshes, causing replay detection to invalidate the session and open browser windows. Machine clients sidestep this entirely — there is no refresh token and no rotation race.
|
||||
|
||||
**How it works:** the token acts as its owner (the user who created the client), scoped to the permissions chosen at creation. All TREK permission checks still apply — the AI agent can only access what you can access, narrowed further to the selected scopes.
|
||||
|
||||
### Create a machine client
|
||||
|
||||
1. Go to **Settings → Integrations → MCP → OAuth Clients** and click **New Client**.
|
||||
2. Tick **Machine client (no browser login)**. The redirect URI field disappears — machine clients don't need one.
|
||||
3. Give it a name, select scopes, and click **Register Client**.
|
||||
4. Copy the `client_id` and `client_secret` shown — the secret is displayed only once.
|
||||
|
||||
### How token management works
|
||||
|
||||
Your AI client uses the `client_id` and `client_secret` to request a token directly from TREK (`POST /oauth/token` with `grant_type=client_credentials`). Tokens are valid for 1 hour. When one expires, the client requests a new one silently — no browser window, no user action, no consent screen. This is handled entirely by the client.
|
||||
|
||||
### Who should use this
|
||||
|
||||
Machine clients are designed for **AI agent frameworks and custom MCP client implementations** that can call the token endpoint themselves and handle renewal programmatically. TREK advertises `client_credentials` in its OAuth discovery document (`/.well-known/oauth-authorization-server`), so any compliant client can discover and use it automatically.
|
||||
|
||||
> **`mcp-remote` users:** `mcp-remote` implements the browser-based `authorization_code` flow only — it does not support `client_credentials`. If you use `mcp-remote`, stick with Option A and use the preset for your client. The machine client option is not applicable.
|
||||
|
||||
## Option C: Static API token (deprecated)
|
||||
|
||||
> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1 or machine clients.
|
||||
|
||||
Static tokens grant full access to all tools and resources with no scope restrictions. Sessions using a static token will receive deprecation warnings in the AI client on every tool call.
|
||||
|
||||
@@ -129,11 +141,12 @@ Each user can create up to **10 static tokens**.
|
||||
|
||||
## Authentication reference
|
||||
|
||||
| Method | Token prefix | Access level | Expiry |
|
||||
|---|---|---|---|
|
||||
| OAuth 2.1 access token | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
|
||||
| OAuth client secret | `trekcs_` | Used during OAuth registration | No expiry (revoke via UI) |
|
||||
| Static API token | `trek_` | Full access | No expiry — **deprecated** |
|
||||
| Method | Grant | Token prefix | Access level | Expiry |
|
||||
|---|---|---|---|---|
|
||||
| OAuth 2.1 — browser consent | `authorization_code` | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
|
||||
| Machine client — no browser | `client_credentials` | `trekoa_` | Scoped (per-client), acts as owner | 1 hour; re-request silently, no refresh token |
|
||||
| OAuth client secret | — | `trekcs_` | Used to authenticate the client at the token endpoint | No expiry (revoke via UI) |
|
||||
| Static API token | — | `trek_` | Full access | No expiry — **deprecated** |
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
Reference in New Issue
Block a user