From 80f3b346fdf0e7e1767ca0b3b18d64790ad62f32 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Tue, 5 May 2026 16:59:17 -0500 Subject: [PATCH 1/5] Refine sync lifecycle and simplify related UI --- CONTEXT.md | 12 ++ docs/README.md | 10 +- docs/acceptance/auth.md | 12 +- docs/acceptance/google-sync.md | 10 +- docs/development/feature-file-map.md | 6 +- docs/development/troubleshoot.md | 2 +- docs/features/password-auth-flow.md | 54 +++++- docs/frontend/frontend-runtime-flow.md | 24 +-- docs/self-hosting/google-calendar.md | 13 +- .../clients/google.oauth.client.test.ts | 30 +++- .../google/clients/google.oauth.client.ts | 10 +- .../util/google.redirect-uri.util.test.ts | 31 ++++ .../google/util/google.redirect-uri.util.ts | 24 +++ .../src/common/errors/auth/auth.errors.ts | 6 + .../src/auth/google/hooks/googe.auth.types.ts | 2 +- .../useConnectGoogle/useConnectGoogle.ts | 64 ++----- .../hooks/useGoogleAuth/useGoogleAuth.ts | 100 ----------- .../useGoogleAuthWithOverlay.test.ts | 134 --------------- .../useGoogleAuthWithOverlay.ts | 49 ------ .../hooks/useGoogleLogin/useGoogleLogin.ts | 110 +++--------- .../google-auth-redirect.constants.ts | 12 ++ .../google-auth-redirect.storage.test.ts | 45 +++++ .../redirect/google-auth-redirect.storage.ts | 57 +++++++ .../google-auth-redirect.util.test.ts | 55 ++++++ .../redirect/google-auth-redirect.util.ts | 48 ++++++ .../util/google.oauth.error.util.test.ts | 18 -- .../google/util/google.oauth.error.util.ts | 33 ---- packages/web/src/common/apis/auth.api.ts | 5 +- packages/web/src/common/constants/routes.ts | 1 + .../event/local.event.repository.test.ts | 49 ++++++ .../event/local.event.repository.ts | 13 +- .../storage/adapter/indexeddb.adapter.ts | 12 +- .../common/storage/adapter/storage.adapter.ts | 10 +- .../external/demo-data-seed.test.ts | 8 + .../migrations/external/demo-data-seed.ts | 9 +- .../storage/types/local-event.types.test.ts | 52 ++++++ .../common/storage/types/local-event.types.ts | 40 +++++ .../utils/sync/local-event-sync.util.test.ts | 69 ++++++++ .../utils/sync/local-event-sync.util.ts | 15 +- .../components/AuthModal/AuthModal.test.tsx | 6 +- .../src/components/AuthModal/AuthModal.tsx | 15 +- .../AuthenticatedLayout.test.tsx | 4 - .../AuthenticatedLayout.tsx | 8 +- .../SyncEventsOverlay.test.tsx | 160 ------------------ .../SyncEventsOverlay/SyncEventsOverlay.tsx | 45 ----- packages/web/src/routers/index.tsx | 9 + .../GoogleAuthCallback/GoogleAuthCallback.tsx | 142 ++++++++++++++++ .../web/src/views/GoogleAuthCallback/index.ts | 1 + 48 files changed, 883 insertions(+), 761 deletions(-) create mode 100644 packages/backend/src/auth/services/google/util/google.redirect-uri.util.test.ts create mode 100644 packages/backend/src/auth/services/google/util/google.redirect-uri.util.ts delete mode 100644 packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts delete mode 100644 packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts delete mode 100644 packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts create mode 100644 packages/web/src/auth/google/redirect/google-auth-redirect.constants.ts create mode 100644 packages/web/src/auth/google/redirect/google-auth-redirect.storage.test.ts create mode 100644 packages/web/src/auth/google/redirect/google-auth-redirect.storage.ts create mode 100644 packages/web/src/auth/google/redirect/google-auth-redirect.util.test.ts create mode 100644 packages/web/src/auth/google/redirect/google-auth-redirect.util.ts delete mode 100644 packages/web/src/auth/google/util/google.oauth.error.util.test.ts delete mode 100644 packages/web/src/auth/google/util/google.oauth.error.util.ts create mode 100644 packages/web/src/common/repositories/event/local.event.repository.test.ts create mode 100644 packages/web/src/common/storage/types/local-event.types.test.ts create mode 100644 packages/web/src/common/storage/types/local-event.types.ts create mode 100644 packages/web/src/common/utils/sync/local-event-sync.util.test.ts delete mode 100644 packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx delete mode 100644 packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx create mode 100644 packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx create mode 100644 packages/web/src/views/GoogleAuthCallback/index.ts diff --git a/CONTEXT.md b/CONTEXT.md index 3463501bf..70d86ed34 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,6 +43,16 @@ _Avoid_: non-Google user **Google-connected user**: An authenticated user with usable Google credentials stored by the backend. +**Google authorization**: +The Google approval step that lets Compass sign a user in with Google or connect +Google Calendar to an existing Compass session. +_Avoid_: Google login mode + +**Google authorization intent**: +The user's Compass purpose for a Google authorization: Google sign-in/up or +Google Calendar connect/reconnect. +_Avoid_: auth mode + **Google revoked**: The state where Google access is no longer usable and Google-origin data should be pruned, ignored, or reconnected. @@ -164,6 +174,8 @@ during Import or Public watch notification handling. **Events** without becoming a **Google-connected user**. - A **Google-connected user** can import from Google and mirror eligible Compass event changes to Google. +- A **Google authorization** must preserve its **Google authorization intent** + instead of inferring the user's goal from the later session state. - **Public watch notifications** are separate from browser API and **SSE** traffic; browser traffic can be local, but Google webhook posts need public HTTPS when continuous sync is expected. diff --git a/docs/README.md b/docs/README.md index f70d95686..d4ceff169 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,10 @@ Internal documentation for engineers and agents working in the Compass repo. Start with [AGENTS.md](../AGENTS.md) for repo rules and command defaults. Use this index for codebase shape, subsystem behavior, and acceptance runbooks. +## How docs get published + +Markdown files in this `docs/` directory are automatically mirrored to [docs.compasscalendar.com](https://docs.compasscalendar.com). A GitHub Action detects any push to `main` that touches `docs/**` and syncs the changes to the doc site. Just edit any of the markdown in `docs/` and the doc site will update itself upon merge. + ## Start Here - [Repo Architecture](./architecture/repo-architecture.md) @@ -56,9 +60,3 @@ User-visible behavior runbooks for manual verification and expected outcomes: - [Recurring Events](./acceptance/recurring-events.md) - [Shortcuts](./acceptance/shortcuts.md) - [Tasks](./acceptance/tasks.md) - -## How docs get published - -Markdown files in this `docs/` directory are automatically mirrored to [docs.compasscalendar.com](https://docs.compasscalendar.com). A GitHub Action detects any push to `main` that touches `docs/**` and syncs the changes to the doc site. Just edit any of the markdown in `docs/` and the doc site will update itself upon merge. - - diff --git a/docs/acceptance/auth.md b/docs/acceptance/auth.md index 14777d590..48327b1d0 100644 --- a/docs/acceptance/auth.md +++ b/docs/acceptance/auth.md @@ -132,21 +132,21 @@ The forgot-password flow should avoid leaking whether an email exists. The reset ### UX -The auth modal should still allow Google sign in from a logged-out state. A successful Google flow should authenticate the user. Closing the popup should behave like cancellation, not like a hard auth failure. +The auth modal should still allow Google sign in from a logged-out state. A successful Google redirect flow should authenticate the user and return to Compass. ### Steps 1. Open the auth modal from `/day?auth=login`. 2. Select `Continue with Google`. -3. Complete Google OAuth successfully. +3. Complete the Google authorization redirect with the intended Google account. 4. Log out. -5. Start Google sign in again, but close the popup before finishing. +5. Start Google sign in again, but cancel at Google before finishing. ### Expected Results -- A successful Google sign-in authenticates the user and returns them to the app. -- Closing the popup clears the loading state. -- Popup cancellation does not leave the app stuck in an auth error state. +- The Google authorization redirect returns to Compass through `/auth/google/callback`. +- A successful Google sign-in authenticates the user and returns them to the saved app path. +- A canceled Google redirect returns to Compass and shows a recoverable auth error. ## Scenario 6: Password-Only Compass Usage Before Google Connect diff --git a/docs/acceptance/google-sync.md b/docs/acceptance/google-sync.md index 626592880..d3a63f173 100644 --- a/docs/acceptance/google-sync.md +++ b/docs/acceptance/google-sync.md @@ -49,13 +49,13 @@ A password-authenticated user can connect Google Calendar from inside the app us 1. Sign up or log in with email/password. Do not connect Google. 2. Create at least one Compass event so there is pre-existing data. 3. Open the command palette (Cmd+K) and select Connect Google Calendar, or click the Google status icon in the sidebar. -4. Complete the Google OAuth popup with the intended Google account. +4. Complete the Google authorization redirect with the intended Google account. 5. Return to Compass and observe the sidebar status icon. 6. Reload the page. ### Expected Results -- The OAuth popup opens and closes cleanly without redirecting away from the app shell. +- The Google authorization redirect returns to Compass through `/auth/google/callback`. - The sidebar status transitions away from NOT_CONNECTED into an importing state. - Pre-existing Compass events remain visible on the calendar. - The network flow uses `POST /api/auth/google/connect`, not the logged-out sign-in path. @@ -72,7 +72,7 @@ After connecting Google, Compass imports all events from the user's Google calen ### Steps 1. Connect Google Calendar (see Scenario 1), or start with an account that has `importGCal` flagged for restart. -2. Observe the header immediately after the OAuth popup closes. +2. Observe the header immediately after the Google authorization redirect returns. 3. Continue using the app normally while the import runs (navigate to different dates, create a Compass event). 4. Wait for the header spinner to disappear. 5. Check the calendar for newly imported Google events. @@ -207,12 +207,12 @@ After revocation, the user can reconnect Google using the same flow as the initi 1. Complete Scenario 7 so the connection is in the NOT_CONNECTED state. 2. Open the command palette and select Connect Google Calendar. -3. Complete the Google OAuth popup. +3. Complete the Google authorization redirect. 4. Wait for the import to complete. ### Expected Results -- The OAuth popup opens and closes without error. +- The Google authorization redirect returns to Compass without error. - The import spinner appears in the header. - Google events repopulate the calendar after import completes. - The sidebar status returns to HEALTHY. diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index d93ca8dc9..86b20fead 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -16,9 +16,9 @@ Use this document to find the first files to inspect for common Compass changes. - Session initialization and SuperTokens wiring: `packages/web/src/auth/session/SessionProvider.tsx` - User profile bootstrap: `packages/web/src/auth/context/UserProvider.tsx` -- Google OAuth app flow: `packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.ts`, `packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts` -- Google OAuth provider wrapper: `packages/web/src/auth/hooks/google/useGoogleLogin/useGoogleLogin.ts` -- Popup-cancel classification for Google OAuth: `packages/web/src/auth/google/google-oauth-error.util.ts` +- Google OAuth app flow: `packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts` +- Google redirect callback: `packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx` +- Google redirect intent storage: `packages/web/src/auth/google/redirect` - Auth schemas: `packages/web/src/auth/schemas/auth.schemas.ts` - Backend auth routes: `packages/backend/src/auth/auth.routes.config.ts` - Backend auth controllers/services: `packages/backend/src/auth/controllers`, `packages/backend/src/auth/services` diff --git a/docs/development/troubleshoot.md b/docs/development/troubleshoot.md index d3a01178c..42dc0a1d5 100644 --- a/docs/development/troubleshoot.md +++ b/docs/development/troubleshoot.md @@ -132,7 +132,7 @@ If that pre-connect local sync fails, connect is intentionally aborted and the u What this means operationally: -- this is a local-to-cloud event migration failure, not an OAuth popup failure +- this is a local-to-cloud event migration failure, not a Google redirect failure - backend `connectGoogleToCurrentUser` is not called for that attempt - no Google import restart should be observed from that click diff --git a/docs/features/password-auth-flow.md b/docs/features/password-auth-flow.md index eb4dc03fc..52c975f6c 100644 --- a/docs/features/password-auth-flow.md +++ b/docs/features/password-auth-flow.md @@ -77,6 +77,33 @@ Design intent: - logged-in Google attach is an authenticated Compass backend flow - logout is decoupled from Google state and succeeds even when no Google account is linked +- Google authorization uses a full-page redirect. Both Google sign-in/up and + Google Calendar connect/reconnect return to `/auth/google/callback`. +- Before leaving for Google, the web app stores a short-lived Google + authorization intent and same-origin return path in `sessionStorage`, keyed by + the OAuth `state` value. The callback validates that state, finishes the saved + intent, removes it, and then returns the user to the page that started the + flow. If the saved return path is missing or unsafe, the callback falls back + to `/day`. +- The backend accepts only the configured Compass Google callback URL as the + OAuth redirect URI when exchanging a Google code. The callback URL is derived + from backend `FRONTEND_URL` plus `/auth/google/callback`. +- The callback page is intentionally transitional: it shows a simple completion + status, finishes or fails the saved Google authorization intent, and navigates + back into the app. +- Google authorization no longer uses a blocking overlay. The callback page is + the only Google authorization loading surface. +- When a user first signs up or signs in, Compass should sync local events the + user created themselves, but should not sync seeded demo events such as + "Morning standup" or "Try Compass" into the new account. +- Seeded demo events should be marked only in browser IndexedDB. The marker is + used to skip demo events during local-event sync and must not be sent to the + backend or stored as account event data. +- Editing a seeded demo event does not make it a user-created event for sync + purposes; it should still be skipped. +- Logged-out Google sign-in keeps the shared post-auth completion behavior for + now: after the session is created, Compass syncs local events to the account + and warns if those events remain device-local. ## Web Entry Points @@ -262,19 +289,31 @@ does not return a new one. When a logged-in password user chooses `Connect Google Calendar`: -1. the web client completes the Google popup flow -2. `useConnectGoogle()` sends the auth-code payload to +1. the web client syncs pending local events to the server +2. if local-event sync succeeds, the web client redirects through Google and + returns to + `/auth/google/callback` +3. the callback sends the auth-code payload to `POST /api/auth/google/connect` -3. `connectGoogleToCurrentUser()` exchanges the code for Google tokens -4. backend verifies the Google account is not already owned by a different +4. `connectGoogleToCurrentUser()` exchanges the code for Google tokens +5. backend verifies the Google account is not already owned by a different Compass user -5. backend persists Google credentials onto the current Compass user -6. backend marks metadata sync flags as `"RESTART"` and restarts sync in the +6. backend persists Google credentials onto the current Compass user +7. backend marks metadata sync flags as `"RESTART"` and restarts sync in the background This path does not call SuperTokens `signInUpPOST` and does not depend on SuperTokens account linking. +Redirect implementation should include focused tests for: + +- matching OAuth `state` before completing the callback +- routing Google sign-in/up and Google Calendar connect/reconnect to the correct + backend endpoint +- rejecting unsafe return paths and falling back to `/day` +- using the configured `/auth/google/callback` URL when exchanging Google codes +- syncing user-created local events while skipping demo-marked local events + ### Google connect conflict contract If a logged-in user attempts to connect a Google account that is already linked @@ -403,3 +442,6 @@ session-linking failure mode. - A Google account can belong to only one Compass user. In-session connect returns a conflict if the Google account is already attached elsewhere. - Dated-route redirects preserve existing query params (including `auth=verify`), but `useAuthUrlParam()` only handles `login`, `signup`, `forgot`, and `reset`. +- Future UX question: first-time Google sign-in may need a choice before syncing + anonymous local events into the account, especially when those events are demo + or placeholder data. diff --git a/docs/frontend/frontend-runtime-flow.md b/docs/frontend/frontend-runtime-flow.md index a3edc94c5..36f9088c4 100644 --- a/docs/frontend/frontend-runtime-flow.md +++ b/docs/frontend/frontend-runtime-flow.md @@ -73,29 +73,13 @@ Once a user has ever authenticated, the app records that fact in local auth-stat When a user re-authenticates with Google, auth-state utilities also clear any in-memory "Google revoked" flag so normal remote sync can resume. -## Google OAuth Popup Cancellation Semantics +## Google Authorization Redirect -Files: - -- `packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.ts` -- `packages/web/src/auth/hooks/google/useGoogleLogin/useGoogleLogin.ts` -- `packages/web/src/auth/google/google-oauth-error.util.ts` - -The web auth flow intentionally treats popup-close outcomes as cancellation, not authentication failure. - -Cancellation detection (`isGooglePopupClosedError`) returns true when any of these match: - -- `type === "popup_closed"` -- `error`, `error_description`, or `message` equals `"popup_closed"` (case-insensitive) -- `error`, `error_description`, or `message` contains `"popup window closed"` (case-insensitive) - -When cancellation is detected in the auth hooks: +Google sign-in/up and Google Calendar connect/reconnect leave Compass through a full-page Google redirect and return through `/auth/google/callback`. -- auth state is reset (`resetAuth`) -- OAuth overlay closes because `selectIsAuthenticating` becomes false -- generic auth failure state is not dispatched for that event +Before redirecting, the web app stores a short-lived authorization intent in `sessionStorage` keyed by OAuth `state`. The callback validates that state, finishes the saved intent, removes it, and returns the user to the original same-origin path or `/day`. -For non-cancellation errors, normal auth-failure handling still applies. +The old blocking overlay is not used for Google authorization. ## User Bootstrap diff --git a/docs/self-hosting/google-calendar.md b/docs/self-hosting/google-calendar.md index 8b370c205..657664267 100644 --- a/docs/self-hosting/google-calendar.md +++ b/docs/self-hosting/google-calendar.md @@ -43,10 +43,11 @@ Authorized JavaScript origins: http://localhost:9080 Authorized redirect URIs: - http://localhost:9080 + http://localhost:9080/auth/google/callback ``` -Compass sends the browser origin as the OAuth redirect URI. That means the redirect URI is the app origin itself, not a longer callback path. +Compass sends the dedicated Google callback page as the OAuth redirect URI. +That means the redirect URI includes `/auth/google/callback`. This path doesn't make your local backend public. It's for sign-in and one-time import only. @@ -74,17 +75,19 @@ Local setups do not create a public HTTPS URL, so they can't receive these. You For a public server install, create a Google OAuth client with **Web application** as the client type. -Use your public Compass origin for both OAuth fields: +Use your public Compass origin for JavaScript origins and the Compass callback +page for redirect URIs: ```text Authorized JavaScript origins: https://cal.example.com Authorized redirect URIs: - https://cal.example.com + https://cal.example.com/auth/google/callback ``` -Replace `https://cal.example.com` with your own Compass URL. Do not add `/api`, `/auth/callback`, or another path to the redirect URI. +Replace `https://cal.example.com` with your own Compass URL. Do not add `/api` +to the redirect URI. Also check these in Google Cloud: diff --git a/packages/backend/src/auth/services/google/clients/google.oauth.client.test.ts b/packages/backend/src/auth/services/google/clients/google.oauth.client.test.ts index a5f1c1cf1..aa7b9fbbe 100644 --- a/packages/backend/src/auth/services/google/clients/google.oauth.client.test.ts +++ b/packages/backend/src/auth/services/google/clients/google.oauth.client.test.ts @@ -1,5 +1,6 @@ import { faker } from "@faker-js/faker"; import { calendar } from "@googleapis/calendar"; +import { OAuth2Client } from "google-auth-library"; import { SELF_HOST_GOOGLE_CLIENT_ID_PLACEHOLDER, SELF_HOST_GOOGLE_CLIENT_SECRET_PLACEHOLDER, @@ -56,6 +57,11 @@ describe("GoogleOAuthClient", () => { const client = new GoogleOAuthClient(); + expect(OAuth2Client).toHaveBeenCalledWith( + ENV.GOOGLE_CLIENT_ID, + ENV.GOOGLE_CLIENT_SECRET, + "http://localhost:9080/auth/google/callback", + ); expect(client.getGcalClient()).toBe(gcalClient); expect(mockCalendar).toHaveBeenCalledWith({ version: "v3", @@ -136,7 +142,8 @@ describe("GoogleOAuthClient", () => { clientType: "web", thirdPartyId: "google", redirectURIInfo: { - redirectURIOnProviderDashboard: "http://localhost:9080", + redirectURIOnProviderDashboard: + "http://localhost:9080/auth/google/callback", redirectURIQueryParams: { code: "auth-code" }, }, }), @@ -152,6 +159,27 @@ describe("GoogleOAuthClient", () => { expect(mockOAuthClient.setCredentials).toHaveBeenCalledWith(tokens); }); + it("rejects auth code exchange from an unexpected redirect URI", async () => { + const client = new GoogleOAuthClient(); + const mockOAuthClient = getMockOAuthClient(client); + + await expect( + client.exchangeAuthCode({ + clientType: "web", + thirdPartyId: "google", + redirectURIInfo: { + redirectURIOnProviderDashboard: + "https://evil.example/auth/google/callback", + redirectURIQueryParams: { code: "auth-code" }, + }, + }), + ).rejects.toMatchObject({ + description: AuthError.GoogleRedirectUriMismatch.description, + }); + + expect(mockOAuthClient.getToken).not.toHaveBeenCalled(); + }); + it("returns the access token when refreshAccessToken receives a non-empty token", async () => { const client = new GoogleOAuthClient(); const mockOAuthClient = getMockOAuthClient(client); diff --git a/packages/backend/src/auth/services/google/clients/google.oauth.client.ts b/packages/backend/src/auth/services/google/clients/google.oauth.client.ts index bb71d88b5..5464e4a19 100644 --- a/packages/backend/src/auth/services/google/clients/google.oauth.client.ts +++ b/packages/backend/src/auth/services/google/clients/google.oauth.client.ts @@ -8,6 +8,10 @@ import { } from "@core/types/auth.types"; import { type gCalendar } from "@core/types/gcal"; import { StringV4Schema } from "@core/types/type.utils"; +import { + assertGoogleRedirectUri, + getGoogleAuthCallbackUrl, +} from "@backend/auth/services/google/util/google.redirect-uri.util"; import { ENV, isGoogleConfigured, @@ -33,7 +37,7 @@ class GoogleOAuthClient { this.oauthClient = new OAuth2Client( ENV.GOOGLE_CLIENT_ID, ENV.GOOGLE_CLIENT_SECRET, - "postmessage", + getGoogleAuthCallbackUrl(), ); } @@ -64,6 +68,10 @@ class GoogleOAuthClient { async exchangeAuthCode( input: GoogleAuthCodeRequest, ): Promise { + assertGoogleRedirectUri( + input.redirectURIInfo.redirectURIOnProviderDashboard, + ); + const response = await this.oauthClient.getToken({ code: input.redirectURIInfo.redirectURIQueryParams.code, codeVerifier: input.redirectURIInfo.pkceCodeVerifier, diff --git a/packages/backend/src/auth/services/google/util/google.redirect-uri.util.test.ts b/packages/backend/src/auth/services/google/util/google.redirect-uri.util.test.ts new file mode 100644 index 000000000..eda7ea4c3 --- /dev/null +++ b/packages/backend/src/auth/services/google/util/google.redirect-uri.util.test.ts @@ -0,0 +1,31 @@ +import { AuthError } from "@backend/common/errors/auth/auth.errors"; +import { + assertGoogleRedirectUri, + getGoogleAuthCallbackUrl, +} from "./google.redirect-uri.util"; + +describe("google.redirect-uri.util", () => { + it("derives the callback URL from FRONTEND_URL origin", () => { + expect(getGoogleAuthCallbackUrl("https://cal.example.com/day")).toBe( + "https://cal.example.com/auth/google/callback", + ); + }); + + it("accepts the configured callback URL", () => { + expect(() => + assertGoogleRedirectUri( + "https://cal.example.com/auth/google/callback", + "https://cal.example.com", + ), + ).not.toThrow(); + }); + + it("rejects unexpected redirect URLs", () => { + expect(() => + assertGoogleRedirectUri( + "https://evil.example/auth/google/callback", + "https://cal.example.com", + ), + ).toThrow(AuthError.GoogleRedirectUriMismatch.description); + }); +}); diff --git a/packages/backend/src/auth/services/google/util/google.redirect-uri.util.ts b/packages/backend/src/auth/services/google/util/google.redirect-uri.util.ts new file mode 100644 index 000000000..f80922946 --- /dev/null +++ b/packages/backend/src/auth/services/google/util/google.redirect-uri.util.ts @@ -0,0 +1,24 @@ +import { ENV } from "@backend/common/constants/env.constants"; +import { AuthError } from "@backend/common/errors/auth/auth.errors"; +import { error } from "@backend/common/errors/handlers/error.handler"; + +export const GOOGLE_AUTH_CALLBACK_PATH = "/auth/google/callback"; + +export function getGoogleAuthCallbackUrl( + frontendUrl = ENV.FRONTEND_URL, +): string { + const origin = new URL(frontendUrl).origin; + return `${origin}${GOOGLE_AUTH_CALLBACK_PATH}`; +} + +export function assertGoogleRedirectUri( + redirectUri: string, + frontendUrl = ENV.FRONTEND_URL, +): void { + if (redirectUri !== getGoogleAuthCallbackUrl(frontendUrl)) { + throw error( + AuthError.GoogleRedirectUriMismatch, + "Google code exchange failed", + ); + } +} diff --git a/packages/backend/src/common/errors/auth/auth.errors.ts b/packages/backend/src/common/errors/auth/auth.errors.ts index 22d7394c0..7cea6d910 100644 --- a/packages/backend/src/common/errors/auth/auth.errors.ts +++ b/packages/backend/src/common/errors/auth/auth.errors.ts @@ -6,6 +6,7 @@ interface AuthErrors { GoogleAccountAlreadyConnected: ErrorMetadata; GoogleConnectEmailMismatch: ErrorMetadata; GoogleNotConfigured: ErrorMetadata; + GoogleRedirectUriMismatch: ErrorMetadata; InadequatePermissions: ErrorMetadata; MissingRefreshToken: ErrorMetadata; NoUserId: ErrorMetadata; @@ -37,6 +38,11 @@ export const AuthError: AuthErrors = { status: Status.SERVICE_UNAVAILABLE, isOperational: true, }, + GoogleRedirectUriMismatch: { + description: "Google redirect URI does not match this Compass instance", + status: Status.BAD_REQUEST, + isOperational: true, + }, InadequatePermissions: { description: "You don't have permission to do that", status: Status.FORBIDDEN, diff --git a/packages/web/src/auth/google/hooks/googe.auth.types.ts b/packages/web/src/auth/google/hooks/googe.auth.types.ts index 91e6bbeef..f0165db7d 100644 --- a/packages/web/src/auth/google/hooks/googe.auth.types.ts +++ b/packages/web/src/auth/google/hooks/googe.auth.types.ts @@ -1,7 +1,7 @@ import { type CodeResponse } from "@react-oauth/google"; export interface GoogleAuthConfig { - thirdPartyId: string; + thirdPartyId: "google"; clientType: "web"; shouldTryLinkingWithSessionUser?: boolean; redirectURIInfo: { diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index 5839d27a3..0b427c733 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts @@ -2,23 +2,16 @@ import { useCallback, useSyncExternalStore } from "react"; import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { type GoogleConnectionState } from "@core/types/user.types"; import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; -import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; -import { useGoogleAuth } from "@web/auth/google/hooks/useGoogleAuth/useGoogleAuth"; +import { useGoogleLogin } from "@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"; import { clearGoogleSyncIndicatorOverride, getGoogleSyncIndicatorOverride, setRepairingSyncIndicatorOverride, - setSyncingSyncIndicatorOverride, subscribeToGoogleSyncUIState, } from "@web/auth/google/state/google.sync.state"; import { syncPendingLocalEvents } from "@web/auth/google/util/google.auth.util"; -import { AuthApi } from "@web/common/apis/auth.api"; import { SyncApi } from "@web/common/apis/sync.api"; -import { - getApiErrorCode, - isApiError, - parseGoogleConnectError, -} from "@web/common/apis/util/api.util"; +import { getApiErrorCode, isApiError } from "@web/common/apis/util/api.util"; import { GOOGLE_REPAIR_FAILED_TOAST_ID } from "@web/common/constants/toast.constants"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; import { @@ -26,7 +19,6 @@ import { selectUserMetadataStatus, } from "@web/ducks/auth/selectors/user-metadata.selectors"; import { type UserMetadataStatus } from "@web/ducks/auth/slices/user-metadata.slice"; -import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; import { type RootState } from "@web/store"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; @@ -35,10 +27,7 @@ import { type GoogleUiState, type UseConnectGoogleResult, } from "./useConnectGoogle.types"; -import { - buildGoogleConnectRequest, - getGoogleConnectionConfig, -} from "./useConnectGoogle.util"; +import { getGoogleConnectionConfig } from "./useConnectGoogle.util"; // Merges Redux-derived Google connection state with transient UI overrides from // google.sync.ui.state.ts; the override is read via useSyncExternalStore so React @@ -58,41 +47,24 @@ export const useConnectGoogle = (): UseConnectGoogleResult => { getGoogleSyncIndicatorOverride, getGoogleSyncIndicatorOverride, ); - const { login } = useGoogleAuth({ - onSuccess: async (data) => { - const didSyncLocalEvents = await syncPendingLocalEvents(); - if (!didSyncLocalEvents) { - return false; - } - - const googleConnectRequest = buildGoogleConnectRequest( - data.redirectURIInfo, - ); - try { - await AuthApi.connectGoogle(googleConnectRequest); - } catch (error) { - if (isApiError(error)) { - const message = parseGoogleConnectError(error)?.message; + const { login } = useGoogleLogin({ + intent: "connectCalendar", + prompt: "consent", + }); - if (message) { - showErrorToast(message); - return false; - } - } + const onOpenGoogleAuth = useCallback(() => { + const start = async () => { + const didSyncLocalEvents = await syncPendingLocalEvents(); - throw error; + if (!didSyncLocalEvents) { + return; } - setSyncingSyncIndicatorOverride(); - await refreshUserMetadata(); - dispatch(triggerFetch()); - }, - prompt: "consent", - }); + dispatch(settingsSlice.actions.closeCmdPalette()); + void login(); + }; - const onOpenGoogleAuth = useCallback(() => { - void login(); - dispatch(settingsSlice.actions.closeCmdPalette()); + void start(); }, [dispatch, login]); const onRepairGoogle = useCallback(() => { @@ -121,8 +93,8 @@ export const useConnectGoogle = (): UseConnectGoogleResult => { }, [dispatch]); // "checking" is a UI-only state until we have loaded metadata from the server. - // Covers both "idle" (before refreshUserMetadata dispatches setLoading) and - // "loading" so returning users do not briefly see NOT_CONNECTED from the selector default. + // Covers both "idle" and "loading" so returning users do not briefly see + // NOT_CONNECTED from the selector default. const isCheckingStatus = hasUserEverAuthenticated() && userMetadataStatus !== "loaded"; diff --git a/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts b/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts deleted file mode 100644 index e5118004b..000000000 --- a/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { toast } from "react-toastify"; -import { useCompleteAuthentication } from "@web/auth/compass/hooks/useCompleteAuthentication"; -import { useGoogleAuthWithOverlay } from "@web/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay"; -import { authenticate } from "@web/auth/google/util/google.auth.util"; -import { isGooglePopupClosedError } from "@web/auth/google/util/google.oauth.error.util"; -import { toastDefaultOptions } from "@web/common/constants/toast.constants"; -import { - dismissErrorToast, - SESSION_EXPIRED_TOAST_ID, -} from "@web/common/utils/toast/error-toast.util"; -import { - authError, - authSuccess, - resetAuth, - startAuthenticating, -} from "@web/ducks/auth/slices/auth.slice"; -import { type AppDispatch } from "@web/store"; -import { useAppDispatch } from "@web/store/store.hooks"; -import { type GoogleAuthConfig } from "../googe.auth.types"; - -const getErrorMessage = (error: unknown): string => { - if (error instanceof Error) { - return error.message; - } - return "Authentication failed"; -}; - -const handleAuthError = (dispatch: AppDispatch, error: unknown) => { - console.error(error); - dispatch(authError(getErrorMessage(error))); -}; - -const resetAuthState = (dispatch: AppDispatch) => { - dispatch(resetAuth()); -}; - -export function useGoogleAuth( - options: { - onSuccess?: (data: GoogleAuthConfig) => Promise; - prompt?: "consent" | "none" | "select_account"; - shouldTryLinkingWithSessionUser?: boolean; - } = {}, -) { - const dispatch = useAppDispatch(); - const completeAuthentication = useCompleteAuthentication(); - const { onSuccess, prompt, shouldTryLinkingWithSessionUser } = options; - - const googleLogin = useGoogleAuthWithOverlay({ - prompt, - shouldTryLinkingWithSessionUser, - onStart: () => { - dismissErrorToast(SESSION_EXPIRED_TOAST_ID); - dispatch(startAuthenticating()); - }, - onSuccess: async (data) => { - if (onSuccess) { - const shouldCompleteAuth = await onSuccess(data); - if (shouldCompleteAuth === false) { - resetAuthState(dispatch); - return; - } - dispatch(authSuccess()); - return; - } - - const authPayload: GoogleAuthConfig = { - ...data, - }; - const authResult = await authenticate(authPayload); - if (!authResult.success) { - toast.error( - "Failed to connect Google Calendar. Please try again.", - toastDefaultOptions, - ); - handleAuthError(dispatch, authResult.error); - return; - } - if (authResult.data !== undefined && authResult.data.status !== "OK") { - toast.error( - "Could not link Google Calendar to your account. Please try again.", - toastDefaultOptions, - ); - dispatch(resetAuth()); - return; - } - const email = authResult.data?.user?.emails?.[0]; - await completeAuthentication({ email }); - }, - onError: (error) => { - if (isGooglePopupClosedError(error)) { - resetAuthState(dispatch); - return; - } - - handleAuthError(dispatch, error); - }, - }); - - return googleLogin; -} diff --git a/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts b/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts deleted file mode 100644 index c38ed7e2f..000000000 --- a/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { type GoogleAuthConfig } from "../googe.auth.types"; -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; - -const mockLogin = mock(); -const mockUseGoogleLogin = mock(); - -mock.module("@web/auth/google/hooks/useGoogleLogin/useGoogleLogin", () => ({ - useGoogleLogin: mockUseGoogleLogin, -})); - -const { useGoogleAuthWithOverlay } = - require("@web/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay") as typeof import("@web/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay"); - -describe("useGoogleAuthWithOverlay", () => { - beforeEach(() => { - mockLogin.mockClear(); - mockUseGoogleLogin.mockClear(); - }); - - it("calls onStart before login", () => { - const onStart = mock(); - - mockUseGoogleLogin.mockReturnValue({ - login: mockLogin, - loading: false, - data: null, - }); - - const { result } = renderHook(() => useGoogleAuthWithOverlay({ onStart })); - - result.current.login(); - - expect(onStart).toHaveBeenCalledTimes(1); - expect(mockLogin).toHaveBeenCalledTimes(1); - }); - - it("calls onSuccess when Google login succeeds", async () => { - let onSuccessCallback: - | ((data: GoogleAuthConfig) => Promise) - | undefined; - const onSuccess = mock(); - - mockUseGoogleLogin.mockImplementation(({ onSuccess: providedSuccess }) => { - onSuccessCallback = providedSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - renderHook(() => useGoogleAuthWithOverlay({ onSuccess })); - - await onSuccessCallback?.({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - it("calls onError when Google login fails", () => { - let onErrorCallback: ((error: unknown) => void) | undefined; - const onError = mock(); - - mockUseGoogleLogin.mockImplementation(({ onError: providedError }) => { - onErrorCallback = providedError; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - renderHook(() => useGoogleAuthWithOverlay({ onError })); - - onErrorCallback?.(new Error("Login failed")); - - expect(onError).toHaveBeenCalledTimes(1); - }); - - it("calls onError when onSuccess throws", async () => { - let onSuccessCallback: - | ((data: GoogleAuthConfig) => Promise) - | undefined; - const onSuccess = mock().mockRejectedValue(new Error("Auth failed")); - const onError = mock(); - - mockUseGoogleLogin.mockImplementation(({ onSuccess: providedSuccess }) => { - onSuccessCallback = providedSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - renderHook(() => useGoogleAuthWithOverlay({ onSuccess, onError })); - - await onSuccessCallback?.({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - - expect(onError).toHaveBeenCalledTimes(1); - }); -}); - -afterAll(() => { - mock.restore(); -}); diff --git a/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts b/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts deleted file mode 100644 index 185eeffcb..000000000 --- a/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback } from "react"; -import { useGoogleLogin } from "@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"; -import { type GoogleAuthConfig } from "../googe.auth.types"; - -interface UseGoogleAuthWithOverlayOptions { - onStart?: () => void; - onSuccess?: (res: GoogleAuthConfig) => Promise; - onError?: (error: unknown) => void; - prompt?: "consent" | "none" | "select_account"; - shouldTryLinkingWithSessionUser?: boolean; -} - -export const useGoogleAuthWithOverlay = ( - options: UseGoogleAuthWithOverlayOptions = {}, -) => { - const { - onStart, - onSuccess, - onError, - prompt, - shouldTryLinkingWithSessionUser, - } = options; - - const googleLogin = useGoogleLogin({ - prompt, - shouldTryLinkingWithSessionUser, - onSuccess: async (data) => { - try { - await onSuccess?.(data); - } catch (error) { - // Call onError to handle the error appropriately - onError?.(error); - } - }, - onError: (error) => { - onError?.(error); - }, - }); - - const login = useCallback(() => { - onStart?.(); - return googleLogin.login(); - }, [googleLogin, onStart]); - - return { - ...googleLogin, - login, - }; -}; diff --git a/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts b/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts index 42188d4ba..042c6e2c4 100644 --- a/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts +++ b/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts @@ -3,105 +3,46 @@ import { useGoogleLogin as useGoogleLoginBase, } from "@react-oauth/google"; import { useCallback, useRef, useState } from "react"; -import { isGooglePopupClosedError } from "@web/auth/google/util/google.oauth.error.util"; -import { type GoogleAuthConfig } from "../googe.auth.types"; - -const SCOPES_REQUIRED = [ - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", -]; - -const isMissingPermissions = (scope: string) => { - const scopesGranted = scope.split(" "); - return SCOPES_REQUIRED.some((s) => !scopesGranted.includes(s)); -}; +import { GOOGLE_AUTH_SCOPES_REQUIRED } from "@web/auth/google/redirect/google-auth-redirect.constants"; +import { + type GoogleAuthorizationIntent, + writeGoogleAuthorizationIntent, +} from "@web/auth/google/redirect/google-auth-redirect.storage"; +import { + buildGoogleAuthCallbackUrl, + getSafeGoogleAuthReturnPath, +} from "@web/auth/google/redirect/google-auth-redirect.util"; export const useGoogleLogin = ({ + intent, onStart, - onSuccess, onError, prompt, - shouldTryLinkingWithSessionUser, }: { + intent: GoogleAuthorizationIntent["intent"]; onStart?: () => void; - onSuccess?: (res: GoogleAuthConfig) => Promise; onError?: (error: unknown) => void; prompt?: "consent" | "none" | "select_account"; - shouldTryLinkingWithSessionUser?: boolean; }) => { - const [data, setData] = useState<{ - code: string; - scope: string; - state: string | undefined; - } | null>(null); const [loading, setLoading] = useState(false); - - const antiCsrfToken = useRef(crypto.randomUUID()).current; + const state = useRef(crypto.randomUUID()).current; + const redirectUri = buildGoogleAuthCallbackUrl(); const loginOptions: UseGoogleLoginOptionsAuthCodeFlow & { prompt?: "consent" | "none" | "select_account"; } = { flow: "auth-code", - scope: SCOPES_REQUIRED.join(" "), + scope: GOOGLE_AUTH_SCOPES_REQUIRED.join(" "), prompt, - state: antiCsrfToken, - onNonOAuthError(nonOAuthError) { + state, + ux_mode: "redirect", + redirect_uri: redirectUri, + onNonOAuthError(error) { setLoading(false); - - if (isGooglePopupClosedError(nonOAuthError)) { - onError?.(nonOAuthError); - return; - } - - console.error(nonOAuthError); - onError?.(nonOAuthError); - }, - onSuccess({ code, scope, state }) { - const isFromHacker = state !== antiCsrfToken; - if (isFromHacker) { - alert("Nice try, hacker"); - return; - } - - if (isMissingPermissions(scope)) { - alert("Missing permissions, please click all the checkboxes"); - return; - } - - const loginResult = onSuccess?.({ - thirdPartyId: "google", - clientType: "web", - shouldTryLinkingWithSessionUser, - redirectURIInfo: { - redirectURIOnProviderDashboard: window.location.origin, - redirectURIQueryParams: { code, state, scope }, - }, - }); - - void (loginResult ?? Promise.resolve()) - .then(() => { - setData({ code, scope, state }); - }) - .catch((e) => { - console.error(e); - alert("Login failed. Please try again."); - onError?.(e); - }) - .finally(() => { - setLoading(false); - }); + onError?.(error); }, - onError: (error) => { + onError(error) { setLoading(false); - - if (isGooglePopupClosedError(error)) { - onError?.(error); - return; - } - - alert(`Login failed because: ${error.error}`); - console.error(error); onError?.(error); }, }; @@ -111,12 +52,15 @@ export const useGoogleLogin = ({ return { login: useCallback(() => { onStart?.(); - setData(null); setLoading(true); - + writeGoogleAuthorizationIntent(state, { + intent, + returnPath: getSafeGoogleAuthReturnPath(), + createdAt: Date.now(), + }); return login(); - }, [login, onStart]), - data, + }, [intent, login, onStart, state]), + data: null, loading, }; }; diff --git a/packages/web/src/auth/google/redirect/google-auth-redirect.constants.ts b/packages/web/src/auth/google/redirect/google-auth-redirect.constants.ts new file mode 100644 index 000000000..202eb2d74 --- /dev/null +++ b/packages/web/src/auth/google/redirect/google-auth-redirect.constants.ts @@ -0,0 +1,12 @@ +import { ROOT_ROUTES } from "@web/common/constants/routes"; + +export const GOOGLE_AUTH_CALLBACK_PATH = ROOT_ROUTES.GOOGLE_AUTH_CALLBACK; +export const GOOGLE_AUTH_INTENT_STORAGE_PREFIX = + "compass.googleAuthorizationIntent"; +export const GOOGLE_AUTH_INTENT_MAX_AGE_MS = 10 * 60 * 1000; + +export const GOOGLE_AUTH_SCOPES_REQUIRED = [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; diff --git a/packages/web/src/auth/google/redirect/google-auth-redirect.storage.test.ts b/packages/web/src/auth/google/redirect/google-auth-redirect.storage.test.ts new file mode 100644 index 000000000..9b01bf408 --- /dev/null +++ b/packages/web/src/auth/google/redirect/google-auth-redirect.storage.test.ts @@ -0,0 +1,45 @@ +import { + clearGoogleAuthorizationIntent, + readGoogleAuthorizationIntent, + writeGoogleAuthorizationIntent, +} from "./google-auth-redirect.storage"; +import { afterEach, describe, expect, it } from "bun:test"; + +describe("google-auth-redirect.storage", () => { + afterEach(() => sessionStorage.clear()); + + it("stores and reads an intent by OAuth state", () => { + writeGoogleAuthorizationIntent("state-1", { + intent: "signIn", + returnPath: "/week?panel=tasks#top", + createdAt: Date.now(), + }); + + expect(readGoogleAuthorizationIntent("state-1")).toMatchObject({ + intent: "signIn", + returnPath: "/week?panel=tasks#top", + }); + }); + + it("removes invalid or expired stored intents", () => { + writeGoogleAuthorizationIntent("state-1", { + intent: "signIn", + returnPath: "/week", + createdAt: Date.now() - 11 * 60 * 1000, + }); + + expect(readGoogleAuthorizationIntent("state-1")).toBeNull(); + }); + + it("clears a consumed intent", () => { + writeGoogleAuthorizationIntent("state-1", { + intent: "connectCalendar", + returnPath: "/day", + createdAt: Date.now(), + }); + + clearGoogleAuthorizationIntent("state-1"); + + expect(readGoogleAuthorizationIntent("state-1")).toBeNull(); + }); +}); diff --git a/packages/web/src/auth/google/redirect/google-auth-redirect.storage.ts b/packages/web/src/auth/google/redirect/google-auth-redirect.storage.ts new file mode 100644 index 000000000..5bd5d3ab8 --- /dev/null +++ b/packages/web/src/auth/google/redirect/google-auth-redirect.storage.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { + GOOGLE_AUTH_INTENT_MAX_AGE_MS, + GOOGLE_AUTH_INTENT_STORAGE_PREFIX, +} from "./google-auth-redirect.constants"; + +export const GoogleAuthorizationIntentSchema = z.object({ + intent: z.enum(["signIn", "connectCalendar"]), + returnPath: z.string().startsWith("/"), + createdAt: z.number(), +}); + +export type GoogleAuthorizationIntent = z.infer< + typeof GoogleAuthorizationIntentSchema +>; + +const getStorageKey = (state: string) => + `${GOOGLE_AUTH_INTENT_STORAGE_PREFIX}.${state}`; + +export function writeGoogleAuthorizationIntent( + state: string, + intent: GoogleAuthorizationIntent, +): void { + sessionStorage.setItem(getStorageKey(state), JSON.stringify(intent)); +} + +export function readGoogleAuthorizationIntent( + state: string, +): GoogleAuthorizationIntent | null { + const key = getStorageKey(state); + const stored = sessionStorage.getItem(key); + + if (!stored) { + return null; + } + + const parsed = GoogleAuthorizationIntentSchema.safeParse(JSON.parse(stored)); + + if (!parsed.success) { + sessionStorage.removeItem(key); + return null; + } + + const isExpired = + Date.now() - parsed.data.createdAt > GOOGLE_AUTH_INTENT_MAX_AGE_MS; + + if (isExpired) { + sessionStorage.removeItem(key); + return null; + } + + return parsed.data; +} + +export function clearGoogleAuthorizationIntent(state: string): void { + sessionStorage.removeItem(getStorageKey(state)); +} diff --git a/packages/web/src/auth/google/redirect/google-auth-redirect.util.test.ts b/packages/web/src/auth/google/redirect/google-auth-redirect.util.test.ts new file mode 100644 index 000000000..00ccf1a40 --- /dev/null +++ b/packages/web/src/auth/google/redirect/google-auth-redirect.util.test.ts @@ -0,0 +1,55 @@ +import { + buildGoogleAuthCallbackUrl, + buildGoogleAuthCodePayload, + getSafeGoogleAuthReturnPath, +} from "./google-auth-redirect.util"; +import { describe, expect, it } from "bun:test"; + +describe("google-auth-redirect.util", () => { + it("builds the callback URL from the current origin", () => { + expect(buildGoogleAuthCallbackUrl("http://localhost:9080")).toBe( + "http://localhost:9080/auth/google/callback", + ); + }); + + it("keeps same-origin app paths as return paths", () => { + expect( + getSafeGoogleAuthReturnPath( + "http://localhost:9080/day/2026-05-05?x=1#agenda", + "http://localhost:9080", + ), + ).toBe("/day/2026-05-05?x=1#agenda"); + }); + + it("falls back to /day for external return paths", () => { + expect( + getSafeGoogleAuthReturnPath( + "https://evil.example/phish", + "http://localhost:9080", + ), + ).toBe("/day"); + }); + + it("builds the existing auth-code payload shape", () => { + expect( + buildGoogleAuthCodePayload({ + code: "auth-code", + scope: "email profile", + state: "state-1", + redirectUri: "http://localhost:9080/auth/google/callback", + }), + ).toEqual({ + thirdPartyId: "google", + clientType: "web", + redirectURIInfo: { + redirectURIOnProviderDashboard: + "http://localhost:9080/auth/google/callback", + redirectURIQueryParams: { + code: "auth-code", + scope: "email profile", + state: "state-1", + }, + }, + }); + }); +}); diff --git a/packages/web/src/auth/google/redirect/google-auth-redirect.util.ts b/packages/web/src/auth/google/redirect/google-auth-redirect.util.ts new file mode 100644 index 000000000..62fb1a8cd --- /dev/null +++ b/packages/web/src/auth/google/redirect/google-auth-redirect.util.ts @@ -0,0 +1,48 @@ +import { type GoogleAuthCodeRequest } from "@core/types/auth.types"; +import { GOOGLE_AUTH_CALLBACK_PATH } from "./google-auth-redirect.constants"; + +export function buildGoogleAuthCallbackUrl(origin = window.location.origin) { + return `${origin}${GOOGLE_AUTH_CALLBACK_PATH}`; +} + +export function getSafeGoogleAuthReturnPath( + href = window.location.href, + origin = window.location.origin, +): string { + try { + const url = new URL(href, origin); + + if (url.origin !== origin) { + return "/day"; + } + + if (url.pathname === GOOGLE_AUTH_CALLBACK_PATH) { + return "/day"; + } + + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return "/day"; + } +} + +export function buildGoogleAuthCodePayload({ + code, + scope, + state, + redirectUri = buildGoogleAuthCallbackUrl(), +}: { + code: string; + scope?: string; + state?: string; + redirectUri?: string; +}): GoogleAuthCodeRequest { + return { + thirdPartyId: "google", + clientType: "web", + redirectURIInfo: { + redirectURIOnProviderDashboard: redirectUri, + redirectURIQueryParams: { code, scope, state }, + }, + }; +} diff --git a/packages/web/src/auth/google/util/google.oauth.error.util.test.ts b/packages/web/src/auth/google/util/google.oauth.error.util.test.ts deleted file mode 100644 index 4ac48a4c4..000000000 --- a/packages/web/src/auth/google/util/google.oauth.error.util.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { isGooglePopupClosedError } from "@web/auth/google/util/google.oauth.error.util"; - -describe("isGooglePopupClosedError", () => { - it("returns true for non-oauth popup_closed type", () => { - expect(isGooglePopupClosedError({ type: "popup_closed" })).toBe(true); - }); - - it("returns true for popup-closed error messages", () => { - expect( - isGooglePopupClosedError({ message: "Popup window closed by user" }), - ).toBe(true); - }); - - it("returns false for non-popup auth failures", () => { - expect(isGooglePopupClosedError({ error: "access_denied" })).toBe(false); - expect(isGooglePopupClosedError(new Error("network down"))).toBe(false); - }); -}); diff --git a/packages/web/src/auth/google/util/google.oauth.error.util.ts b/packages/web/src/auth/google/util/google.oauth.error.util.ts deleted file mode 100644 index b12e93700..000000000 --- a/packages/web/src/auth/google/util/google.oauth.error.util.ts +++ /dev/null @@ -1,33 +0,0 @@ -const POPUP_CLOSED_ERROR_TYPE = "popup_closed"; -const POPUP_CLOSED_ERROR_MESSAGE = "popup window closed"; - -type GoogleOAuthErrorLike = { - type?: unknown; - error?: unknown; - error_description?: unknown; - message?: unknown; -}; - -export const isGooglePopupClosedError = (error: unknown): boolean => { - if (!error || typeof error !== "object") { - return false; - } - - const maybeGoogleError = error as GoogleOAuthErrorLike; - - if (maybeGoogleError.type === POPUP_CLOSED_ERROR_TYPE) { - return true; - } - - const errorMessages = [ - maybeGoogleError.error, - maybeGoogleError.error_description, - maybeGoogleError.message, - ].filter((value): value is string => typeof value === "string"); - - return errorMessages.some( - (value) => - value.toLowerCase() === POPUP_CLOSED_ERROR_TYPE || - value.toLowerCase().includes(POPUP_CLOSED_ERROR_MESSAGE), - ); -}; diff --git a/packages/web/src/common/apis/auth.api.ts b/packages/web/src/common/apis/auth.api.ts index a87207bf5..17059fb58 100644 --- a/packages/web/src/common/apis/auth.api.ts +++ b/packages/web/src/common/apis/auth.api.ts @@ -3,11 +3,12 @@ import { type GoogleConnectResponse, type Result_Auth_Compass, } from "@core/types/auth.types"; -import { type GoogleAuthConfig } from "@web/auth/google/hooks/googe.auth.types"; import { BaseApi } from "@web/common/apis/base/base.api"; const AuthApi = { - async loginOrSignup(data: GoogleAuthConfig): Promise { + async loginOrSignup( + data: GoogleAuthCodeRequest, + ): Promise { const response = await BaseApi.post( `/signinup`, data, diff --git a/packages/web/src/common/constants/routes.ts b/packages/web/src/common/constants/routes.ts index 872e4fc28..a48cef3b3 100644 --- a/packages/web/src/common/constants/routes.ts +++ b/packages/web/src/common/constants/routes.ts @@ -2,6 +2,7 @@ export const ROOT_ROUTES = { API: "/api", LOGOUT: "/logout", CLEANUP: "/cleanup", + GOOGLE_AUTH_CALLBACK: "/auth/google/callback", ROOT: "/", WEEK: "/week", DAY: "/day", diff --git a/packages/web/src/common/repositories/event/local.event.repository.test.ts b/packages/web/src/common/repositories/event/local.event.repository.test.ts new file mode 100644 index 000000000..9f64d06b3 --- /dev/null +++ b/packages/web/src/common/repositories/event/local.event.repository.test.ts @@ -0,0 +1,49 @@ +import { Origin, Priorities } from "@core/constants/core.constants"; +import { type Event_Core } from "@core/types/event.types"; +import { LocalEventRepository } from "@web/common/repositories/event/local.event.repository"; +import { + isLocalDemoEvent, + markLocalDemoEvent, +} from "@web/common/storage/types/local-event.types"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const putEvent = mock(); +const getAllEvents = mock(); + +mock.module("@web/common/storage/adapter/adapter", () => ({ + getStorageAdapter: () => ({ + putEvent, + getAllEvents, + }), +})); + +const makeEvent = (overrides: Partial = {}): Event_Core => ({ + _id: "event-1", + title: "Morning standup", + startDate: "2026-05-05T09:00:00.000Z", + endDate: "2026-05-05T10:00:00.000Z", + origin: Origin.COMPASS, + priority: Priorities.UNASSIGNED, + user: "unauthenticated", + ...overrides, +}); + +describe("LocalEventRepository", () => { + beforeEach(() => { + putEvent.mockClear(); + getAllEvents.mockClear(); + }); + + it("preserves the demo marker when editing a seeded demo event", async () => { + const existing = markLocalDemoEvent(makeEvent()); + getAllEvents.mockResolvedValue([existing]); + + await new LocalEventRepository().edit( + "event-1", + makeEvent({ title: "Renamed sample" }), + {}, + ); + + expect(isLocalDemoEvent(putEvent.mock.calls[0][0])).toBe(true); + }); +}); diff --git a/packages/web/src/common/repositories/event/local.event.repository.ts b/packages/web/src/common/repositories/event/local.event.repository.ts index e0a364e45..ceaf7d0b3 100644 --- a/packages/web/src/common/repositories/event/local.event.repository.ts +++ b/packages/web/src/common/repositories/event/local.event.repository.ts @@ -6,6 +6,7 @@ import { type Schema_Event, } from "@core/types/event.types"; import { getStorageAdapter } from "@web/common/storage/adapter/adapter"; +import { preserveLocalEventMarker } from "@web/common/storage/types/local-event.types"; import { type Response_GetEventsSuccess } from "@web/ducks/events/event.types"; import { type EventRepository } from "./event.repository.interface"; @@ -66,9 +67,15 @@ export class LocalEventRepository implements EventRepository { // eslint-disable-next-line @typescript-eslint/no-unused-vars _params: { applyTo?: RecurringEventUpdateScope }, ): Promise { - // For local repository, we just save the updated event - // The applyTo parameter is not relevant for local storage - await this.adapter.putEvent(event as Event_Core); + const existingEvent = (await this.adapter.getAllEvents()).find( + (storedEvent) => storedEvent._id === _id, + ); + const eventToSave = preserveLocalEventMarker( + existingEvent, + event as Event_Core, + ); + + await this.adapter.putEvent(eventToSave); } async delete( diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts index bf5e48439..1f3736039 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -1,6 +1,6 @@ import Dexie, { type Table } from "dexie"; -import { type Event_Core } from "@core/types/event.types"; import { isDateRangeOverlapping } from "@core/util/date/date.util"; +import { type LocalStoredEvent } from "@web/common/storage/types/local-event.types"; import { normalizeTask, normalizeTasks, @@ -23,7 +23,7 @@ import { * Schema versioning is handled by Dexie's built-in version() method. */ class CompassDB extends Dexie { - events!: Table; + events!: Table; tasks!: Table; _migrations!: Table; @@ -194,7 +194,7 @@ export class IndexedDBAdapter implements StorageAdapter { startDate: string, endDate: string, isSomeday?: boolean, - ): Promise { + ): Promise { const allEvents = await this.db.events.toArray(); return allEvents.filter((event) => { @@ -212,18 +212,18 @@ export class IndexedDBAdapter implements StorageAdapter { }); } - async getAllEvents(): Promise { + async getAllEvents(): Promise { return this.db.events.toArray(); } - async putEvent(event: Event_Core): Promise { + async putEvent(event: LocalStoredEvent): Promise { if (!event._id) { throw new Error("Event must have an _id to save"); } await this.db.events.put(event); } - async putEvents(events: Event_Core[]): Promise { + async putEvents(events: LocalStoredEvent[]): Promise { const validEvents = events.filter((e) => e._id); if (validEvents.length > 0) { await this.db.events.bulkPut(validEvents); diff --git a/packages/web/src/common/storage/adapter/storage.adapter.ts b/packages/web/src/common/storage/adapter/storage.adapter.ts index acf4b5a36..4529cd951 100644 --- a/packages/web/src/common/storage/adapter/storage.adapter.ts +++ b/packages/web/src/common/storage/adapter/storage.adapter.ts @@ -1,4 +1,4 @@ -import { type Event_Core } from "@core/types/event.types"; +import { type LocalStoredEvent } from "@web/common/storage/types/local-event.types"; import { type Task } from "@web/common/types/task.types"; /** @@ -87,22 +87,22 @@ export interface StorageAdapter { startDate: string, endDate: string, isSomeday?: boolean, - ): Promise; + ): Promise; /** * Get all events without filtering. */ - getAllEvents(): Promise; + getAllEvents(): Promise; /** * Save or update a single event. */ - putEvent(event: Event_Core): Promise; + putEvent(event: LocalStoredEvent): Promise; /** * Save or update multiple events. */ - putEvents(events: Event_Core[]): Promise; + putEvents(events: LocalStoredEvent[]): Promise; /** * Delete an event by ID. diff --git a/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts b/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts index 77696a8de..f00a8b934 100644 --- a/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts +++ b/packages/web/src/common/storage/migrations/external/demo-data-seed.test.ts @@ -9,6 +9,10 @@ import { import dayjs from "@core/util/date/dayjs"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; import { createMockStorageAdapter } from "@web/__tests__/utils/storage/mock-storage-adapter.util"; +import { + isLocalDemoEvent, + LOCAL_DEMO_EVENT_FIELD, +} from "@web/common/storage/types/local-event.types"; import { GridEventSchema, type Schema_GridEvent, @@ -51,6 +55,10 @@ describe("demoDataSeedMigration", () => { // Verify events were created (7 total: 5 today + 2 someday) const eventsCall = adapter.putEvents.mock.calls[0][0] as Schema_WebEvent[]; expect(eventsCall).toHaveLength(7); + expect( + eventsCall.every((event) => isLocalDemoEvent(event as Event_Core)), + ).toBe(true); + expect(eventsCall[0]).toHaveProperty(LOCAL_DEMO_EVENT_FIELD, true); // Verify tasks were created for 3 days expect(adapter.putTasks).toHaveBeenCalledTimes(3); diff --git a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts index 220c698b1..3fb945be8 100644 --- a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts +++ b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts @@ -9,6 +9,7 @@ import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; import { createObjectIdString } from "@web/common/utils/id/object-id.util"; import { getModifierKeyLabel } from "@web/common/utils/shortcut/shortcut.util"; import { type StorageAdapter } from "../../adapter/storage.adapter"; +import { markLocalDemoEvent } from "../../types/local-event.types"; import { type ExternalMigration } from "../migration.types"; type Event_WithPosition = Event_Core & Pick; @@ -196,12 +197,14 @@ function generateDemoData() { return { events: [...somedayEvents, ...todayEvents].map((event): Event_Seeded => { - if (event.isSomeday || event.isAllDay) { - return event; + const localDemoEvent = markLocalDemoEvent(event); + + if (localDemoEvent.isSomeday || localDemoEvent.isAllDay) { + return localDemoEvent; } return { - ...event, + ...localDemoEvent, position: { ...gridEventDefaultPosition, dragOffset: { ...gridEventDefaultPosition.dragOffset }, diff --git a/packages/web/src/common/storage/types/local-event.types.test.ts b/packages/web/src/common/storage/types/local-event.types.test.ts new file mode 100644 index 000000000..d3abc7fcc --- /dev/null +++ b/packages/web/src/common/storage/types/local-event.types.test.ts @@ -0,0 +1,52 @@ +import { Origin, Priorities } from "@core/constants/core.constants"; +import { + isLocalDemoEvent, + LOCAL_DEMO_EVENT_FIELD, + markLocalDemoEvent, + preserveLocalEventMarker, + stripLocalOnlyEventFields, +} from "./local-event.types"; +import { describe, expect, it } from "bun:test"; + +const baseEvent = { + _id: "event-1", + title: "User event", + startDate: "2026-05-05T09:00:00.000Z", + endDate: "2026-05-05T10:00:00.000Z", + origin: Origin.COMPASS, + priority: Priorities.UNASSIGNED, + user: "unauthenticated", +}; + +describe("local-event.types", () => { + it("marks seeded demo events as local demo events", () => { + const marked = markLocalDemoEvent(baseEvent); + + expect(marked[LOCAL_DEMO_EVENT_FIELD]).toBe(true); + expect(isLocalDemoEvent(marked)).toBe(true); + }); + + it("preserves a demo marker across local edits", () => { + const existing = markLocalDemoEvent(baseEvent); + const edited = { ...baseEvent, title: "Renamed sample" }; + + expect(preserveLocalEventMarker(existing, edited)).toMatchObject({ + title: "Renamed sample", + [LOCAL_DEMO_EVENT_FIELD]: true, + }); + }); + + it("does not add a marker to user-created events", () => { + const edited = { ...baseEvent, title: "Real event" }; + + expect(preserveLocalEventMarker(baseEvent, edited)).toEqual(edited); + }); + + it("strips local-only fields before backend sync", () => { + const marked = markLocalDemoEvent(baseEvent); + + expect(stripLocalOnlyEventFields(marked)).not.toHaveProperty( + LOCAL_DEMO_EVENT_FIELD, + ); + }); +}); diff --git a/packages/web/src/common/storage/types/local-event.types.ts b/packages/web/src/common/storage/types/local-event.types.ts new file mode 100644 index 000000000..f180cc6f0 --- /dev/null +++ b/packages/web/src/common/storage/types/local-event.types.ts @@ -0,0 +1,40 @@ +import { type Event_Core } from "@core/types/event.types"; + +export const LOCAL_DEMO_EVENT_FIELD = "__compassDemoEvent"; + +export type LocalStoredEvent = Event_Core & { + [LOCAL_DEMO_EVENT_FIELD]?: true; +}; + +export function markLocalDemoEvent( + event: T, +): T & Pick { + return { + ...event, + [LOCAL_DEMO_EVENT_FIELD]: true, + }; +} + +export function isLocalDemoEvent(event: Event_Core): boolean { + return (event as LocalStoredEvent)[LOCAL_DEMO_EVENT_FIELD] === true; +} + +export function preserveLocalEventMarker( + existingEvent: Event_Core | undefined, + nextEvent: T, +): T | (T & Pick) { + if (!existingEvent || !isLocalDemoEvent(existingEvent)) { + return nextEvent; + } + + return markLocalDemoEvent(nextEvent); +} + +export function stripLocalOnlyEventFields( + event: T, +): Event_Core { + const { [LOCAL_DEMO_EVENT_FIELD]: _demo, ...eventForSync } = + event as LocalStoredEvent; + + return eventForSync; +} diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts new file mode 100644 index 000000000..b7280bf6e --- /dev/null +++ b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts @@ -0,0 +1,69 @@ +import { Origin, Priorities } from "@core/constants/core.constants"; +import { type Event_Core } from "@core/types/event.types"; +import { EventApi } from "@web/ducks/events/event.api"; +import { markLocalDemoEvent } from "../../storage/types/local-event.types"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const ensureStorageReady = mock(); +const getAllEvents = mock(); +const clearAllEvents = mock(); +const create = mock(); + +mock.module("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady, + getStorageAdapter: () => ({ + getAllEvents, + clearAllEvents, + }), +})); + +mock.module("@web/ducks/events/event.api", () => ({ + EventApi: { create }, +})); + +const { syncLocalEventsToCloud } = + require("./local-event-sync.util") as typeof import("./local-event-sync.util"); + +const makeEvent = (overrides: Partial = {}): Event_Core => ({ + _id: overrides._id ?? "event-1", + title: overrides.title ?? "User event", + startDate: "2026-05-05T09:00:00.000Z", + endDate: "2026-05-05T10:00:00.000Z", + origin: Origin.COMPASS, + priority: Priorities.UNASSIGNED, + user: "unauthenticated", + ...overrides, +}); + +describe("syncLocalEventsToCloud", () => { + beforeEach(() => { + ensureStorageReady.mockClear(); + getAllEvents.mockClear(); + clearAllEvents.mockClear(); + create.mockClear(); + }); + + it("syncs user-created events and skips demo events", async () => { + const userEvent = makeEvent({ _id: "user-event" }); + const demoEvent = markLocalDemoEvent( + makeEvent({ _id: "demo-event", title: "Try Compass" }), + ); + getAllEvents.mockResolvedValue([userEvent, demoEvent]); + + await expect(syncLocalEventsToCloud()).resolves.toBe(1); + + expect(EventApi.create).toHaveBeenCalledWith([userEvent]); + expect(clearAllEvents).toHaveBeenCalledTimes(1); + }); + + it("clears local demo events without sending them to the backend", async () => { + getAllEvents.mockResolvedValue([ + markLocalDemoEvent(makeEvent({ _id: "demo-event" })), + ]); + + await expect(syncLocalEventsToCloud()).resolves.toBe(0); + + expect(EventApi.create).not.toHaveBeenCalled(); + expect(clearAllEvents).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.ts b/packages/web/src/common/utils/sync/local-event-sync.util.ts index 35696568e..3168c4c21 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.ts @@ -2,6 +2,10 @@ import { ensureStorageReady, getStorageAdapter, } from "@web/common/storage/adapter/adapter"; +import { + isLocalDemoEvent, + stripLocalOnlyEventFields, +} from "@web/common/storage/types/local-event.types"; import { EventApi } from "@web/ducks/events/event.api"; export async function syncLocalEventsToCloud(): Promise { @@ -13,8 +17,15 @@ export async function syncLocalEventsToCloud(): Promise { return 0; } - await EventApi.create(events); + const eventsToSync = events + .filter((event) => !isLocalDemoEvent(event)) + .map(stripLocalOnlyEventFields); + + if (eventsToSync.length > 0) { + await EventApi.create(eventsToSync); + } + await adapter.clearAllEvents(); - return events.length; + return eventsToSync.length; } diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 5c5b6a933..09a126e65 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -32,10 +32,10 @@ mock.module("@web/auth/compass/session/useSession", () => ({ useSession: () => mockUseSession(), })); -// Mock useGoogleAuth +// Mock useGoogleLogin const mockGoogleLogin = mock(); -mock.module("@web/auth/google/hooks/useGoogleAuth/useGoogleAuth", () => ({ - useGoogleAuth: () => ({ +mock.module("@web/auth/google/hooks/useGoogleLogin/useGoogleLogin", () => ({ + useGoogleLogin: () => ({ login: mockGoogleLogin, }), })); diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index bd4b41452..81a496463 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -1,7 +1,11 @@ import { DotIcon } from "@phosphor-icons/react"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; -import { useGoogleAuth } from "@web/auth/google/hooks/useGoogleAuth/useGoogleAuth"; +import { useGoogleLogin } from "@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"; import { useIsGoogleAvailable } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; +import { + dismissErrorToast, + SESSION_EXPIRED_TOAST_ID, +} from "@web/common/utils/toast/error-toast.util"; import { GoogleButton } from "@web/components/AuthModal/components/GoogleButton"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; import { AuthButton } from "./components/AuthButton"; @@ -33,7 +37,7 @@ function getInitialAuthToken(): string | undefined { * * Features: * - Tab navigation between Sign In and Sign Up - * - Google OAuth integration via existing useGoogleAuth hook + * - Google OAuth integration via redirect-based Google login hook * - Email/password forms with Zod validation * - Forgot password flow with generic success message * - Accessible modal with proper ARIA attributes @@ -41,7 +45,12 @@ function getInitialAuthToken(): string | undefined { export const AuthModal: FC = () => { const { isOpen, currentView, openModal, closeModal, setView } = useAuthModal(); - const googleAuth = useGoogleAuth(); + const googleAuth = useGoogleLogin({ + intent: "signIn", + onStart: () => { + dismissErrorToast(SESSION_EXPIRED_TOAST_ID); + }, + }); const isGoogleAvailable = useIsGoogleAvailable(); const isLoginView = currentView === "login" || currentView === "loginAfterReset"; diff --git a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx index d6631b7b4..c51dcf63c 100644 --- a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx +++ b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.test.tsx @@ -4,10 +4,6 @@ import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import { AuthenticatedLayout } from "./AuthenticatedLayout"; -mock.module("@web/components/SyncEventsOverlay/SyncEventsOverlay", () => ({ - SyncEventsOverlay: () => null, -})); - describe("AuthenticatedLayout", () => { beforeEach(() => { mock.restore(); diff --git a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx index 13e2e8001..01c5c1c64 100644 --- a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx +++ b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx @@ -1,15 +1,9 @@ import { Outlet } from "react-router-dom"; -import { SyncEventsOverlay } from "@web/components/SyncEventsOverlay/SyncEventsOverlay"; /** * Layout component for authenticated routes * Handles shared logic like data refetching that should run for all authenticated views */ export const AuthenticatedLayout = () => { - return ( - <> - - - - ); + return ; }; diff --git a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx b/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx deleted file mode 100644 index 0d5c36c52..000000000 --- a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { act } from "react"; -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; -import { readFile, writeFile } from "node:fs/promises"; - -mock.restore(); - -const componentQuery = "?test=sync-events-overlay"; -const overlayPanelQuery = `@web/components/OverlayPanel/OverlayPanel${componentQuery}`; -const bufferedVisibilityQuery = `@web/common/hooks/useBufferedVisibility${componentQuery}`; -const storeHooksQuery = `@web/store/store.hooks${componentQuery}`; - -mock.module(overlayPanelQuery, () => ({ - OverlayPanel: ({ title, message }: { message: string; title: string }) => ( -
-

{title}

-

{message}

-
- ), -})); - -mock.module(bufferedVisibilityQuery, () => ({ - useBufferedVisibility: (value: boolean) => value, -})); - -let authStatus: "idle" | "authenticating" = "idle"; - -mock.module(storeHooksQuery, () => ({ - useAppSelector: ( - selector: (state: { auth: { status: string } }) => unknown, - ) => selector({ auth: { status: authStatus } }), -})); - -const source = await readFile( - new URL("./SyncEventsOverlay.tsx", import.meta.url), - "utf8", -); - -const transformedSource = source - .replaceAll("@web/components/OverlayPanel/OverlayPanel", overlayPanelQuery) - .replaceAll( - "@web/common/hooks/useBufferedVisibility", - bufferedVisibilityQuery, - ) - .replaceAll("@web/store/store.hooks", storeHooksQuery); - -const transpiler = new Bun.Transpiler({ - autoImportJSX: true, - tsconfig: { - compilerOptions: { - jsx: "react-jsxdev", - jsxImportSource: "react", - }, - }, -}); -const transformedJavaScript = transpiler.transformSync( - transformedSource, - "tsx", -); - -const tempModuleUrl = new URL( - `./.sync-events-overlay-${process.pid}-${Date.now()}.mjs`, - import.meta.url, -); -await writeFile(tempModuleUrl, transformedJavaScript); - -const { SyncEventsOverlay } = await import(tempModuleUrl.href); - -describe("SyncEventsOverlay", () => { - let pendingTimers: Array<() => void> = []; - let setTimeoutSpy: ReturnType; - let clearTimeoutSpy: ReturnType; - - beforeEach(() => { - pendingTimers = []; - setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( - callback: TimerHandler, - ) => { - if (typeof callback === "function") { - pendingTimers.push(() => callback()); - } - return 1; - }) as unknown as typeof setTimeout); - clearTimeoutSpy = spyOn(globalThis, "clearTimeout").mockImplementation( - (() => undefined) as unknown as typeof clearTimeout, - ); - document.body.removeAttribute("data-app-locked"); - }); - - afterEach(() => { - setTimeoutSpy.mockRestore(); - clearTimeoutSpy.mockRestore(); - }); - - afterAll(() => { - mock.restore(); - }); - - const runPendingTimers = () => { - const timers = pendingTimers; - pendingTimers = []; - - for (const timer of timers) { - timer(); - } - }; - - const renderWithAuthStatus = (status: "idle" | "authenticating") => { - authStatus = status; - return render(); - }; - - it("renders nothing when not authenticating", () => { - renderWithAuthStatus("idle"); - - expect(screen.queryByText("Complete Google sign-in...")).toBeNull(); - expect(document.body.getAttribute("data-app-locked")).toBeNull(); - }); - - it("renders OAuth message when authenticating", () => { - renderWithAuthStatus("authenticating"); - - expect(screen.getByText("Complete Google sign-in...")).toBeInTheDocument(); - expect( - screen.getByText("Please complete authorization in the popup window"), - ).toBeInTheDocument(); - expect(document.body.getAttribute("data-app-locked")).toBe("true"); - }); - - it("unlocks app when authentication completes", () => { - authStatus = "authenticating"; - const { rerender } = render(); - - expect(screen.getByText("Complete Google sign-in...")).toBeInTheDocument(); - expect(document.body.getAttribute("data-app-locked")).toBe("true"); - - act(() => { - authStatus = "idle"; - rerender(); - }); - - // Wait for buffered visibility to settle - act(() => { - runPendingTimers(); - }); - - expect(screen.queryByText("Complete Google sign-in...")).toBeNull(); - expect(document.body.getAttribute("data-app-locked")).toBeNull(); - }); -}); diff --git a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx b/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx deleted file mode 100644 index ec14d5596..000000000 --- a/packages/web/src/components/SyncEventsOverlay/SyncEventsOverlay.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from "react"; -import { useBufferedVisibility } from "@web/common/hooks/useBufferedVisibility"; -import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; -import { selectIsAuthenticating } from "@web/ducks/auth/selectors/auth.selectors"; -import { useAppSelector } from "@web/store/store.hooks"; - -export const SyncEventsOverlay = () => { - const isAuthenticating = useAppSelector(selectIsAuthenticating); - - // Only block the app during OAuth popup phase - // Calendar import happens in background with sidebar spinner - const isActive = useBufferedVisibility(isAuthenticating); - - useEffect(() => { - if (!isActive) { - document.body.removeAttribute("data-app-locked"); - return; - } - - document.body.setAttribute("data-app-locked", "true"); - const activeElement = document.activeElement as HTMLElement | null; - activeElement?.blur?.(); - - return () => { - document.body.removeAttribute("data-app-locked"); - }; - }, [isActive]); - - if (!isActive) return null; - - return ( -