diff --git a/app/api/caldav/discover/route.ts b/app/api/caldav/discover/route.ts new file mode 100644 index 00000000..35d9bf2c --- /dev/null +++ b/app/api/caldav/discover/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +interface DiscoveryAccountRequest { + key: string; + candidates: string[]; +} + +interface DiscoveryResult { + url: string | null; + resolvedAccount: string | null; +} + +function buildPublicUrl(serverUrl: string, path: string): string { + return new URL(path, serverUrl).toString(); +} + +async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise { + const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`); + const response = await fetch(targetUrl, { + method: 'PROPFIND', + headers: { + Authorization: authHeader, + Depth: '0', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body: ` + + + + + +`, + redirect: 'manual', + }); + + if (response.status === 207) { + return targetUrl; + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('Location'); + if (location) { + return new URL(location, targetUrl).toString(); + } + } + + return null; +} + +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : []; + const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav'); + const discovered: Record = {}; + + for (const account of accounts) { + if (!account?.key) continue; + + const candidates = Array.from(new Set( + (account.candidates || []) + .map((candidate) => candidate?.trim()) + .filter((candidate): candidate is string => Boolean(candidate)) + )); + + let url: string | null = null; + let resolvedAccount: string | null = null; + + for (const candidate of candidates) { + try { + url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate); + if (url) { + resolvedAccount = candidate; + break; + } + } catch (error) { + logger.warn('CalDAV discovery probe failed', { + accountKey: account.key, + candidate, + error: error instanceof Error ? error.message : 'Unknown', + }); + } + } + + discovered[account.key] = { url, resolvedAccount }; + } + + return NextResponse.json({ + wellKnownUrl, + accounts: discovered, + }); + } catch (error) { + logger.error('CalDAV discovery failed', { error: error instanceof Error ? error.message : 'Unknown' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index f010d773..412927be 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -6,7 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns"; import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseDuration, getEventColor } from "./event-card"; -import { getEventDayBounds } from "@/lib/calendar-utils"; +import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { getParticipantCount } from "@/lib/calendar-participants"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -113,8 +113,8 @@ export function CalendarAgendaView({
{group.events.map((ev) => { - const calId = Object.keys(ev.calendarIds)[0]; - const calendar = calendarMap.get(calId); + const calId = getPrimaryCalendarId(ev); + const calendar = calId ? calendarMap.get(calId) : undefined; const color = getEventColor(ev, calendar); const start = parseISO(ev.start); const durMin = parseDuration(ev.duration); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index cd78490f..fbc9af96 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -6,7 +6,7 @@ import { format, isToday, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; -import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; +import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; @@ -127,12 +127,12 @@ export function CalendarDayView({
{t("events.all_day")}
{allDayEvents.map((ev) => { - const calId = Object.keys(ev.calendarIds)[0]; + const calId = getPrimaryCalendarId(ev); return ( onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} @@ -192,7 +192,7 @@ export function CalendarDayView({ const top = (startMin / 60) * HOUR_HEIGHT; const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; - const calId = Object.keys(ev.calendarIds)[0]; + const calId = getPrimaryCalendarId(ev); const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; @@ -205,7 +205,7 @@ export function CalendarDayView({ > onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 56641227..8974a917 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -8,7 +8,7 @@ import { } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; -import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils"; +import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; @@ -197,8 +197,8 @@ export function CalendarMonthView({ dayEvents.length > 0 && (
{dayEvents.slice(0, 3).map((ev) => { - const calId = Object.keys(ev.calendarIds)[0]; - const cal = calendarMap.get(calId); + const calId = getPrimaryCalendarId(ev); + const cal = calId ? calendarMap.get(calId) : undefined; const evColor = ev.color || cal?.color || "#3b82f6"; return ( 0 && (
{segments.map((segment) => { - const calId = Object.keys(segment.event.calendarIds)[0]; + const calId = getPrimaryCalendarId(segment.event); return (
c.isShared); const groups = new Map(); for (const cal of shared) { - const key = cal.accountId!; + const key = cal.accountId || cal.accountName || cal.id; if (!groups.has(key)) { groups.set(key, { accountName: cal.accountName || key, calendars: [] }); } diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 5780eb41..9320dcd2 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -183,7 +183,7 @@ export function CalendarToolbar({ const shared = calendars.filter(c => c.isShared); const groups = new Map(); for (const c of shared) { - const key = c.accountId!; + const key = c.accountId || c.accountName || c.id; if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] }); groups.get(key)!.cals.push(c); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index e54bfe9b..bd4543ec 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -8,7 +8,7 @@ import { import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; -import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; +import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; @@ -162,7 +162,7 @@ export function CalendarWeekView({
{allDaySegments.map((segment) => { - const calId = Object.keys(segment.event.calendarIds)[0]; + const calId = getPrimaryCalendarId(segment.event); return (
onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 795ea232..8f08c8d5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid import { format, parseISO, addHours, addDays } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; -import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils"; +import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput } from "./participant-input"; import { isOrganizer, @@ -184,7 +184,7 @@ export function EventModal({ const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); const [allDay, setAllDay] = useState(event?.showWithoutTime || false); const [calendarId, setCalendarId] = useState(() => { - if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || ""; + if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; const defaultCal = calendars.find(c => c.isDefault); return defaultCal?.id || calendars[0]?.id || ""; }); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index bd92a0cc..52ac07f0 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -153,6 +153,9 @@ export function CalendarManagementSettings() { const { client, serverUrl, username } = useAuthStore(); const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); + const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState>({}); + const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState(null); + const [isCreating, setIsCreating] = useState(false); const [editingId, setEditingId] = useState(null); const [deletingId, setDeletingId] = useState(null); @@ -175,6 +178,60 @@ export function CalendarManagementSettings() { } }, [client, calendars.length, fetchCalendars]); + useEffect(() => { + if (!client || !serverUrl || !username) { + setDiscoveredCalDavUrls({}); + setWellKnownCalDavUrl(null); + return; + } + + const primaryKey = username; + const accounts = new Map(); + accounts.set(primaryKey, [username]); + + for (const calendar of calendars) { + if (!calendar.isShared) continue; + const key = calendar.accountId || calendar.accountName || calendar.id; + const candidates = accounts.get(key) || []; + if (calendar.accountId) candidates.push(calendar.accountId); + if (calendar.accountName) candidates.push(calendar.accountName); + accounts.set(key, candidates); + } + + const controller = new AbortController(); + + fetch('/api/caldav/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })), + }), + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`CalDAV discovery failed: ${response.status}`); + return response.json() as Promise<{ + wellKnownUrl?: string; + accounts?: Record; + }>; + }) + .then((payload) => { + setWellKnownCalDavUrl(payload.wellKnownUrl || null); + const next: Record = {}; + for (const [key, value] of Object.entries(payload.accounts || {})) { + next[key] = value?.url || null; + } + setDiscoveredCalDavUrls(next); + }) + .catch(() => { + const fallbackWellKnown = new URL('/.well-known/caldav', serverUrl).toString(); + setDiscoveredCalDavUrls({}); + setWellKnownCalDavUrl(fallbackWellKnown); + }); + + return () => controller.abort(); + }, [client, calendars, serverUrl, username]); + const handleRefreshSubscription = async (subId: string) => { if (!client) return; setRefreshingSubId(subId); @@ -295,8 +352,10 @@ export function CalendarManagementSettings() { const buildCalDavUrl = (calendarId: string) => { if (!serverUrl || !username) return null; - const base = serverUrl.replace(/\/$/, ''); - return `${base}/dav/cal/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`; + const calendar = calendars.find((entry) => entry.id === calendarId); + if (!calendar) return null; + const accountKey = calendar.isShared ? (calendar.accountId || calendar.accountName || calendar.id) : username; + return discoveredCalDavUrls[accountKey] || wellKnownCalDavUrl; }; const handleCopyUrl = async (url: string) => { diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 3c2c452e..ac1df05e 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -142,3 +142,7 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri } return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } + +export function getPrimaryCalendarId(event: Pick): string | undefined { + return Object.keys(event.calendarIds || {})[0]; +}