diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea7f96c..fa70b9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 1.4.5 (2026-03-20) + +### Features + +- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar +- **Calendar**: Add pending event preview functionality to calendar views and event modal +- **Calendar**: Add setting to show event start time in month view +- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability +- **Email**: Add attachment position setting in email settings +- **Layout**: Add mobile visibility toggle for sidebar apps +- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users + +### Fixes + +- **Auth**: Enhance account switching logic and clear stores on account change +- **Auth**: Improve account restoration logic and handle stale accounts +- **Auth**: Improve draft handling in email composer and enhance session cookie verification +- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65) +- **Calendar**: Validate event start field when fetching calendar events +- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups +- **Calendar**: Correct JSX syntax in CalendarToolbar component +- **Dependencies**: Update flatted to 3.4.2 +- **DevOps**: Use native ARM runners instead of QEMU for Docker builds +- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe + ## 1.4.4 (2026-03-19) ### Features diff --git a/VERSION b/VERSION index 1c99cf0e..e516bb9d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.4 +1.4.5 diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 398abaa2..19e41bbc 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -25,7 +25,7 @@ import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; -import { EventModal } from "@/components/calendar/event-modal"; +import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; @@ -82,6 +82,7 @@ export default function CalendarPage() { const [pendingScopeAction, setPendingScopeAction] = useState(null); const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); + const [pendingPreview, setPendingPreview] = useState(null); const hasFetched = useRef(false); // Sidebar resize state @@ -268,12 +269,13 @@ export default function CalendarPage() { }, [closeDetail, openEditModal]); const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { + if (isMobile) return; if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } // Don't show hover popover if the sidebar is already open for this event if (showEventModal && editEvent?.id === event.id) return; setDetailEvent(event); setDetailAnchorRect(anchorRect); - }, [showEventModal, editEvent]); + }, [isMobile, showEventModal, editEvent]); const handleHoverLeave = useCallback(() => { hoverTimerRef.current = setTimeout(() => { @@ -615,7 +617,7 @@ export default function CalendarPage() { const visibleEvents = useMemo(() => events.filter((e) => { - if (!e.calendarIds) return false; + if (!e.start || !e.calendarIds) return false; const calIds = Object.keys(e.calendarIds); return calIds.some((id) => selectedCalendarIds.includes(id)); }), @@ -648,6 +650,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "week": @@ -664,6 +667,7 @@ export default function CalendarPage() { firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "day": @@ -678,6 +682,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} timeFormat={timeFormat} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "agenda": @@ -708,7 +713,7 @@ export default function CalendarPage() { }; return ( -
+
{/* Left Navigation Rail */} {!isMobile && (
@@ -771,7 +776,7 @@ export default function CalendarPage() { )} {!inlineApp && ( -
+
{ setShowEventModal(false); setEditEvent(null); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }} + onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} isMobile={false} /> @@ -831,13 +837,15 @@ export default function CalendarPage() { {/* Mobile Bottom Navigation */} {isMobile && ( - +
+ +
)} {detailEvent && detailAnchorRect && ( diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index fbf631dd..a7f47981 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -544,7 +544,7 @@ export default function ContactsPage() { }; return ( -
+
{/* Navigation Rail - desktop only */} {!isMobile && (
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index ccb3eb26..ed688c5f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { decryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; function isEnabled(): boolean { @@ -10,19 +10,30 @@ function isEnabled(): boolean { } /** - * Verify identity against the session cookie if available. - * Returns true if no session cookie exists (can't verify) or if identity matches. - * Returns false if session cookie exists but identity doesn't match. + * Verify identity against session cookies across all account slots. + * With multi-account, the requesting account may be on any slot (0-4). + * Returns true if any slot matches OR if no session cookies exist at all. */ async function verifyIdentity(username: string, serverUrl: string): Promise { const cookieStore = await cookies(); - const sessionToken = cookieStore.get(SESSION_COOKIE)?.value; - if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies) + let hasAnyCookie = false; - const session = decryptSession(sessionToken); - if (!session) return true; // Invalid session cookie, skip verification + for (let slot = 0; slot <= 4; slot++) { + const token = cookieStore.get(sessionCookieName(slot))?.value; + if (!token) continue; + hasAnyCookie = true; - return session.username === username && session.serverUrl === serverUrl; + const session = decryptSession(token); + if (session && session.username === username && session.serverUrl === serverUrl) { + return true; // Found a matching slot + } + } + + // No cookies at all → can't verify, allow (same-origin protection applies) + if (!hasAnyCookie) return true; + + // Cookies exist but none matched → identity mismatch + return false; } export async function GET(request: NextRequest) { diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..c1d0dd64 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; +import { useAuthStore } from "@/stores/auth-store"; + +export default function NotFound() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated) { + window.location.href = "/login"; + } + }, [isAuthenticated]); + + if (!isAuthenticated) { + return null; + } + + return ( +
+
+

404

+

This page could not be found.

+ + Go home + +
+
+ ); +} diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index fbc9af96..09cd5372 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -2,13 +2,14 @@ import { useMemo, useEffect, useRef, useState } from "react"; import { useTranslations, useFormatter } from "next-intl"; -import { format, isToday, parseISO } from "date-fns"; +import { format, isSameDay, isToday, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; 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"; +import type { PendingEventPreview } from "./event-modal"; interface CalendarDayViewProps { selectedDate: Date; @@ -20,6 +21,7 @@ interface CalendarDayViewProps { onCreateAtTime: (date: Date, endDate?: Date) => void; timeFormat?: "12h" | "24h"; isMobile?: boolean; + pendingPreview?: PendingEventPreview | null; } const HOUR_HEIGHT = 64; @@ -35,6 +37,7 @@ export function CalendarDayView({ onCreateAtTime, timeFormat = "24h", isMobile, + pendingPreview, }: CalendarDayViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -274,6 +277,34 @@ export function CalendarDayView({
)} + + {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && ( + (() => { + const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); + const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + const durationMin = Math.max(15, endMin - startMin); + const cal = calendars.find(c => c.id === pendingPreview.calendarId); + const color = cal?.color || "hsl(var(--primary))"; + return ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )}
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 8974a917..be21714c 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; +import type { PendingEventPreview } from "./event-modal"; import { toast } from "@/stores/toast-store"; interface CalendarMonthViewProps { @@ -25,6 +26,7 @@ interface CalendarMonthViewProps { onCreateAtTime?: (date: Date) => void; firstDayOfWeek?: number; isMobile?: boolean; + pendingPreview?: PendingEventPreview | null; } export function CalendarMonthView({ @@ -38,6 +40,7 @@ export function CalendarMonthView({ onCreateAtTime, firstDayOfWeek = 1, isMobile, + pendingPreview, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -194,31 +197,63 @@ export function CalendarMonthView({
{isMobile ? ( - dayEvents.length > 0 && ( -
- {dayEvents.slice(0, 3).map((ev) => { - const calId = getPrimaryCalendarId(ev); - const cal = calId ? calendarMap.get(calId) : undefined; - const evColor = ev.color || cal?.color || "#3b82f6"; - return ( - - ); - })} - {dayEvents.length > 3 && ( - - )} -
- ) +
+ {dayEvents.slice(0, 3).map((ev) => { + const calId = getPrimaryCalendarId(ev); + const cal = calId ? calendarMap.get(calId) : undefined; + const evColor = ev.color || cal?.color || "#3b82f6"; + return ( + + ); + })} + {dayEvents.length > 3 && ( + + )} + {pendingPreview && isSameDay(pendingPreview.start, day) && ( + + )} +
) : null}
); })}
+ {!isMobile && pendingPreview && (() => { + const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start)); + if (previewDayIdx === -1) return null; + const previewRow = rowCount; + const cal = calendarMap.get(pendingPreview.calendarId); + const color = cal?.color || "#3b82f6"; + return ( +
+
+
+ {pendingPreview.title} +
+
+
+ ); + })()} + {!isMobile && segments.length > 0 && (
{segments.map((segment) => { diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 9320dcd2..3f88d83a 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -136,6 +136,20 @@ export function CalendarToolbar({ {t("views.today")} + {!isMobile && ( +
+ + + + {getDateLabel()} + +
+ )} + {isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
)} + + {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( + (() => { + const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); + const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + const durationMin = Math.max(15, endMin - startMin); + const cal = calendars.find(c => c.id === pendingPreview.calendarId); + const color = cal?.color || "hsl(var(--primary))"; + return ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )}
); })} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index e45bfa93..40908ac1 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -70,6 +70,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM const color = getEventColor(event, calendar); const startDate = parseISO(event.start); const timeFormat = useSettingsStore((state) => state.timeFormat); + const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const calendarName = calendar?.name || ""; @@ -156,6 +157,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} >
+ {showTimeInMonthView && !event.showWithoutTime && ( + {format(startDate, timeFmt)} + )} {event.title || t("events.no_title")}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 8f08c8d5..61961bbf 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -20,6 +20,14 @@ import { } from "@/lib/calendar-participants"; import { useSettingsStore } from "@/stores/settings-store"; +export interface PendingEventPreview { + start: Date; + end: Date; + title: string; + allDay: boolean; + calendarId: string; +} + interface EventModalProps { event?: CalendarEvent | null; calendars: Calendar[]; @@ -30,6 +38,7 @@ interface EventModalProps { onDuplicate?: (data: Partial) => void; onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void; onClose: () => void; + onPreviewChange?: (preview: PendingEventPreview | null) => void; currentUserEmails?: string[]; isMobile?: boolean; } @@ -105,6 +114,7 @@ export function EventModal({ onDuplicate, onRsvp, onClose, + onPreviewChange, currentUserEmails = [], isMobile = false, }: EventModalProps) { @@ -219,6 +229,18 @@ export function EventModal({ }); const [sendInvitations, setSendInvitations] = useState(true); + // Report live preview to parent for grid outline + useEffect(() => { + if (!onPreviewChange || isEdit) return; + const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`; + const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`; + const s = new Date(startStr); + const e = new Date(endStr); + if (isNaN(s.getTime()) || isNaN(e.getTime())) return; + onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId }); + return () => onPreviewChange(null); + }, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]); + const statusCounts = useMemo(() => { if (!event?.participants) return null; return getStatusCounts(event); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index aaa93e12..4e22eba8 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -217,8 +217,8 @@ export function NavigationRail({ ); })} - {/* Custom sidebar apps */} - {sidebarApps.map((app) => { + {/* Custom sidebar apps (per-app mobile visibility) */} + {sidebarApps.filter((app) => app.showOnMobile).map((app) => { const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined; const isActive = activeAppId === app.id; return ( @@ -252,16 +252,27 @@ export function NavigationRail({ ); })} - {/* Manage apps button */} - {onManageApps && ( - - )} + {/* Settings */} + onCloseInlineApp?.() : undefined} + className={cn( + "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]", + "transition-colors duration-150", + isSettingsActive + ? "text-primary" + : "text-muted-foreground hover:text-foreground" + )} + aria-current={isSettingsActive ? "page" : undefined} + > +
+ + {isSettingsActive && ( + + )} +
+ {t("settings")} + ); } diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index d399b538..6f858f4a 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -14,6 +14,7 @@ export function CalendarSettings() { const { timeFormat, firstDayOfWeek, + showTimeInMonthView, calendarNotificationsEnabled, calendarNotificationSound, calendarInvitationParsingEnabled, @@ -57,6 +58,16 @@ export function CalendarSettings() { /> + + updateSetting('showTimeInMonthView', checked)} + /> + + >({}); @@ -144,6 +146,25 @@ function AppForm({ +
+ + +
+