From de9a8606ab22780e47661c99d0cb2ff6afe6d5ac Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 13 May 2026 09:02:58 +0200 Subject: [PATCH] Add Google Calendar integration and improve station selection Implement full OAuth 2.0 flow for Google Calendar, including token exchange, refresh, and status checks. Add UI for connecting, syncing, and disconnecting Google accounts in the Calendar panel. Additionally: - Make mobile station selection async with error handling and alerts - Add HAFAS LocMatch method to API client for finding nearest station - Expose Google OAuth env vars in Next.js config --- apps/mobile/src/screens/SettingsScreen.tsx | 60 ++++-- apps/web/next.config.ts | 2 + .../src/app/api/auth/google/callback/route.ts | 91 ++++++++ .../app/api/auth/google/disconnect/route.ts | 8 + apps/web/src/app/api/auth/google/route.ts | 41 ++++ .../src/app/api/auth/google/status/route.ts | 13 ++ apps/web/src/app/api/calendar/google/route.ts | 129 ++++++++++++ apps/web/src/app/calendar/CalendarPanel.tsx | 38 ++-- apps/web/src/app/calendar/GoogleTab.tsx | 194 ++++++++++++++++++ packages/api-client/src/client.ts | 51 +++++ 10 files changed, 592 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/app/api/auth/google/callback/route.ts create mode 100644 apps/web/src/app/api/auth/google/disconnect/route.ts create mode 100644 apps/web/src/app/api/auth/google/route.ts create mode 100644 apps/web/src/app/api/auth/google/status/route.ts create mode 100644 apps/web/src/app/api/calendar/google/route.ts create mode 100644 apps/web/src/app/calendar/GoogleTab.tsx diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index dfa481c..3f4f9e7 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -92,12 +92,17 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { searchTimerRef.current = setTimeout(() => searchStation(text), 400); }; - const selectStation = (station: Station) => { + const selectStation = async (station: Station) => { setOrigin(station); setQuery(station.name); setResults([]); - saveOriginStation(station); - rescheduleAllNotifications(); // Recalculate when origin changes + try { + await saveOriginStation(station); + await rescheduleAllNotifications(); + Alert.alert('Station gespeichert', station.name); + } catch { + Alert.alert('Fehler', 'Station konnte nicht gespeichert werden.'); + } }; const useCurrentLocation = async () => { @@ -114,29 +119,40 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) { const userLat = loc.coords.latitude; const userLng = loc.coords.longitude; - // Find real public-transport stops near the user's GPS coordinates - // via the WienerLinien nearby-stops proxy. - const stops = await api.findNearbyStops(userLat, userLng, 2000); - if (stops.length === 0) { + // Try the WienerLinien nearby-stops proxy first + let stops: Awaited> | null = null; + try { + stops = await api.findNearbyStops(userLat, userLng, 2000); + } catch { + // API unavailable, will fall back to HAFAS LocMatch below + } + + // If nearby-stops returned results, pick the closest one + if (stops && stops.length > 0) { + const closest = stops.reduce((best, candidate) => { + const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng); + const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng); + return candDist < bestDist ? candidate : best; + }, stops[0]); + + const station: Station = { + name: closest.name, + extId: closest.id, + lat: closest.lat, + lng: closest.lng, + }; + await selectStation(station); + return; + } + + // Fallback: use HAFAS LocMatch directly (same pattern as the web app) + const nearestStation = await api.findNearestStationByCoords(userLat, userLng); + if (!nearestStation) { Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.'); return; } - // Pick the closest stop to the user's actual position - const closest = stops.reduce((best, candidate) => { - const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng); - const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng); - return candDist < bestDist ? candidate : best; - }, stops[0]); - - // Build a Station with the real stop id as extId — HAFAS can look this up. - const station: Station = { - name: closest.name, - extId: closest.id, - lat: closest.lat, - lng: closest.lng, - }; - selectStation(station); + await selectStation(nearestStation); } catch (_err) { Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.'); } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 546613f..3443bfe 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -7,6 +7,8 @@ const nextConfig: NextConfig = { env: { CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS, DEPLOYMENT_URL: process.env.DEPLOYMENT_URL, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI, }, }; diff --git a/apps/web/src/app/api/auth/google/callback/route.ts b/apps/web/src/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..7b7c890 --- /dev/null +++ b/apps/web/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,91 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +function getBaseUrl(): string { + return process.env.DEPLOYMENT_URL || "http://localhost:3000"; +} + +function calendarRedirect(params: Record) { + const qs = new URLSearchParams(params).toString(); + return NextResponse.redirect(`${getBaseUrl()}/calendar?${qs}`); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const oauthError = searchParams.get("error"); + + const cookieStore = await cookies(); + + if (oauthError) { + cookieStore.delete("google_oauth_state"); + return calendarRedirect({ google_error: oauthError }); + } + + const storedState = cookieStore.get("google_oauth_state")?.value; + cookieStore.delete("google_oauth_state"); + + if (!state || !storedState || state !== storedState) { + return calendarRedirect({ google_error: "invalid_state" }); + } + + if (!code) { + return calendarRedirect({ google_error: "missing_code" }); + } + + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return calendarRedirect({ google_error: "not_configured" }); + } + + const redirectUri = + process.env.GOOGLE_REDIRECT_URI || `${getBaseUrl()}/api/auth/google/callback`; + + let tokenData: { + access_token: string; + refresh_token?: string; + expires_in: number; + }; + + try { + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!tokenResponse.ok) { + return calendarRedirect({ google_error: "token_exchange_failed" }); + } + + tokenData = await tokenResponse.json(); + } catch { + return calendarRedirect({ google_error: "token_exchange_failed" }); + } + + cookieStore.set( + "google_tokens", + JSON.stringify({ + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token ?? null, + expires_at: Date.now() + tokenData.expires_in * 1000, + }), + { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 365, + path: "/", + }, + ); + + return calendarRedirect({ google_connected: "1" }); +} diff --git a/apps/web/src/app/api/auth/google/disconnect/route.ts b/apps/web/src/app/api/auth/google/disconnect/route.ts new file mode 100644 index 0000000..0a7fb13 --- /dev/null +++ b/apps/web/src/app/api/auth/google/disconnect/route.ts @@ -0,0 +1,8 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.delete("google_tokens"); + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/auth/google/route.ts b/apps/web/src/app/api/auth/google/route.ts new file mode 100644 index 0000000..1308df7 --- /dev/null +++ b/apps/web/src/app/api/auth/google/route.ts @@ -0,0 +1,41 @@ +import { randomBytes } from "crypto"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +function getBaseUrl(): string { + return process.env.DEPLOYMENT_URL || "http://localhost:3000"; +} + +export async function GET() { + const clientId = process.env.GOOGLE_CLIENT_ID; + if (!clientId) { + return NextResponse.json({ error: "Google Calendar is not configured" }, { status: 503 }); + } + + const redirectUri = + process.env.GOOGLE_REDIRECT_URI || `${getBaseUrl()}/api/auth/google/callback`; + + const state = randomBytes(16).toString("hex"); + const cookieStore = await cookies(); + cookieStore.set("google_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 300, + path: "/", + }); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "https://www.googleapis.com/auth/calendar.readonly", + access_type: "offline", + prompt: "consent", + state, + }); + + return NextResponse.redirect( + `https://accounts.google.com/o/oauth2/v2/auth?${params}`, + ); +} diff --git a/apps/web/src/app/api/auth/google/status/route.ts b/apps/web/src/app/api/auth/google/status/route.ts new file mode 100644 index 0000000..74c8dd9 --- /dev/null +++ b/apps/web/src/app/api/auth/google/status/route.ts @@ -0,0 +1,13 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + if (!configured) { + return NextResponse.json({ configured: false, connected: false }); + } + + const cookieStore = await cookies(); + const connected = cookieStore.has("google_tokens"); + return NextResponse.json({ configured: true, connected }); +} diff --git a/apps/web/src/app/api/calendar/google/route.ts b/apps/web/src/app/api/calendar/google/route.ts new file mode 100644 index 0000000..2a5ce79 --- /dev/null +++ b/apps/web/src/app/api/calendar/google/route.ts @@ -0,0 +1,129 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import type { CalendarEvent } from "@timetoleave/core"; +import { cleanLocation } from "@/lib/calendar-utils"; +import { DEFAULT_DAYS } from "@/lib/constants"; + +interface GoogleTokens { + access_token: string; + refresh_token: string | null; + expires_at: number; +} + +interface GoogleEventItem { + id: string; + summary?: string; + location?: string; + start?: { dateTime?: string; date?: string }; +} + +async function refreshAccessToken(tokens: GoogleTokens): Promise { + if (!tokens.refresh_token) return null; + + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + refresh_token: tokens.refresh_token, + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + }), + }); + + if (!response.ok) return null; + + const data: { access_token: string; expires_in: number } = await response.json(); + return { + access_token: data.access_token, + refresh_token: tokens.refresh_token, + expires_at: Date.now() + data.expires_in * 1000, + }; +} + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const tokensRaw = cookieStore.get("google_tokens")?.value; + + if (!tokensRaw) { + return NextResponse.json({ error: "Not authenticated with Google" }, { status: 401 }); + } + + let tokens: GoogleTokens; + try { + tokens = JSON.parse(tokensRaw) as GoogleTokens; + } catch { + cookieStore.delete("google_tokens"); + return NextResponse.json({ error: "Invalid token data" }, { status: 401 }); + } + + // Refresh if expired or within 5 minutes of expiry + if (Date.now() > tokens.expires_at - 5 * 60 * 1000) { + const refreshed = await refreshAccessToken(tokens); + if (!refreshed) { + cookieStore.delete("google_tokens"); + return NextResponse.json({ error: "Token expired — please reconnect Google Calendar" }, { status: 401 }); + } + tokens = refreshed; + cookieStore.set("google_tokens", JSON.stringify(tokens), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 365, + path: "/", + }); + } + + const { searchParams } = new URL(request.url); + const days = parseInt(searchParams.get("days") ?? String(DEFAULT_DAYS), 10); + const now = new Date(); + const timeMax = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + + const params = new URLSearchParams({ + timeMin: now.toISOString(), + timeMax: timeMax.toISOString(), + singleEvents: "true", + orderBy: "startTime", + maxResults: "100", + }); + + let gcalData: { items?: GoogleEventItem[] }; + try { + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events?${params}`, + { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + signal: AbortSignal.timeout(10_000), + }, + ); + + if (response.status === 401) { + cookieStore.delete("google_tokens"); + return NextResponse.json({ error: "Google token rejected — please reconnect" }, { status: 401 }); + } + + if (!response.ok) { + return NextResponse.json({ error: "Failed to fetch Google Calendar events" }, { status: 502 }); + } + + gcalData = await response.json(); + } catch { + return NextResponse.json({ error: "Failed to reach Google Calendar API" }, { status: 502 }); + } + + const events: CalendarEvent[] = (gcalData.items ?? []) + .filter((item) => item.location && item.start?.dateTime) + .map((item) => ({ + id: `gcal_${item.id}`, + title: item.summary ?? "Untitled", + destination: cleanLocation(item.location!), + eventTime: item.start!.dateTime!, + source: "google_calendar", + })); + + return NextResponse.json(events); +} diff --git a/apps/web/src/app/calendar/CalendarPanel.tsx b/apps/web/src/app/calendar/CalendarPanel.tsx index c0701c0..3c7c29e 100644 --- a/apps/web/src/app/calendar/CalendarPanel.tsx +++ b/apps/web/src/app/calendar/CalendarPanel.tsx @@ -3,15 +3,17 @@ import React, { useState, useEffect } from "react"; import { useCalendar } from "@/hooks/useCalendar"; import { useEventsStore } from "@/hooks/useEventsStore"; +import type { CalendarEvent } from "@timetoleave/core"; import UrlTab from "./UrlTab"; import FileTab from "./FileTab"; +import GoogleTab from "./GoogleTab"; type CalendarPanelProps = { className?: string; }; const CalendarPanel: React.FC = ({ className = "" }) => { - const [activeTab, setActiveTab] = useState<"url" | "file">("url"); + const [activeTab, setActiveTab] = useState<"url" | "file" | "google">("url"); const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar(); const { mergeEvents } = useEventsStore(); @@ -30,6 +32,17 @@ const CalendarPanel: React.FC = ({ className = "" }) => { } }; + const handleGoogleEventsLoaded = (events: CalendarEvent[]) => { + mergeEvents(events); + }; + + const tabClass = (tab: "url" | "file" | "google") => + `rounded-full px-4 py-2 text-sm font-semibold transition-colors ${ + activeTab === tab + ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" + : "text-[#F4F1EA]/58 hover:text-white" + }`; + return (
@@ -40,27 +53,26 @@ const CalendarPanel: React.FC = ({ className = "" }) => {
- {activeTab === "url" ? ( + {activeTab === "url" && ( handleLoadCalendar(url)} loading={loading} error={error} /> - ) : ( + )} + {activeTab === "file" && ( handleLoadCalendar(file)} loading={loading} error={error} /> )} + {activeTab === "google" && ( + + )}
); diff --git a/apps/web/src/app/calendar/GoogleTab.tsx b/apps/web/src/app/calendar/GoogleTab.tsx new file mode 100644 index 0000000..31ff358 --- /dev/null +++ b/apps/web/src/app/calendar/GoogleTab.tsx @@ -0,0 +1,194 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import Button from "@/app/ui/Button"; +import type { CalendarEvent } from "@timetoleave/core"; + +type GoogleStatus = "loading" | "not-configured" | "not-connected" | "connected"; + +type GoogleTabProps = { + onEventsLoaded: (events: CalendarEvent[]) => void; + className?: string; +}; + +const GoogleTab: React.FC = ({ onEventsLoaded, className = "" }) => { + const [status, setStatus] = useState("loading"); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [syncedCount, setSyncedCount] = useState(null); + + const checkStatus = useCallback(async () => { + try { + const res = await fetch("/api/auth/google/status"); + const data: { configured: boolean; connected: boolean } = await res.json(); + if (!data.configured) { + setStatus("not-configured"); + } else if (data.connected) { + setStatus("connected"); + } else { + setStatus("not-connected"); + } + } catch { + setStatus("not-connected"); + } + }, []); + + useEffect(() => { + // Handle OAuth return params + const params = new URLSearchParams(window.location.search); + const connected = params.get("google_connected"); + const oauthError = params.get("google_error"); + + if (connected || oauthError) { + const clean = new URL(window.location.href); + clean.searchParams.delete("google_connected"); + clean.searchParams.delete("google_error"); + window.history.replaceState(null, "", clean.toString()); + } + + if (oauthError) { + setError(friendlyOAuthError(oauthError)); + setStatus("not-connected"); + return; + } + + checkStatus().then(() => { + if (connected) { + syncEvents(); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const syncEvents = useCallback(async () => { + setSyncing(true); + setError(null); + try { + const res = await fetch("/api/calendar/google"); + if (res.status === 401) { + setStatus("not-connected"); + setSyncing(false); + return; + } + if (!res.ok) { + const body: { error?: string } = await res.json().catch(() => ({})); + setError(body.error ?? "Failed to fetch Google Calendar events"); + setSyncing(false); + return; + } + const events: CalendarEvent[] = await res.json(); + onEventsLoaded(events); + setSyncedCount(events.length); + } catch { + setError("Failed to reach the server"); + } finally { + setSyncing(false); + } + }, [onEventsLoaded]); + + const disconnect = async () => { + await fetch("/api/auth/google/disconnect", { method: "POST" }); + setStatus("not-connected"); + setSyncedCount(null); + setError(null); + }; + + if (status === "loading") { + return ( +
+

Checking connection…

+
+ ); + } + + if (status === "not-configured") { + return ( +
+

+ Google Calendar is not configured. Add{" "} + GOOGLE_CLIENT_ID and{" "} + GOOGLE_CLIENT_SECRET to + your environment. +

+
+ ); + } + + return ( +
+ {status === "not-connected" ? ( +
+

+ Connect your Google account to import events from Google Calendar. +

+ + + +
+ ) : ( +
+
+ +

Google Calendar connected

+
+ {syncedCount !== null && ( +

+ {syncedCount === 0 + ? "No upcoming events with a location found." + : `${syncedCount} event${syncedCount === 1 ? "" : "s"} imported.`} +

+ )} +
+ + +
+
+ )} + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +function GoogleIcon() { + return ( + + ); +} + +function friendlyOAuthError(error: string): string { + switch (error) { + case "access_denied": + return "Google Calendar access was denied."; + case "invalid_state": + return "Security check failed. Please try connecting again."; + case "token_exchange_failed": + return "Could not complete Google sign-in. Please try again."; + case "not_configured": + return "Google Calendar is not configured on this server."; + default: + return `Google sign-in failed: ${error}`; + } +} + +export default GoogleTab; diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 6e59261..a608839 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -178,4 +178,55 @@ export class ApiClient { }); return result?.svcReqL?.[0]?.res?.locL ?? []; } + + /** + * Find the nearest station to given GPS coordinates using HAFAS LocMatch. + * This is a reliable fallback that works even when the nearby-stops proxy + * is unavailable or the base URL is empty. + */ + async findNearestStationByCoords(lat: number, lng: number): Promise { + interface HafasLocation extends Station { + type: string; + lon: number; + } + + const result = await this.hafasRequest<{ + svcReqL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>; + }>({ + svcReqL: [ + { + meth: "LocMatch", + req: { + input: { + loc: { + crd: { + x: Math.round(lng * 1e6), + y: Math.round(lat * 1e6), + }, + type: "S", + }, + maxLoc: 5, + field: "S", + }, + }, + }, + ], + }); + + const stations = result?.svcReqL?.[0]?.res?.match?.locL ?? []; + if (stations.length === 0) return null; + + // Filter to only "S" (station) type results, then pick the closest + const stationResults = stations.filter((s) => s.type === "S"); + if (stationResults.length === 0) return null; + + // Find the closest station by Euclidean distance + const closest = stationResults.reduce((best, candidate) => { + const bestDist = Math.hypot((best.lat ?? lat) - lat, (best.lng ?? lng) - lng); + const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng); + return candDist < bestDist ? candidate : best; + }, stationResults[0]); + + return closest; + } }