diff --git a/.env.example b/.env.example index ed961bb..ce2fbff 100644 --- a/.env.example +++ b/.env.example @@ -16,5 +16,17 @@ SECRET_KEY=change-this-to-a-random-secret-key-in-production # Timezone (applied to backend + db containers via env_file) TZ=Australia/Perth +# Session cookie security +# Set to true when serving over HTTPS. Required before any TLS deployment. +# COOKIE_SECURE=true + # Integrations OPENWEATHERMAP_API_KEY=your-openweathermap-api-key + +# Production security checklist (enable all before any non-internal deployment): +# 1. Set SECRET_KEY to output of: openssl rand -hex 32 +# 2. Set POSTGRES_PASSWORD to a strong unique value +# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000) +# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream) +# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; +# 6. Complete user_id migration (migration 026) before enabling multi-user accounts diff --git a/README.md b/README.md index cbcf9e8..a1af264 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you - **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root - **No external backend port** — port 8000 is internal-only; all traffic flows through nginx - **Server version suppression** — `server_tokens off` (nginx) and `--no-server-header` (uvicorn) -- **Rate limiting** — nginx `limit_req_zone` (10 req/min, burst=5) on `/api/auth/login`, `/verify-password`, `/change-password`, `/totp-verify` -- **Application-level rate limiting** — in-memory IP-based rate limit (5 attempts / 5 min) + DB-backed account lockout (10 failures = 30-min lock) +- **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3) +- **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account - **Dotfile blocking** — `/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME) - **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts - **CORS** — configurable origins with explicit method/header allowlists diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 531d8ce..e0fb04b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -9,14 +9,12 @@ Session flow: GET /status → verify user exists + session valid Security layers: - 1. IP-based in-memory rate limit (5 attempts / 5 min) — outer guard, username enumeration + 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard 3. Session revocation stored in DB (survives container restarts) 4. bcrypt→Argon2id transparent upgrade on first login with migrated hash """ import uuid -import time -from collections import defaultdict from datetime import datetime, timedelta from typing import Optional @@ -40,41 +38,6 @@ from app.config import settings as app_settings router = APIRouter() -# --------------------------------------------------------------------------- -# IP-based in-memory rate limit (retained as outer layer for all login attempts) -# --------------------------------------------------------------------------- -_failed_attempts: dict[str, list[float]] = defaultdict(list) -_MAX_IP_ATTEMPTS = 5 -_IP_WINDOW_SECONDS = 300 # 5 minutes -_MAX_TRACKED_IPS = 10000 # cap to prevent unbounded memory growth - - -def _check_ip_rate_limit(ip: str) -> None: - """Raise 429 if the IP has exceeded the failure window.""" - now = time.time() - # Purge all stale entries if the dict is oversized (spray attack defense) - if len(_failed_attempts) > _MAX_TRACKED_IPS: - stale_ips = [k for k, v in _failed_attempts.items() if all(now - t >= _IP_WINDOW_SECONDS for t in v)] - for k in stale_ips: - del _failed_attempts[k] - # If still over cap after purge, clear everything (all entries are within window - # but we can't let memory grow unbounded — login will still hit account lockout) - if len(_failed_attempts) > _MAX_TRACKED_IPS: - _failed_attempts.clear() - _failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS] - if not _failed_attempts[ip]: - _failed_attempts.pop(ip, None) - elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS: - raise HTTPException( - status_code=429, - detail="Too many failed login attempts. Try again in a few minutes.", - ) - - -def _record_ip_failure(ip: str) -> None: - _failed_attempts[ip].append(time.time()) - - # --------------------------------------------------------------------------- # Cookie helper # --------------------------------------------------------------------------- @@ -267,14 +230,12 @@ async def login( HTTP 429 — IP rate limited """ client_ip = request.client.host if request.client else "unknown" - _check_ip_rate_limit(client_ip) # Lookup user — do NOT differentiate "user not found" from "wrong password" result = await db.execute(select(User).where(User.username == data.username)) user = result.scalar_one_or_none() if not user: - _record_ip_failure(client_ip) raise HTTPException(status_code=401, detail="Invalid username or password") await _check_account_lockout(user) @@ -283,7 +244,6 @@ async def login( valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) if not valid: - _record_ip_failure(client_ip) await _record_failed_login(db, user) raise HTTPException(status_code=401, detail="Invalid username or password") @@ -291,8 +251,6 @@ async def login( if new_hash: user.password_hash = new_hash - # Clear IP failures and update user state - _failed_attempts.pop(client_ip, None) await _record_successful_login(db, user) # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session @@ -376,7 +334,6 @@ async def auth_status( @router.post("/verify-password") async def verify_password( data: VerifyPasswordRequest, - request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -384,20 +341,15 @@ async def verify_password( Verify the current user's password without changing anything. Used by the frontend lock screen to re-authenticate without a full login. Also handles transparent bcrypt→Argon2id upgrade. - Shares the same rate-limit and lockout guards as /login. + Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting. """ - client_ip = request.client.host if request.client else "unknown" - _check_ip_rate_limit(client_ip) await _check_account_lockout(current_user) valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash) if not valid: - _record_ip_failure(client_ip) await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid password") - _failed_attempts.pop(client_ip, None) - # Persist upgraded hash if migration happened if new_hash: current_user.password_hash = new_hash @@ -409,22 +361,17 @@ async def verify_password( @router.post("/change-password") async def change_password( data: ChangePasswordRequest, - request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Change the current user's password. Requires old password verification.""" - client_ip = request.client.host if request.client else "unknown" - _check_ip_rate_limit(client_ip) await _check_account_lockout(current_user) valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash) if not valid: - _record_ip_failure(client_ip) await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid current password") - _failed_attempts.pop(client_ip, None) current_user.password_hash = hash_password(data.new_password) await db.commit() diff --git a/backend/app/services/ntfy.py b/backend/app/services/ntfy.py index 8a1578b..3fb4f2b 100644 --- a/backend/app/services/ntfy.py +++ b/backend/app/services/ntfy.py @@ -17,13 +17,18 @@ logger = logging.getLogger(__name__) NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests -# Loopback + link-local only. Private IPs (RFC 1918) are intentionally allowed -# because UMBRA is self-hosted and the user's ntfy server is typically on the same LAN. +# Loopback, link-local, and all RFC 1918 private ranges are blocked to prevent +# SSRF against Docker-internal services. If a self-hosted ntfy server on the LAN +# is required, remove the RFC 1918 entries from _BLOCKED_NETWORKS and document the accepted risk. _BLOCKED_NETWORKS = [ - ipaddress.ip_network("127.0.0.0/8"), - ipaddress.ip_network("169.254.0.0/16"), - ipaddress.ip_network("::1/128"), - ipaddress.ip_network("fe80::/10"), + ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback + ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private + ipaddress.ip_network("172.16.0.0/12"), # RFC 1918 private — covers Docker bridge 172.17-31.x + ipaddress.ip_network("192.168.0.0/16"), # RFC 1918 private + ipaddress.ip_network("169.254.0.0/16"), # IPv4 link-local + ipaddress.ip_network("::1/128"), # IPv6 loopback + ipaddress.ip_network("fe80::/10"), # IPv6 link-local + ipaddress.ip_network("fc00::/7"), # IPv6 ULA (covers fd00::/8) ] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0605125..3de626d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -46,6 +46,13 @@ server { include /etc/nginx/proxy-params.conf; } + location /api/auth/setup { + # Tighter burst: setup is one-time-only, 3 attempts is sufficient + limit_req zone=auth_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # API proxy location /api { proxy_pass http://backend:8000; diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 94bb260..8f68451 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import FullCalendar from '@fullcalendar/react'; @@ -6,20 +7,16 @@ import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, EventTemplate } from '@/types'; +import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import CalendarSidebar from './CalendarSidebar'; -import EventForm from './EventForm'; +import EventDetailPanel from './EventDetailPanel'; +import type { CreateDefaults } from './EventDetailPanel'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @@ -29,32 +26,57 @@ const viewLabels: Record = { timeGridDay: 'Day', }; -type ScopeAction = 'edit' | 'delete'; - export default function CalendarPage() { const queryClient = useQueryClient(); + const location = useLocation(); const calendarRef = useRef(null); - const [showForm, setShowForm] = useState(false); - const [editingEvent, setEditingEvent] = useState(null); - const [selectedStart, setSelectedStart] = useState(null); - const [selectedEnd, setSelectedEnd] = useState(null); - const [selectedAllDay, setSelectedAllDay] = useState(false); const [currentView, setCurrentView] = useState('dayGridMonth'); const [calendarTitle, setCalendarTitle] = useState(''); - const [templateEvent, setTemplateEvent] = useState | null>(null); - const [templateName, setTemplateName] = useState(null); + const [eventSearch, setEventSearch] = useState(''); + const [searchFocused, setSearchFocused] = useState(false); - // Scope dialog state - const [scopeDialogOpen, setScopeDialogOpen] = useState(false); - const [scopeAction, setScopeAction] = useState('edit'); - const [scopeEvent, setScopeEvent] = useState(null); - const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null); + // Panel state + const [selectedEventId, setSelectedEventId] = useState(null); + const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed'); + const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); const { data: calendars = [] } = useCalendars(); const calendarContainerRef = useRef(null); + // Location data for event panel + const { data: locations = [] } = useQuery({ + queryKey: ['locations'], + queryFn: async () => { + const { data } = await api.get('/locations'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + + const locationMap = useMemo(() => { + const map = new Map(); + locations.forEach((l) => map.set(l.id, l.name)); + return map; + }, [locations]); + + // Handle navigation state from dashboard + useEffect(() => { + const state = location.state as { date?: string; view?: string; eventId?: number } | null; + if (!state) return; + const calApi = calendarRef.current?.getApi(); + if (state.date && calApi) { + calApi.gotoDate(state.date); + if (state.view) calApi.changeView(state.view); + } + if (state.eventId) { + setSelectedEventId(state.eventId); + setPanelMode('view'); + } + window.history.replaceState({}, ''); + }, [location.state]); + // Resize FullCalendar when container size changes (e.g. sidebar collapse) useEffect(() => { const el = calendarContainerRef.current; @@ -66,6 +88,23 @@ export default function CalendarPage() { return () => observer.disconnect(); }, []); + const panelOpen = panelMode !== 'closed'; + + // Continuously resize calendar during panel open/close CSS transition + useEffect(() => { + let rafId: number; + const start = performance.now(); + const duration = 350; // slightly longer than the 300ms CSS transition + const tick = () => { + calendarRef.current?.getApi().updateSize(); + if (performance.now() - start < duration) { + rafId = requestAnimationFrame(tick); + } + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [panelOpen]); + // Scroll wheel navigation in month view useEffect(() => { const el = calendarContainerRef.current; @@ -94,6 +133,21 @@ export default function CalendarPage() { }, }); + const selectedEvent = useMemo( + () => events.find((e) => e.id === selectedEventId) ?? null, + [selectedEventId, events], + ); + + // Escape key closes detail panel + useEffect(() => { + if (!panelOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') handlePanelClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [panelOpen]); + const visibleCalendarIds = useMemo( () => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), [calendars], @@ -152,28 +206,38 @@ export default function CalendarPage() { }, }); - const scopeDeleteMutation = useMutation({ - mutationFn: async ({ id, scope }: { id: number; scope: string }) => { - await api.delete(`/events/${id}?scope=${scope}`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); - queryClient.invalidateQueries({ queryKey: ['dashboard'] }); - queryClient.invalidateQueries({ queryKey: ['upcoming'] }); - toast.success('Event(s) deleted'); - setScopeDialogOpen(false); - setScopeEvent(null); - }, - onError: (error) => { - toast.error(getErrorMessage(error, 'Failed to delete event')); - }, - }); - const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); }, [events, visibleCalendarIds, calendars.length]); + const searchResults = useMemo(() => { + if (!eventSearch.trim()) return []; + const q = eventSearch.toLowerCase(); + return filteredEvents + .filter((e) => e.title.toLowerCase().includes(q)) + .slice(0, 8); + }, [filteredEvents, eventSearch]); + + const handleSearchSelect = (event: CalendarEvent) => { + const calApi = calendarRef.current?.getApi(); + if (!calApi) return; + const startDate = new Date(event.start_datetime); + calApi.gotoDate(startDate); + if (event.all_day) { + calApi.changeView('dayGridMonth'); + } else { + calApi.changeView('timeGridDay'); + } + setEventSearch(''); + setSearchFocused(false); + // Also open the event in the panel + if (!event.is_virtual) { + setSelectedEventId(event.id); + setPanelMode('view'); + } + }; + const calendarEvents = filteredEvents.map((event) => ({ id: String(event.id), title: event.title, @@ -189,10 +253,6 @@ export default function CalendarPage() { }, })); - const isRecurring = (event: CalendarEvent): boolean => { - return !!(event.is_recurring || event.parent_event_id); - }; - const handleEventClick = (info: EventClickArg) => { const event = events.find((e) => String(e.id) === info.event.id); if (!event) return; @@ -200,27 +260,8 @@ export default function CalendarPage() { toast.info(`${event.title} — from People contacts`); return; } - - if (isRecurring(event)) { - setScopeEvent(event); - setScopeAction('edit'); - setScopeDialogOpen(true); - } else { - setEditingEvent(event); - setShowForm(true); - } - }; - - const handleScopeChoice = (scope: 'this' | 'this_and_future') => { - if (!scopeEvent) return; - if (scopeAction === 'edit') { - setEditingEvent(scopeEvent); - setActiveEditScope(scope); - setShowForm(true); - setScopeDialogOpen(false); - } else if (scopeAction === 'delete') { - scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope }); - } + setSelectedEventId(event.id); + setPanelMode('view'); }; const handleEventDrop = (info: EventDropArg) => { @@ -228,7 +269,6 @@ export default function CalendarPage() { info.revert(); return; } - // Prevent drag-drop on recurring events — user must use scope dialog via click if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { info.revert(); toast.info('Click the event to edit recurring events'); @@ -253,7 +293,6 @@ export default function CalendarPage() { info.revert(); return; } - // Prevent resize on recurring events — user must use scope dialog via click if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { info.revert(); toast.info('Click the event to edit recurring events'); @@ -274,37 +313,43 @@ export default function CalendarPage() { }; const handleDateSelect = (selectInfo: DateSelectArg) => { - setSelectedStart(selectInfo.startStr); - setSelectedEnd(selectInfo.endStr); - setSelectedAllDay(selectInfo.allDay); - setShowForm(true); + setSelectedEventId(null); + setPanelMode('create'); + setCreateDefaults({ + start: selectInfo.startStr, + end: selectInfo.endStr, + allDay: selectInfo.allDay, + }); }; - const handleCloseForm = () => { + const handleCreateNew = () => { + setSelectedEventId(null); + setPanelMode('create'); + setCreateDefaults(null); + }; + + const handlePanelClose = () => { calendarRef.current?.getApi().unselect(); - setShowForm(false); - setEditingEvent(null); - setTemplateEvent(null); - setTemplateName(null); - setActiveEditScope(null); - setSelectedStart(null); - setSelectedEnd(null); - setSelectedAllDay(false); + setPanelMode('closed'); + setSelectedEventId(null); + setCreateDefaults(null); }; const handleUseTemplate = (template: EventTemplate) => { - setTemplateEvent({ - title: template.title, - description: template.description || '', - all_day: template.all_day, - calendar_id: template.calendar_id ?? undefined, - location_id: template.location_id || undefined, - is_starred: template.is_starred, - recurrence_rule: template.recurrence_rule || undefined, - } as Partial); - setTemplateName(template.name); - setEditingEvent(null); - setShowForm(true); + setSelectedEventId(null); + setPanelMode('create'); + setCreateDefaults({ + templateData: { + title: template.title, + description: template.description || '', + all_day: template.all_day, + calendar_id: template.calendar_id ?? undefined, + location_id: template.location_id || undefined, + is_starred: template.is_starred, + recurrence_rule: template.recurrence_rule || undefined, + } as Partial, + templateName: template.name, + }); }; const handleDatesSet = (arg: DatesSetArg) => { @@ -318,11 +363,11 @@ export default function CalendarPage() { const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view); return ( -
+
- {/* Custom toolbar — h-16 matches sidebar header */} + {/* Custom toolbar */}
-

{calendarTitle}

+
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
+ +

{calendarTitle}

+ +
+ + {/* Event search */} +
+ + setEventSearch(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setTimeout(() => setSearchFocused(false), 200)} + className="w-52 h-8 pl-8 text-sm ring-inset" + /> + {searchFocused && searchResults.length > 0 && ( +
+ {searchResults.map((event) => ( + + ))} +
+ )} +
+ +
- {/* Calendar grid */} -
-
- +
+
+ +
+
+ + {/* Detail panel (desktop) */} +
+
- {showForm && ( - - )} - - {/* Recurring event scope dialog */} - - - - - {scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'} - - -

- This is a recurring event. How would you like to proceed? -

-
- - - + {/* Mobile detail panel overlay */} + {panelOpen && ( +
+
e.stopPropagation()} + > +
- -
+
+ )}
); } diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx new file mode 100644 index 0000000..863cffb --- /dev/null +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -0,0 +1,915 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { format, parseISO } from 'date-fns'; +import { + X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, +} from 'lucide-react'; +import api, { getErrorMessage } from '@/lib/api'; +import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { formatUpdatedAt } from '@/components/shared/utils'; +import CopyableField from '@/components/shared/CopyableField'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import LocationPicker from '@/components/ui/location-picker'; + +// --- Helpers --- + +function toDateOnly(dt: string): string { + if (!dt) return ''; + return dt.split('T')[0]; +} + +function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string { + if (!dt) return ''; + if (dt.includes('T')) return dt.slice(0, 16); + return `${dt}T${fallbackTime}`; +} + +function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string { + if (!dt) return ''; + return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); +} + +function adjustAllDayEndForDisplay(dateStr: string): string { + if (!dateStr) return ''; + const d = new Date(dateStr.split('T')[0] + 'T12:00:00'); + d.setDate(d.getDate() - 1); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function adjustAllDayEndForSave(dateStr: string): string { + if (!dateStr) return ''; + const d = new Date(dateStr + 'T12:00:00'); + d.setDate(d.getDate() + 1); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function nowLocal(): string { + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; +} + +function plusOneHour(dt: string): string { + const d = new Date(dt); + d.setHours(d.getHours() + 1); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function formatRecurrenceRule(rule: string): string { + try { + const parsed = JSON.parse(rule); + const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + switch (parsed.type) { + case 'every_n_days': + return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`; + case 'weekly': + return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly'; + case 'monthly_nth_weekday': + return parsed.week && parsed.weekday != null + ? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}` + : 'Monthly'; + case 'monthly_date': + return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly'; + default: + return 'Recurring'; + } + } catch { + return 'Recurring'; + } +} + +function ordinal(n: number): string { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; +} + +function parseRecurrenceRule(raw?: string): RecurrenceRule | null { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// Python weekday: 0=Monday, 6=Sunday +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +// --- Types --- + +export interface CreateDefaults { + start?: string; + end?: string; + allDay?: boolean; + templateData?: Partial; + templateName?: string; +} + +interface EventDetailPanelProps { + event: CalendarEvent | null; + isCreating?: boolean; + createDefaults?: CreateDefaults | null; + onClose: () => void; + onSaved?: () => void; + onDeleted?: () => void; + locationName?: string; +} + +interface EditState { + title: string; + description: string; + start_datetime: string; + end_datetime: string; + all_day: boolean; + location_id: string; + calendar_id: string; + is_starred: boolean; + recurrence_type: string; + recurrence_interval: number; + recurrence_weekday: number; + recurrence_week: number; + recurrence_day: number; +} + +function buildEditStateFromEvent(event: CalendarEvent): EditState { + const rule = parseRecurrenceRule(event.recurrence_rule); + const isAllDay = event.all_day; + const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime; + return { + title: event.title, + description: event.description || '', + start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'), + end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), + all_day: isAllDay, + location_id: event.location_id?.toString() || '', + calendar_id: event.calendar_id?.toString() || '', + is_starred: event.is_starred || false, + recurrence_type: rule?.type || '', + recurrence_interval: rule?.interval || 2, + recurrence_weekday: rule?.weekday ?? 1, + recurrence_week: rule?.week || 1, + recurrence_day: rule?.day || 1, + }; +} + +function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState { + const source = defaults?.templateData; + const isAllDay = source?.all_day ?? defaults?.allDay ?? false; + const defaultStart = nowLocal(); + const defaultEnd = plusOneHour(defaultStart); + const rawStart = defaults?.start || defaultStart; + const rawEnd = defaults?.end || defaultEnd; + const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd; + const rule = parseRecurrenceRule(source?.recurrence_rule); + + return { + title: source?.title || '', + description: source?.description || '', + start_datetime: formatForInput(rawStart, isAllDay, '09:00'), + end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), + all_day: isAllDay, + location_id: source?.location_id?.toString() || '', + calendar_id: source?.calendar_id?.toString() || defaultCalendarId, + is_starred: source?.is_starred || false, + recurrence_type: rule?.type || '', + recurrence_interval: rule?.interval || 2, + recurrence_weekday: rule?.weekday ?? 1, + recurrence_week: rule?.week || 1, + recurrence_day: rule?.day || 1, + }; +} + +function buildRecurrencePayload(state: EditState): RecurrenceRule | null { + if (!state.recurrence_type) return null; + switch (state.recurrence_type) { + case 'every_n_days': + return { type: 'every_n_days', interval: state.recurrence_interval }; + case 'weekly': + return { type: 'weekly' }; + case 'monthly_nth_weekday': + return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday }; + case 'monthly_date': + return { type: 'monthly_date', day: state.recurrence_day }; + default: + return null; + } +} + +// --- Component --- + +export default function EventDetailPanel({ + event, + isCreating = false, + createDefaults, + onClose, + onSaved, + onDeleted, + locationName, +}: EventDetailPanelProps) { + const queryClient = useQueryClient(); + const { data: calendars = [] } = useCalendars(); + const selectableCalendars = calendars.filter((c) => !c.is_system); + const defaultCalendar = calendars.find((c) => c.is_default); + + const { data: locations = [] } = useQuery({ + queryKey: ['locations'], + queryFn: async () => { + const { data } = await api.get('/locations'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState(() => + isCreating + ? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '') + : event + ? buildEditStateFromEvent(event) + : buildCreateState(null, defaultCalendar?.id?.toString() || '') + ); + const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null); + const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); + const [locationSearch, setLocationSearch] = useState(''); + + const isRecurring = !!(event?.is_recurring || event?.parent_event_id); + + // Reset state when event changes + useEffect(() => { + setIsEditing(false); + setScopeStep(null); + setEditScope(null); + setLocationSearch(''); + if (event) setEditState(buildEditStateFromEvent(event)); + }, [event?.id]); + + // Enter edit mode when creating + useEffect(() => { + if (isCreating) { + setIsEditing(true); + setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')); + setLocationSearch(''); + } + }, [isCreating, createDefaults]); + + // Initialize location search text from existing location + useEffect(() => { + if (isEditing && !isCreating && event?.location_id) { + const loc = locations.find((l) => l.id === event.location_id); + if (loc) setLocationSearch(loc.name); + } + }, [isEditing, isCreating, event?.location_id, locations]); + + const invalidateAll = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + }, [queryClient]); + + // --- Mutations --- + + const saveMutation = useMutation({ + mutationFn: async (data: EditState) => { + const rule = buildRecurrencePayload(data); + let endDt = data.end_datetime; + if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt); + + const payload: Record = { + title: data.title, + description: data.description || null, + start_datetime: data.start_datetime, + end_datetime: endDt, + all_day: data.all_day, + location_id: data.location_id ? parseInt(data.location_id) : null, + calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, + is_starred: data.is_starred, + recurrence_rule: rule, + }; + + if (event && !isCreating) { + if (editScope) payload.edit_scope = editScope; + return api.put(`/events/${event.id}`, payload); + } else { + return api.post('/events', payload); + } + }, + onSuccess: () => { + invalidateAll(); + toast.success(isCreating ? 'Event created' : 'Event updated'); + if (isCreating) { + onClose(); + } else { + setIsEditing(false); + setEditScope(null); + } + onSaved?.(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const scope = editScope ? `?scope=${editScope}` : ''; + await api.delete(`/events/${event!.id}${scope}`); + }, + onSuccess: () => { + invalidateAll(); + toast.success('Event deleted'); + onClose(); + onDeleted?.(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete event')); + }, + }); + + const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); + const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete); + + // --- Handlers --- + + const handleEditStart = () => { + if (isRecurring) { + setScopeStep('edit'); + } else { + if (event) setEditState(buildEditStateFromEvent(event)); + setIsEditing(true); + } + }; + + const handleScopeSelect = (scope: 'this' | 'this_and_future') => { + setEditScope(scope); + if (scopeStep === 'edit') { + if (event) setEditState(buildEditStateFromEvent(event)); + setIsEditing(true); + setScopeStep(null); + } else if (scopeStep === 'delete') { + // Delete with scope — execute immediately + setScopeStep(null); + // The deleteMutation will read editScope, but we need to set it first + // Since setState is async, use the mutation directly with the scope + const scopeParam = `?scope=${scope}`; + api.delete(`/events/${event!.id}${scopeParam}`).then(() => { + invalidateAll(); + toast.success('Event(s) deleted'); + onClose(); + onDeleted?.(); + }).catch((error) => { + toast.error(getErrorMessage(error, 'Failed to delete event')); + }); + } + }; + + const handleEditCancel = () => { + setIsEditing(false); + setEditScope(null); + setLocationSearch(''); + if (isCreating) { + onClose(); + } else if (event) { + setEditState(buildEditStateFromEvent(event)); + } + }; + + const handleEditSave = () => { + saveMutation.mutate(editState); + }; + + const handleDeleteStart = () => { + if (isRecurring) { + setScopeStep('delete'); + } else { + handleDeleteClick(); + } + }; + + // --- Render helpers --- + + const updateField = (key: K, value: EditState[K]) => { + setEditState((s) => ({ ...s, [key]: value })); + }; + + // Empty state + if (!event && !isCreating) { + return ( +
+ +

Select an event to view details

+
+ ); + } + + // View mode data + const startDate = event ? parseISO(event.start_datetime) : null; + const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null; + const startStr = startDate + ? event!.all_day + ? format(startDate, 'EEEE, MMMM d, yyyy') + : format(startDate, 'EEEE, MMMM d, yyyy · h:mm a') + : ''; + const endStr = endDate + ? event!.all_day + ? format(endDate, 'EEEE, MMMM d, yyyy') + : format(endDate, 'h:mm a') + : null; + + const panelTitle = isCreating + ? createDefaults?.templateName + ? `New Event from ${createDefaults.templateName}` + : 'New Event' + : event?.title || ''; + + return ( +
+ {/* Header */} +
+
+ {isEditing ? ( +
+ {isCreating ? ( +

{panelTitle}

+ ) : ( + updateField('title', e.target.value)} + className="h-8 text-base font-semibold" + placeholder="Event title" + autoFocus + /> + )} +
+ ) : scopeStep ? ( +

+ {scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'} +

+ ) : ( +
+
+
+

{event?.title}

+ {event?.calendar_name} +
+
+ )} + +
+ {scopeStep ? ( + + ) : isEditing ? ( + <> + + + + ) : ( + <> + {!event?.is_virtual && ( + <> + + {confirmingDelete ? ( + + ) : ( + + )} + + )} + + + )} +
+
+
+ + {/* Body */} +
+ {scopeStep ? ( + /* Scope selection step */ +
+

+ This is a recurring event. How would you like to proceed? +

+
+ + + +
+
+ ) : isEditing ? ( + /* Edit / Create mode */ +
+ {/* Title (only shown in body for create mode; edit mode has it in header) */} + {isCreating && ( +
+ + updateField('title', e.target.value)} + placeholder="Event title" + required + autoFocus + /> +
+ )} + +
+ +