Conversation
Implements full Google Calendar integration: - OAuth 2.0 authorization flow (connect/disconnect Google account) - Background sync fetches upcoming 30 days of events on page load with a 5-minute cooldown to avoid excessive API calls - Dedicated /api/sync/calendar endpoint for external cron triggering - Google events appear in the agenda with a distinct "Google" badge and are read-only (no delete button) - New Prisma models: GoogleAccount for token storage, Event model extended with source/googleEventId/endDate fields Requires GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI environment variables to be configured. Resolves IRL-3
| const existingGoogleEvents = await prisma.event.findMany({ | ||
| where: { source: "google" }, | ||
| select: { id: true, googleEventId: true }, | ||
| }); | ||
|
|
||
| for (const existing of existingGoogleEvents) { | ||
| if (existing.googleEventId && !googleEventIds.has(existing.googleEventId)) { | ||
| await prisma.event.delete({ where: { id: existing.id } }); | ||
| totalRemoved++; | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 Cross-account Google event deletion during sync loop
In syncCalendarEvents, the cleanup phase at line 132 queries ALL events with source: "google" but compares them against only the current account's googleEventIds. When multiple Google accounts are connected, processing account A will delete events that were synced from account B (because those event IDs aren't in account A's calendar). Then processing account B re-creates its events but deletes account A's. The net result is that only events from the last account processed survive each sync cycle.
Illustration of the bug with two accounts
- Loop iteration for Account A: upserts A's events, then queries all
source: "google"events and deletes any not in A'sgoogleEventIds→ deletes B's events. - Loop iteration for Account B: upserts B's events, then queries all
source: "google"events and deletes any not in B'sgoogleEventIds→ deletes A's events.
The fix should either scope the cleanup query to the current account's events, or collect all googleEventIds across all accounts before performing deletion.
Prompt for agents
In syncCalendarEvents() in src/lib/google-calendar.ts, the event cleanup logic at lines 132-142 runs inside the per-account loop but queries ALL google-sourced events (where: { source: "google" }). It then deletes any event whose googleEventId is not in the current account's googleEventIds set. This means processing one account deletes events from other accounts.
There are two possible approaches to fix:
1. Accumulate all googleEventIds from ALL accounts first (before the loop), then do a single cleanup pass after the loop using the combined set.
2. Add a foreign key or field linking events to their source GoogleAccount, so the cleanup query can be scoped to the current account (where: { source: "google", googleAccountId: account.id }).
Approach 1 is simpler and doesn't require a schema change. Move the cleanup logic (lines 131-142) outside the for loop and build a combined set of all google event IDs from all accounts.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Delete all Google events from this account | ||
| // Since we only support one calendar per account right now, | ||
| // remove all Google-sourced events when disconnecting | ||
| await prisma.event.deleteMany({ where: { source: "google" } }); |
There was a problem hiding this comment.
🔴 disconnectGoogleAccount deletes Google events from ALL accounts, not just the disconnected one
At src/lib/google-calendar.ts:187, disconnectGoogleAccount runs prisma.event.deleteMany({ where: { source: "google" } }) which deletes all Google-sourced events regardless of which account they came from. If two Google accounts are connected and a user disconnects one, all synced events from both accounts are deleted. The comment acknowledges this limitation but the UI (EventsWidget.tsx:142-157) renders per-account disconnect buttons, implying per-account granularity that doesn't exist.
Prompt for agents
In disconnectGoogleAccount (src/lib/google-calendar.ts:183-188), the deleteMany call uses { where: { source: "google" } } which deletes ALL google-sourced events, not just those belonging to the disconnected account. Since there's no foreign key from Event to GoogleAccount, there's no way to scope this correctly with the current schema.
To fix this properly, add a googleAccountId field to the Event model (with a relation to GoogleAccount), populate it during sync (in syncCalendarEvents), and then use it in the disconnect delete query: prisma.event.deleteMany({ where: { source: "google", googleAccountId: id } }). This also fixes the related cross-account deletion bug in syncCalendarEvents.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Pull request overview
Adds Google Calendar read-only sync into the dashboard by introducing a Google OAuth flow, storing tokens in Prisma, lazily syncing upcoming events into the Event table, and exposing UI/endpoint hooks to connect/disconnect and trigger syncs.
Changes:
- Add Google OAuth endpoints (
/api/auth/google+ callback) and token storage via newGoogleAccountmodel. - Add Google Calendar sync logic (lazy sync on
getEvents()+ manual/api/sync/calendarendpoint) and extendEventschema for Google-sourced events. - Update Calendar/Agenda UI to show Google connection status and mark Google events as read-only with a “Google” badge.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
src/proxy.ts |
Allows Google auth routes through the session gate. |
src/lib/google-auth.ts |
Builds OAuth2 client + authorization URL for Google. |
src/app/api/auth/google/route.ts |
Redirects to Google consent screen. |
src/app/api/auth/google/callback/route.ts |
Exchanges code for tokens and upserts GoogleAccount. |
src/lib/google-calendar.ts |
Implements token refresh, event fetch, upsert/delete sync, and disconnect. |
src/app/api/sync/calendar/route.ts |
Adds a sync trigger endpoint returning counts. |
src/app/actions/events.ts |
Triggers lazy sync before listing events; adds calendar status + disconnect action. |
src/app/page.tsx |
Fetches/passes Google account status into the Events widget. |
src/components/EventsWidget.tsx |
Shows connect/disconnect UI; labels Google events; disables delete for Google events. |
src/components/EventsWidget.module.css |
Styles for connection status and Google badge. |
prisma/schema.prisma |
Adds GoogleAccount; extends Event with source/googleEventId/endDate. |
prisma/migrations/.../migration.sql |
Creates the new table and migrates Event. |
package.json / package-lock.json |
Adds google-auth-library. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Get the user's email from the token info | ||
| client.setCredentials(tokens); | ||
| const tokenInfo = await client.getTokenInfo(tokens.access_token); | ||
| const email = tokenInfo.email || "unknown"; | ||
|
|
There was a problem hiding this comment.
OAuth callback is attempting to derive the user email via client.getTokenInfo(tokens.access_token) and tokenInfo.email, but the requested scopes are calendar-only (no openid/email/userinfo.email). In that case token info typically won’t contain an email, causing email to become "unknown" and breaking the GoogleAccount.email @unique upsert (and potentially collapsing multiple users into one row). Request an email-capable scope and/or use an ID token / userinfo endpoint to reliably identify the account; avoid defaulting to a constant like "unknown".
| // Remove Google events that are no longer in the API response | ||
| const existingGoogleEvents = await prisma.event.findMany({ | ||
| where: { source: "google" }, | ||
| select: { id: true, googleEventId: true }, | ||
| }); |
There was a problem hiding this comment.
existingGoogleEvents is fetched with where: { source: "google" } (no account scoping). When multiple GoogleAccount rows exist, syncing one account can delete events from other accounts. To support multiple accounts, associate events to an account (e.g. googleAccountId) and scope the query/deletes accordingly; otherwise explicitly enforce a single connected account.
| // Delete all Google events from this account | ||
| // Since we only support one calendar per account right now, | ||
| // remove all Google-sourced events when disconnecting |
There was a problem hiding this comment.
disconnectGoogleAccount() deletes all events with source: "google", not just events for the disconnected account. This will remove other accounts’ events if multiple accounts are connected. Either scope deletions to the account (requires storing an account reference on Event) or enforce a single Google account connection in the data model/UI.
| // Delete all Google events from this account | |
| // Since we only support one calendar per account right now, | |
| // remove all Google-sourced events when disconnecting | |
| const accounts = await prisma.googleAccount.findMany({ | |
| select: { id: true }, | |
| }); | |
| const accountExists = accounts.some((account) => account.id === id); | |
| if (!accountExists) { | |
| throw new Error("Google account not found"); | |
| } | |
| // This implementation only safely supports deleting all Google events | |
| // when there is a single connected Google account. Without storing an | |
| // account reference on Event, deleting by source would remove events | |
| // belonging to other connected accounts. | |
| if (accounts.length > 1) { | |
| throw new Error( | |
| "Disconnecting one of multiple Google accounts is not supported without account-scoped event ownership" | |
| ); | |
| } |
| @@ -0,0 +1,26 @@ | |||
| import { OAuth2Client } from "google-auth-library"; | |||
|
|
|||
| const SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]; | |||
There was a problem hiding this comment.
SCOPES only requests Calendar access, but the callback logic needs a stable user identifier (email) to upsert GoogleAccount. Add identity scopes (e.g. openid, email, and/or https://www.googleapis.com/auth/userinfo.email) and update the callback to use that identity data, otherwise account identification will be unreliable.
| const SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]; | |
| const SCOPES = [ | |
| "openid", | |
| "email", | |
| "https://www.googleapis.com/auth/userinfo.email", | |
| "https://www.googleapis.com/auth/calendar.readonly", | |
| ]; |
| return client.generateAuthUrl({ | ||
| access_type: "offline", | ||
| scope: SCOPES, | ||
| prompt: "consent", // always show consent to get refresh token | ||
| }); |
There was a problem hiding this comment.
The OAuth authorization URL is generated without a state parameter and there’s no corresponding validation in the callback, which enables CSRF/login-confusion attacks. Generate a random state, persist it (e.g. HttpOnly cookie), and validate it in the callback before exchanging the code.
| timeMax: future.toISOString(), | ||
| singleEvents: "true", | ||
| orderBy: "startTime", | ||
| maxResults: "100", | ||
| }); |
There was a problem hiding this comment.
Event fetch is limited to maxResults: "100" and does not paginate via nextPageToken. Calendars with >100 upcoming events will be partially synced, and any “delete missing” logic may remove valid events. Implement pagination (loop until no nextPageToken) or avoid deletion-by-diff unless you can guarantee a complete listing.
| if (dt.dateTime) return new Date(dt.dateTime); | ||
| if (dt.date) return new Date(`${dt.date}T12:00:00.000Z`); | ||
| return new Date(); |
There was a problem hiding this comment.
parseGoogleDate() falls back to new Date() when neither dateTime nor date is present. That will create events at “now” rather than failing fast, which can lead to confusing/incorrect agenda entries. Prefer throwing/skipping the event if the start/end is missing or invalid, and log enough context to debug the upstream payload.
| if (dt.dateTime) return new Date(dt.dateTime); | |
| if (dt.date) return new Date(`${dt.date}T12:00:00.000Z`); | |
| return new Date(); | |
| let parsed: Date | null = null; | |
| if (dt.dateTime) { | |
| parsed = new Date(dt.dateTime); | |
| } else if (dt.date) { | |
| parsed = new Date(`${dt.date}T12:00:00.000Z`); | |
| } else { | |
| console.error("Google Calendar event is missing both dateTime and date", { | |
| dt, | |
| }); | |
| throw new Error("Google Calendar event is missing a start/end date"); | |
| } | |
| if (Number.isNaN(parsed.getTime())) { | |
| console.error("Google Calendar event has an invalid date value", { | |
| dt, | |
| }); | |
| throw new Error("Google Calendar event contains an invalid start/end date"); | |
| } | |
| return parsed; |
| import { NextResponse } from "next/server"; | ||
| import { syncCalendarEvents } from "@/lib/google-calendar"; | ||
|
|
||
| export async function GET() { |
There was a problem hiding this comment.
GET /api/sync/calendar has no authentication/authorization checks, so any caller who can reach it can trigger external API calls and DB writes/deletes. Add an auth gate (e.g., validate the existing session cookie, or require a shared secret header/query param for cron).
| export async function GET() { | |
| export async function GET(request: Request) { | |
| const configuredSecret = process.env.CALENDAR_SYNC_SECRET; | |
| if (!configuredSecret) { | |
| console.error("Calendar sync endpoint misconfigured: missing CALENDAR_SYNC_SECRET"); | |
| return NextResponse.json( | |
| { error: "Sync endpoint is not configured" }, | |
| { status: 500 } | |
| ); | |
| } | |
| const authorizationHeader = request.headers.get("authorization"); | |
| const bearerToken = authorizationHeader?.startsWith("Bearer ") | |
| ? authorizationHeader.slice("Bearer ".length) | |
| : null; | |
| const headerSecret = request.headers.get("x-sync-secret"); | |
| const querySecret = new URL(request.url).searchParams.get("secret"); | |
| const providedSecret = bearerToken ?? headerSecret ?? querySecret; | |
| if (providedSecret !== configuredSecret) { | |
| return NextResponse.json( | |
| { error: "Unauthorized" }, | |
| { status: 401 } | |
| ); | |
| } |
| pathname.startsWith("/favicon") || | ||
| pathname.startsWith("/api/auth/google") |
There was a problem hiding this comment.
pathname.startsWith("/api/auth/google") bypasses the session check for both the OAuth initiation and callback routes. If this middleware is active, it allows unauthenticated visitors to complete OAuth and persist tokens in GoogleAccount. Consider requiring an authenticated session for the initiation route (and ideally the callback too, validated via OAuth state tied to the session), or narrowing the allowlist to only what must be public.
| pathname.startsWith("/favicon") || | |
| pathname.startsWith("/api/auth/google") | |
| pathname.startsWith("/favicon") |
|
@copilot resolve the merge conflicts in this pull request |
…flicts) Co-authored-by: alecvdp <182568458+alecvdp@users.noreply.github.com>
Resolved in 39e7315. The merge conflicts in Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
| pathname.startsWith("/_next/") || | ||
| pathname.startsWith("/favicon") | ||
| pathname.startsWith("/favicon") || | ||
| pathname.startsWith("/api/auth/google") |
There was a problem hiding this comment.
🔴 Unauthenticated access to Google OAuth callback allows unauthorized account linking
The proxy at src/proxy.ts:17 exempts all paths starting with /api/auth/google from authentication. This covers both /api/auth/google (initiate OAuth) and /api/auth/google/callback (complete OAuth and store tokens). An unauthenticated attacker who can reach the app can complete the OAuth flow and insert a GoogleAccount record into the database, causing their Google Calendar events to appear on the dashboard. The /api/sync/calendar endpoint is correctly protected behind authentication, but the OAuth endpoints are not.
The callback endpoint at src/app/api/auth/google/callback/route.ts writes directly to the database via prisma.googleAccount.upsert, so bypassing auth here has real impact.
Prompt for agents
The proxy in src/proxy.ts allows unauthenticated access to all paths starting with /api/auth/google. While the initial OAuth redirect (/api/auth/google) could arguably be public, the callback (/api/auth/google/callback) writes tokens to the database and should require authentication.
Option 1: Only exempt the exact path /api/auth/google (not the callback) from the proxy. The callback URL will then require the session cookie. This works because the user initiates OAuth from the authenticated dashboard, and the callback redirect preserves their cookies.
Option 2: Add a CSRF state parameter to the OAuth flow. In the initiate endpoint, generate a random state value stored in a cookie, pass it to Google, then verify it in the callback before accepting the tokens. This is the standard OAuth security practice.
Was this helpful? React with 👍 or 👎 to provide feedback.
Assignee: @alecvdp (alecvdpoel)
Summary
/api/auth/google. Tokens (access + refresh) are stored in a newGoogleAccounttable and auto-refreshed on expiry.Eventtable. A dedicated/api/sync/calendarendpoint is also available for external cron triggering.Eventmodel extended withsource,googleEventId(unique), andendDatefields. NewGoogleAccountmodel for OAuth token storage.New environment variables required
GOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETGOOGLE_REDIRECT_URIhttps://your-domain/api/auth/google/callback)Resolves IRL-3
Test plan
/api/sync/calendardirectly → returns sync countnpm run buildnpm test(31/31 passing)