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; + } }