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..a34c12771 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 authorization app flow: `packages/web/src/auth/google/authorization` +- Google redirect callback: `packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx` +- Google authorization intent storage: `packages/web/src/auth/google/authorization/google-authorization.storage.ts` - 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/e2e/oauth/google-auth-callback.spec.ts b/e2e/oauth/google-auth-callback.spec.ts new file mode 100644 index 000000000..bcf9eb65f --- /dev/null +++ b/e2e/oauth/google-auth-callback.spec.ts @@ -0,0 +1,276 @@ +import { expect, type Page, test } from "@playwright/test"; + +const CALLBACK_PATH = "/auth/google/callback"; +const INTENT_STORAGE_PREFIX = "compass.googleAuthorizationIntent"; +const REQUIRED_SCOPES = [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; + +type CapturedAuthRequest = { + body: unknown; + headers: Record; +}; + +type ApiMocks = { + connectGoogle: CapturedAuthRequest[]; + loginOrSignup: CapturedAuthRequest[]; +}; + +type ApiMockOptions = { + beforeConnectGoogleResponse?: Promise; + beforeLoginOrSignupResponse?: Promise; +}; + +const createDeferred = () => { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + + return { promise, resolve }; +}; + +const getIntentStorageKey = (state: string) => + `${INTENT_STORAGE_PREFIX}.${state}`; + +const getCallbackUrl = (state: string, scope = REQUIRED_SCOPES.join(" ")) => + `${CALLBACK_PATH}?state=${encodeURIComponent( + state, + )}&code=auth-code&scope=${encodeURIComponent(scope)}`; + +const writeGoogleAuthorizationIntent = async ({ + intent, + page, + returnPath, + state, +}: { + intent: "signIn" | "connectCalendar"; + page: Page; + returnPath: string; + state: string; +}) => { + await page.goto("/week"); + await page.evaluate( + ({ key, value }) => { + sessionStorage.setItem(key, JSON.stringify(value)); + }, + { + key: getIntentStorageKey(state), + value: { + intent, + returnPath, + createdAt: Date.now(), + }, + }, + ); +}; + +const prepareGoogleAuthCallbackPage = async ( + page: Page, + options: ApiMockOptions = {}, +): Promise => { + const apiMocks: ApiMocks = { + connectGoogle: [], + loginOrSignup: [], + }; + + page.on("dialog", async (dialog) => { + await dialog.dismiss().catch(() => undefined); + }); + + await page.addInitScript(() => { + window.__COMPASS_E2E_TEST__ = true; + window.alert = () => undefined; + window.confirm = () => true; + window.prompt = () => null; + }); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + + if (url.pathname.endsWith("/api/signinup")) { + apiMocks.loginOrSignup.push({ + body: request.postDataJSON(), + headers: request.headers(), + }); + await options.beforeLoginOrSignupResponse; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + user: { emails: ["user@example.com"] }, + }), + }); + } + + if (url.pathname.endsWith("/api/auth/google/connect")) { + apiMocks.connectGoogle.push({ + body: request.postDataJSON(), + headers: request.headers(), + }); + await options.beforeConnectGoogleResponse; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({}), + }); + } + + if (url.pathname.includes("/api/session")) { + return route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ message: "unauthorized" }), + }); + } + + if (url.pathname.endsWith("/api/user/metadata")) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ google: { connectionState: "HEALTHY" } }), + }); + } + + if (url.pathname.endsWith("/api/config")) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ google: { isConfigured: true } }), + }); + } + + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({}), + }); + }); + + return apiMocks; +}; + +test.describe("Google auth callback", () => { + test("shows completion status while finishing a saved Google sign-in intent", async ({ + page, + }) => { + const state = "sign-in-state"; + const delayedSignIn = createDeferred(); + const apiMocks = await prepareGoogleAuthCallbackPage(page, { + beforeLoginOrSignupResponse: delayedSignIn.promise, + }); + + await writeGoogleAuthorizationIntent({ + intent: "signIn", + page, + returnPath: "/week", + state, + }); + + await page.goto(getCallbackUrl(state)); + + await expect( + page.locator('[role="status"][aria-busy="true"][aria-live="polite"]'), + ).toBeVisible(); + await expect( + page.getByText("Completing Google authorization..."), + ).toBeVisible(); + await expect(page.getByText("Returning you to Compass.")).toBeVisible(); + + delayedSignIn.resolve(); + + await expect(page).toHaveURL(/\/week$/); + expect(apiMocks.loginOrSignup).toHaveLength(1); + expect(apiMocks.connectGoogle).toHaveLength(0); + expect(apiMocks.loginOrSignup[0]?.headers.rid).toBe("thirdparty"); + expect(apiMocks.loginOrSignup[0]?.body).toMatchObject({ + thirdPartyId: "google", + clientType: "web", + redirectURIInfo: { + redirectURIOnProviderDashboard: expect.stringContaining(CALLBACK_PATH), + redirectURIQueryParams: { + code: "auth-code", + state, + }, + }, + }); + expect( + await page.evaluate( + (key) => sessionStorage.getItem(key), + getIntentStorageKey(state), + ), + ).toBeNull(); + }); + + test("finishes a saved Google Calendar connect intent", async ({ page }) => { + const state = "connect-calendar-state"; + const apiMocks = await prepareGoogleAuthCallbackPage(page); + + await writeGoogleAuthorizationIntent({ + intent: "connectCalendar", + page, + returnPath: "/week", + state, + }); + + await page.goto(getCallbackUrl(state)); + + await expect(page).toHaveURL(/\/week$/); + expect(apiMocks.loginOrSignup).toHaveLength(0); + expect(apiMocks.connectGoogle).toHaveLength(1); + expect(apiMocks.connectGoogle[0]?.body).toMatchObject({ + thirdPartyId: "google", + clientType: "web", + redirectURIInfo: { + redirectURIOnProviderDashboard: expect.stringContaining(CALLBACK_PATH), + redirectURIQueryParams: { + code: "auth-code", + state, + }, + }, + }); + }); + + test("rejects callbacks that are missing required Google Calendar scopes", async ({ + page, + }) => { + const state = "missing-scopes-state"; + const apiMocks = await prepareGoogleAuthCallbackPage(page); + + await writeGoogleAuthorizationIntent({ + intent: "signIn", + page, + returnPath: "/week", + state, + }); + + await page.goto(getCallbackUrl(state, REQUIRED_SCOPES[0] ?? "")); + + await expect(page).toHaveURL(/\/week$/); + await expect( + page.getByText( + "Missing Google Calendar permissions. Please grant all requested permissions.", + ), + ).toBeVisible(); + expect(apiMocks.loginOrSignup).toHaveLength(0); + expect(apiMocks.connectGoogle).toHaveLength(0); + }); + + test("rejects callbacks without a saved intent", async ({ page }) => { + const apiMocks = await prepareGoogleAuthCallbackPage(page); + + await page.goto(getCallbackUrl("unknown-state")); + + await expect(page).toHaveURL(/\/day(\/|$)/); + await expect( + page.getByText( + "Google authorization could not be completed. Please try again.", + ), + ).toBeVisible(); + expect(apiMocks.loginOrSignup).toHaveLength(0); + expect(apiMocks.connectGoogle).toHaveLength(0); + }); +}); diff --git a/e2e/oauth/oauth-overlay.spec.ts b/e2e/oauth/oauth-overlay.spec.ts deleted file mode 100644 index 0b58c6215..000000000 --- a/e2e/oauth/oauth-overlay.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { - expectBodyLocked, - expectNoOverlay, - expectOAuthOverlayVisible, - OVERLAY_SELECTORS, - OVERLAY_TEXT, - prepareOAuthTestPage, - setIsSyncing, - waitForAppReady, -} from "../utils/oauth-test-utils"; - -/** - * E2E tests for the OAuth sync overlay. - * - * These tests validate the SyncEventsOverlay component behavior during - * the Google OAuth flow by using test hooks to control session state. - * - * The overlay shows during OAuth popup phase: - * - OAuth phase: "Complete Google sign-in..." - when isSyncing=true - * - * Note: Calendar import now happens in the background with a sidebar spinner, - * not a blocking overlay. The import phase overlay was removed. - * - * NOTE: These tests are skipped on mobile because the MobileGate component - * blocks the entire app on mobile viewports, preventing the OAuth overlay - * from ever being rendered. This is intentional product behavior. - */ -test.describe("OAuth Overlay", () => { - // Skip on mobile - MobileGate blocks the app, so OAuth overlay never renders - test.skip( - ({ isMobile }) => isMobile, - "OAuth overlay not available on mobile", - ); - test.beforeEach(async ({ page }) => { - await prepareOAuthTestPage(page); - await page.goto("/week"); - await waitForAppReady(page); - }); - - test("renders overlay with OAuth phase message while user is authenticating", async ({ - page, - }) => { - // Initially no overlay should be visible - await expectNoOverlay(page); - - // Trigger OAuth phase (isSyncing=true, importing=false) - await setIsSyncing(page, true); - - // Verify OAuth overlay appears with correct content - await expectOAuthOverlayVisible(page); - - // Verify the specific text content - await expect(page.getByText(OVERLAY_TEXT.oauthTitle)).toBeVisible(); - await expect(page.getByText(OVERLAY_TEXT.oauthMessage)).toBeVisible(); - }); - - test("hides overlay after user completes OAuth prompt", async ({ page }) => { - // Start OAuth phase - await setIsSyncing(page, true); - await expectOAuthOverlayVisible(page); - - // Simulate OAuth completion - user accepted - // The overlay should disappear (import happens in background with sidebar spinner) - await setIsSyncing(page, false); - - // Verify overlay is gone - await expectNoOverlay(page); - }); - - test("locks the app (body data-app-locked) when overlay is active", async ({ - page, - }) => { - // Initially not locked - await expectBodyLocked(page, false); - - // Activate overlay - await setIsSyncing(page, true); - - // Body should be locked - await expectBodyLocked(page, true); - - // Deactivate overlay - await setIsSyncing(page, false); - - // Body should be unlocked - await expectBodyLocked(page, false); - }); - - test("blurs active element when overlay activates", async ({ page }) => { - // Wait for main grid to be visible and focusable - const mainGrid = page.locator("#mainGrid"); - await mainGrid.waitFor({ state: "visible", timeout: 10000 }); - await mainGrid.focus(); - - await page.waitForFunction( - () => document.activeElement?.tagName !== "BODY", - ); - - // Verify something is focused (not body) - const activeBeforeOverlay = await page.evaluate( - () => document.activeElement?.tagName, - ); - expect(activeBeforeOverlay).not.toBe("BODY"); - - // Activate overlay - await setIsSyncing(page, true); - await page.waitForFunction( - () => document.activeElement?.tagName === "BODY", - ); - - // Active element should be blurred (now body) - const activeAfterOverlay = await page.evaluate( - () => document.activeElement?.tagName, - ); - expect(activeAfterOverlay).toBe("BODY"); - }); - - test("overlay has correct ARIA attributes and shows spinner", async ({ - page, - }) => { - await setIsSyncing(page, true); - - const statusPanel = page.locator(OVERLAY_SELECTORS.statusPanel); - await expect(statusPanel).toBeVisible(); - await expect(statusPanel).toHaveAttribute("aria-busy", "true"); - await expect(statusPanel).toHaveAttribute("aria-live", "polite"); - await expect(page.locator(OVERLAY_SELECTORS.spinner)).toBeVisible(); - - await setIsSyncing(page, false); - await expect(page.locator(OVERLAY_SELECTORS.spinner)).not.toBeVisible(); - }); -}); - -test.describe("OAuth Overlay - Edge Cases", () => { - // Skip on mobile - MobileGate blocks the app, so OAuth overlay never renders - test.skip( - ({ isMobile }) => isMobile, - "OAuth overlay not available on mobile", - ); - - test.beforeEach(async ({ page }) => { - await prepareOAuthTestPage(page); - await page.goto("/week"); - await waitForAppReady(page); - }); - - test("handles rapid state changes without visual glitches", async ({ - page, - }) => { - // Rapidly toggle OAuth states - await setIsSyncing(page, true); - await setIsSyncing(page, false); - await setIsSyncing(page, true); - await setIsSyncing(page, false); - - // Should settle to no overlay - await expectNoOverlay(page); - }); -}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 6a1c47b5c..7adc4af00 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, diff --git a/e2e/utils/compass-window.ts b/e2e/utils/compass-window.ts index 03f6fed94..1ead47cf3 100644 --- a/e2e/utils/compass-window.ts +++ b/e2e/utils/compass-window.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +type CompassE2EState = { + userMetadata?: { + current?: { + google?: { + connectionState?: string; + }; + }; + status?: string; + }; +}; + /** * Zod schema for the Redux store subset exposed on window for e2e testing. * Validates that the store is available and has the expected dispatch/getState surface. @@ -43,7 +54,7 @@ declare global { __COMPASS_E2E_TEST__?: boolean; __COMPASS_E2E_STORE__?: { dispatch: (action: unknown) => unknown; - getState: () => unknown; + getState: () => CompassE2EState; }; __COMPASS_E2E_HOOKS__?: { setAuthenticated: (value: boolean) => void; diff --git a/e2e/utils/oauth-test-utils.ts b/e2e/utils/oauth-test-utils.ts index f91895010..ef334d5af 100644 --- a/e2e/utils/oauth-test-utils.ts +++ b/e2e/utils/oauth-test-utils.ts @@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test"; import "./compass-window"; /** - * Sets up the page for OAuth overlay testing. + * Sets up the page for OAuth connection-state testing. * - Exposes test hooks for session state manipulation * - Mocks API endpoints */ @@ -99,100 +99,6 @@ export const waitForAppReady = async (page: Page) => { ); }; -/** - * Set the authenticating state via Redux (triggers OAuth overlay when true). - * Dispatches to the store and waits for the UI to reflect the change. - * SessionProvider skips real session checks in e2e mode, so the store state - * is stable after dispatch — no retry loop needed. - */ -export const setIsSyncing = async (page: Page, value: boolean) => { - await page.evaluate((syncValue) => { - const store = window.__COMPASS_E2E_STORE__; - if (!store) return; - if (syncValue) { - store.dispatch({ type: "auth/startAuthenticating" }); - } else { - store.dispatch({ type: "auth/resetAuth" }); - } - }, value); - - if (value) { - await expect(page.locator(OVERLAY_SELECTORS.statusPanel)).toBeVisible(); - } else { - await expect(page.locator(OVERLAY_SELECTORS.statusPanel)).toHaveCount(0); - } -}; - -/** - * Selectors for OAuth overlay elements. - */ -export const OVERLAY_SELECTORS = { - /** The status overlay panel (specific to SyncEventsOverlay) */ - statusPanel: '[role="status"][aria-busy="true"][aria-live="polite"]', - /** Spinner element */ - spinner: ".animate-spin", -}; - -/** - * Text content for OAuth overlay phase. - * Note: Import phase overlay was removed - import now happens in background. - */ -export const OVERLAY_TEXT = { - oauthTitle: "Complete Google sign-in...", - oauthMessage: "Please complete authorization in the popup window", -}; - -/** - * Wait for body locked state to match expected value (with retry). - */ -export const expectBodyLocked = async (page: Page, locked: boolean) => { - if (locked) { - await expect(page.locator("body")).toHaveAttribute( - "data-app-locked", - "true", - ); - } else { - // When unlocked, the attribute is removed entirely - await expect(page.locator("body")).not.toHaveAttribute( - "data-app-locked", - "true", - ); - } -}; - -/** - * Wait for overlay phase to match expected value (with retry). - * Note: Import phase overlay was removed - import now happens in background. - */ -export const expectOverlayPhase = async ( - page: Page, - phase: "oauth" | "none", -) => { - if (phase === "oauth") { - await expect(page.getByText(OVERLAY_TEXT.oauthTitle)).toBeVisible(); - } else { - await expect(page.getByText(OVERLAY_TEXT.oauthTitle)).not.toBeVisible(); - } -}; - -/** - * Assert that the OAuth phase overlay is visible. - */ -export const expectOAuthOverlayVisible = async (page: Page) => { - await expect(page.getByText(OVERLAY_TEXT.oauthTitle)).toBeVisible(); - await expect(page.getByText(OVERLAY_TEXT.oauthMessage)).toBeVisible(); - await expect(page.locator(OVERLAY_SELECTORS.spinner)).toBeVisible(); - await expectBodyLocked(page, true); -}; - -/** - * Assert that no overlay is visible. - */ -export const expectNoOverlay = async (page: Page) => { - await expect(page.getByText(OVERLAY_TEXT.oauthTitle)).not.toBeVisible(); - await expectBodyLocked(page, false); -}; - /** * Google connection states that can be set via Redux. */ 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/__tests__/__mocks__/mock.setup.ts b/packages/web/src/__tests__/__mocks__/mock.setup.ts index 700d83c6e..aa1a4b249 100644 --- a/packages/web/src/__tests__/__mocks__/mock.setup.ts +++ b/packages/web/src/__tests__/__mocks__/mock.setup.ts @@ -90,13 +90,16 @@ export function mockLinuxUserAgent() { return uaSpy; } -export function mockUseGoogleLogin() { - mockModule("@web/auth/google/hooks/useGoogleLogin/useGoogleLogin", () => ({ - useGoogleLogin: mock(() => ({ - login: mock(), - loading: false, - })), - })); +export function mockUseStartGoogleAuthorization() { + mockModule( + "@web/auth/google/authorization/useStartGoogleAuthorization", + () => ({ + useStartGoogleAuthorization: mock(() => ({ + loading: false, + startGoogleAuthorization: mock(), + })), + }), + ); } export function mockSuperTokens() { @@ -175,7 +178,7 @@ function mockReactToastify() { } export function mockNodeModules() { - mockUseGoogleLogin(); + mockUseStartGoogleAuthorization(); mockSuperTokens(); mockReactToastify(); } diff --git a/packages/web/src/auth/google/authorization/complete-google-authorization.ts b/packages/web/src/auth/google/authorization/complete-google-authorization.ts new file mode 100644 index 000000000..d0cc99778 --- /dev/null +++ b/packages/web/src/auth/google/authorization/complete-google-authorization.ts @@ -0,0 +1,140 @@ +import { + type GoogleAuthCodeRequest, + GoogleConnectErrorResponseSchema, +} from "@core/types/auth.types"; +import { ROOT_ROUTES } from "@web/common/constants/routes"; +import { + GOOGLE_AUTH_SCOPES_REQUIRED, + GOOGLE_AUTHORIZATION_ERROR_MESSAGE, + MISSING_GOOGLE_SCOPES_ERROR_MESSAGE, +} from "./google-authorization.constants"; +import { + clearGoogleAuthorizationIntent, + readGoogleAuthorizationIntent, +} from "./google-authorization.storage"; +import { + buildGoogleAuthCallbackUrl, + buildGoogleAuthCodePayload, +} from "./google-authorization.util"; + +type CompleteAuthentication = (input: { + email?: string; + onComplete?: () => void; +}) => Promise; + +export type GoogleAuthorizationAuthAdapter = { + connectGoogle(data: GoogleAuthCodeRequest): Promise; + loginOrSignup(data: GoogleAuthCodeRequest): Promise<{ + user: { emails?: string[] }; + }>; +}; + +export type CompleteGoogleAuthorizationOptions = { + authApi: GoogleAuthorizationAuthAdapter; + completeAuthentication: CompleteAuthentication; + refreshUserMetadata: () => Promise | void; + requestEventFetch?: () => void; + search: string; +}; + +export type CompleteGoogleAuthorizationResult = + | { + returnPath: string; + status: "completed"; + } + | { + message: string; + returnPath: string; + status: "failed"; + }; + +const fail = ( + message = GOOGLE_AUTHORIZATION_ERROR_MESSAGE, + returnPath = ROOT_ROUTES.DAY, +): CompleteGoogleAuthorizationResult => ({ + message, + returnPath, + status: "failed", +}); + +const parseGoogleConnectErrorMessage = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null || !("response" in error)) { + return undefined; + } + + const data = (error as { response?: { data?: unknown } }).response?.data; + const parsed = GoogleConnectErrorResponseSchema.safeParse(data); + + return parsed.success ? parsed.data.message : undefined; +}; + +export async function completeGoogleAuthorization({ + authApi, + completeAuthentication, + refreshUserMetadata, + requestEventFetch, + search, +}: CompleteGoogleAuthorizationOptions): Promise { + const params = new URLSearchParams(search); + const state = params.get("state"); + + if (!state) { + return fail(); + } + + const savedIntent = readGoogleAuthorizationIntent(state); + clearGoogleAuthorizationIntent(state); + const returnPath = savedIntent?.returnPath ?? ROOT_ROUTES.DAY; + + if (!savedIntent || params.get("error")) { + return fail(GOOGLE_AUTHORIZATION_ERROR_MESSAGE, returnPath); + } + + const code = params.get("code"); + + if (!code) { + return fail(GOOGLE_AUTHORIZATION_ERROR_MESSAGE, returnPath); + } + + const grantedScopes = new Set((params.get("scope") ?? "").split(" ")); + const isMissingRequiredScope = GOOGLE_AUTH_SCOPES_REQUIRED.some( + (scope) => !grantedScopes.has(scope), + ); + + if (isMissingRequiredScope) { + return fail(MISSING_GOOGLE_SCOPES_ERROR_MESSAGE, returnPath); + } + + const payload = buildGoogleAuthCodePayload({ + code, + scope: params.get("scope") ?? undefined, + state, + redirectUri: buildGoogleAuthCallbackUrl(), + }); + + try { + if (savedIntent.intent === "signIn") { + const result = await authApi.loginOrSignup(payload); + await completeAuthentication({ + email: result.user.emails?.[0], + }); + } else { + await authApi.connectGoogle(payload); + await refreshUserMetadata(); + requestEventFetch?.(); + } + + return { + returnPath, + status: "completed", + }; + } catch (error) { + const parsedMessage = parseGoogleConnectErrorMessage(error); + + if (parsedMessage) { + return fail(parsedMessage, returnPath); + } + + return fail(GOOGLE_AUTHORIZATION_ERROR_MESSAGE, returnPath); + } +} diff --git a/packages/web/src/auth/google/authorization/google-authorization.constants.ts b/packages/web/src/auth/google/authorization/google-authorization.constants.ts new file mode 100644 index 000000000..ab6ea4c62 --- /dev/null +++ b/packages/web/src/auth/google/authorization/google-authorization.constants.ts @@ -0,0 +1,17 @@ +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", +]; + +export const GOOGLE_AUTHORIZATION_ERROR_MESSAGE = + "Google authorization could not be completed. Please try again."; +export const MISSING_GOOGLE_SCOPES_ERROR_MESSAGE = + "Missing Google Calendar permissions. Please grant all requested permissions."; diff --git a/packages/web/src/auth/google/authorization/google-authorization.storage.test.ts b/packages/web/src/auth/google/authorization/google-authorization.storage.test.ts new file mode 100644 index 000000000..daaa94d5f --- /dev/null +++ b/packages/web/src/auth/google/authorization/google-authorization.storage.test.ts @@ -0,0 +1,64 @@ +import { + clearGoogleAuthorizationIntent, + readGoogleAuthorizationIntent, + writeGoogleAuthorizationIntent, +} from "./google-authorization.storage"; +import { afterEach, describe, expect, it } from "bun:test"; + +describe("google-authorization.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("removes stored intents with invalid JSON", () => { + sessionStorage.setItem("compass.googleAuthorizationIntent.state-1", "{"); + + expect(readGoogleAuthorizationIntent("state-1")).toBeNull(); + }); + + it("removes stored intents with unsafe return paths", () => { + sessionStorage.setItem( + "compass.googleAuthorizationIntent.state-1", + JSON.stringify({ + intent: "signIn", + returnPath: "//evil.example", + createdAt: Date.now(), + }), + ); + + 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/authorization/google-authorization.storage.ts b/packages/web/src/auth/google/authorization/google-authorization.storage.ts new file mode 100644 index 000000000..948abcc9b --- /dev/null +++ b/packages/web/src/auth/google/authorization/google-authorization.storage.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { + GOOGLE_AUTH_INTENT_MAX_AGE_MS, + GOOGLE_AUTH_INTENT_STORAGE_PREFIX, +} from "./google-authorization.constants"; + +export const GoogleAuthorizationIntentSchema = z.object({ + intent: z.enum(["signIn", "connectCalendar"]), + returnPath: z + .string() + .startsWith("/") + .refine((path) => !path.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; + } + + let storedIntent: unknown; + + try { + storedIntent = JSON.parse(stored); + } catch { + sessionStorage.removeItem(key); + return null; + } + + const parsed = GoogleAuthorizationIntentSchema.safeParse(storedIntent); + + 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/authorization/google-authorization.test.ts b/packages/web/src/auth/google/authorization/google-authorization.test.ts new file mode 100644 index 000000000..263681f4e --- /dev/null +++ b/packages/web/src/auth/google/authorization/google-authorization.test.ts @@ -0,0 +1,114 @@ +import { completeGoogleAuthorization } from "./complete-google-authorization"; +import { GOOGLE_AUTH_SCOPES_REQUIRED } from "./google-authorization.constants"; +import { + readGoogleAuthorizationIntent, + writeGoogleAuthorizationIntent, +} from "./google-authorization.storage"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const makeDeps = () => ({ + authApi: { + loginOrSignup: mock(async () => ({ + createdNewRecipeUser: false, + status: "OK" as const, + user: { emails: ["user@example.com"] }, + })), + connectGoogle: mock(async () => ({ status: "OK" as const })), + }, + completeAuthentication: mock(async () => undefined), + refreshUserMetadata: mock(async () => undefined), + requestEventFetch: mock(() => undefined), +}); + +const callbackSearch = ( + state: string, + scope = GOOGLE_AUTH_SCOPES_REQUIRED.join(" "), +) => + `?state=${encodeURIComponent( + state, + )}&code=auth-code&scope=${encodeURIComponent(scope)}`; + +describe("completeGoogleAuthorization", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("completes a saved Google sign-in intent", async () => { + const deps = makeDeps(); + writeGoogleAuthorizationIntent("state-1", { + intent: "signIn", + returnPath: "/week", + createdAt: Date.now(), + }); + + await expect( + completeGoogleAuthorization({ + ...deps, + search: callbackSearch("state-1"), + }), + ).resolves.toEqual({ status: "completed", returnPath: "/week" }); + + expect(deps.authApi.loginOrSignup).toHaveBeenCalledWith( + expect.objectContaining({ + redirectURIInfo: expect.objectContaining({ + redirectURIQueryParams: expect.objectContaining({ + code: "auth-code", + state: "state-1", + }), + }), + }), + ); + expect(deps.completeAuthentication).toHaveBeenCalledWith({ + email: "user@example.com", + }); + expect(deps.authApi.connectGoogle).not.toHaveBeenCalled(); + expect(readGoogleAuthorizationIntent("state-1")).toBeNull(); + }); + + it("completes a saved Google Calendar connect intent", async () => { + const deps = makeDeps(); + writeGoogleAuthorizationIntent("state-2", { + intent: "connectCalendar", + returnPath: "/day", + createdAt: Date.now(), + }); + + await expect( + completeGoogleAuthorization({ + ...deps, + search: callbackSearch("state-2"), + }), + ).resolves.toEqual({ status: "completed", returnPath: "/day" }); + + expect(deps.authApi.connectGoogle).toHaveBeenCalledTimes(1); + expect(deps.refreshUserMetadata).toHaveBeenCalledTimes(1); + expect(deps.requestEventFetch).toHaveBeenCalledTimes(1); + expect(deps.completeAuthentication).not.toHaveBeenCalled(); + }); + + it("rejects callbacks that are missing required Google Calendar scopes", async () => { + const deps = makeDeps(); + writeGoogleAuthorizationIntent("state-3", { + intent: "signIn", + returnPath: "/week", + createdAt: Date.now(), + }); + + await expect( + completeGoogleAuthorization({ + ...deps, + search: callbackSearch("state-3", GOOGLE_AUTH_SCOPES_REQUIRED[0]), + }), + ).resolves.toEqual({ + status: "failed", + message: + "Missing Google Calendar permissions. Please grant all requested permissions.", + returnPath: "/week", + }); + + expect(deps.authApi.loginOrSignup).not.toHaveBeenCalled(); + expect(deps.authApi.connectGoogle).not.toHaveBeenCalled(); + expect(deps.completeAuthentication).not.toHaveBeenCalled(); + expect(readGoogleAuthorizationIntent("state-3")).toBeNull(); + }); +}); diff --git a/packages/web/src/auth/google/authorization/google-authorization.util.test.ts b/packages/web/src/auth/google/authorization/google-authorization.util.test.ts new file mode 100644 index 000000000..183b65d4a --- /dev/null +++ b/packages/web/src/auth/google/authorization/google-authorization.util.test.ts @@ -0,0 +1,55 @@ +import { + buildGoogleAuthCallbackUrl, + buildGoogleAuthCodePayload, + getSafeGoogleAuthReturnPath, +} from "./google-authorization.util"; +import { describe, expect, it } from "bun:test"; + +describe("google-authorization.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/authorization/google-authorization.util.ts b/packages/web/src/auth/google/authorization/google-authorization.util.ts new file mode 100644 index 000000000..025cb4486 --- /dev/null +++ b/packages/web/src/auth/google/authorization/google-authorization.util.ts @@ -0,0 +1,48 @@ +import { type GoogleAuthCodeRequest } from "@core/types/auth.types"; +import { GOOGLE_AUTH_CALLBACK_PATH } from "./google-authorization.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/authorization/useStartGoogleAuthorization.ts b/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.ts new file mode 100644 index 000000000..bb5e9b800 --- /dev/null +++ b/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.ts @@ -0,0 +1,70 @@ +import { + type UseGoogleLoginOptionsAuthCodeFlow, + useGoogleLogin as useGoogleLoginBase, +} from "@react-oauth/google"; +import { useCallback, useMemo, useState } from "react"; +import { GOOGLE_AUTH_SCOPES_REQUIRED } from "./google-authorization.constants"; +import { + type GoogleAuthorizationIntent, + writeGoogleAuthorizationIntent, +} from "./google-authorization.storage"; +import { + buildGoogleAuthCallbackUrl, + getSafeGoogleAuthReturnPath, +} from "./google-authorization.util"; + +export const useStartGoogleAuthorization = ({ + intent, + onStart, + onError, + prompt, +}: { + intent: GoogleAuthorizationIntent["intent"]; + onStart?: () => void; + onError?: (error: unknown) => void; + prompt?: "consent" | "none" | "select_account"; +}) => { + const [loading, setLoading] = useState(false); + const [state] = useState(() => crypto.randomUUID()); + const [redirectUri] = useState(buildGoogleAuthCallbackUrl); + + const loginOptions = useMemo< + UseGoogleLoginOptionsAuthCodeFlow & { + prompt?: "consent" | "none" | "select_account"; + } + >( + () => ({ + flow: "auth-code", + scope: GOOGLE_AUTH_SCOPES_REQUIRED.join(" "), + prompt, + state, + ux_mode: "redirect", + redirect_uri: redirectUri, + onNonOAuthError(error) { + setLoading(false); + onError?.(error); + }, + onError(error) { + setLoading(false); + onError?.(error); + }, + }), + [onError, prompt, redirectUri, state], + ); + + const startGoogleAuthorization = useGoogleLoginBase(loginOptions); + + return { + loading, + startGoogleAuthorization: useCallback(() => { + onStart?.(); + setLoading(true); + writeGoogleAuthorizationIntent(state, { + intent, + returnPath: getSafeGoogleAuthReturnPath(), + createdAt: Date.now(), + }); + return startGoogleAuthorization(); + }, [intent, onStart, startGoogleAuthorization, state]), + }; +}; diff --git a/packages/web/src/auth/google/hooks/googe.auth.types.ts b/packages/web/src/auth/google/hooks/googe.auth.types.ts deleted file mode 100644 index 91e6bbeef..000000000 --- a/packages/web/src/auth/google/hooks/googe.auth.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type CodeResponse } from "@react-oauth/google"; - -export interface GoogleAuthConfig { - thirdPartyId: string; - clientType: "web"; - shouldTryLinkingWithSessionUser?: boolean; - redirectURIInfo: { - redirectURIOnProviderDashboard: string; - redirectURIQueryParams: Omit< - CodeResponse, - "error" | "error_description" | "error_uri" - >; - pkceCodeVerifier?: string; - }; -} diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index 5839d27a3..818a44652 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 { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization"; 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,42 +47,25 @@ 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 { startGoogleAuthorization } = useStartGoogleAuthorization({ + 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 startGoogleAuthorization(); + }; - const onOpenGoogleAuth = useCallback(() => { - void login(); - dispatch(settingsSlice.actions.closeCmdPalette()); - }, [dispatch, login]); + void start(); + }, [dispatch, startGoogleAuthorization]); const onRepairGoogle = useCallback(() => { const startRepair = async () => { @@ -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/useConnectGoogle/useConnectGoogle.util.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts index a836e781a..dc14d2e25 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts @@ -1,4 +1,3 @@ -import { type GoogleAuthCodeRequest } from "@core/types/auth.types"; import { type CommandActionIcon, type GoogleUiConfig, @@ -8,14 +7,6 @@ import { const COMMAND_ICON: CommandActionIcon = "CloudArrowUpIcon"; type RepairDialog = NonNullable; -export const buildGoogleConnectRequest = ( - redirectURIInfo: GoogleAuthCodeRequest["redirectURIInfo"], -): GoogleAuthCodeRequest => ({ - thirdPartyId: "google", - clientType: "web", - redirectURIInfo, -}); - const buildRepairDialog = (onRepairGoogle: () => void): RepairDialog => ({ title: "Calendar sync needs repair", description: 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 deleted file mode 100644 index 42188d4ba..000000000 --- a/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - type UseGoogleLoginOptionsAuthCodeFlow, - 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)); -}; - -export const useGoogleLogin = ({ - onStart, - onSuccess, - onError, - prompt, - shouldTryLinkingWithSessionUser, -}: { - 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 loginOptions: UseGoogleLoginOptionsAuthCodeFlow & { - prompt?: "consent" | "none" | "select_account"; - } = { - flow: "auth-code", - scope: SCOPES_REQUIRED.join(" "), - prompt, - state: antiCsrfToken, - onNonOAuthError(nonOAuthError) { - 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) => { - setLoading(false); - - if (isGooglePopupClosedError(error)) { - onError?.(error); - return; - } - - alert(`Login failed because: ${error.error}`); - console.error(error); - onError?.(error); - }, - }; - - const login = useGoogleLoginBase(loginOptions); - - return { - login: useCallback(() => { - onStart?.(); - setData(null); - setLoading(true); - - return login(); - }, [login, onStart]), - data, - loading, - }; -}; diff --git a/packages/web/src/auth/google/util/google.auth.util.test.ts b/packages/web/src/auth/google/util/google.auth.util.test.ts index 95c7604c4..9316c8bca 100644 --- a/packages/web/src/auth/google/util/google.auth.util.test.ts +++ b/packages/web/src/auth/google/util/google.auth.util.test.ts @@ -2,7 +2,6 @@ import { clearGoogleRevokedState, isGoogleRevoked, } from "@web/auth/google/state/google.auth.state"; -import { type GoogleAuthConfig } from "../hooks/googe.auth.types"; import { afterAll, afterEach, @@ -54,12 +53,8 @@ mock.module("@web/store", () => ({ mock.module("@web/sse/client/sse.client", () => mockSse); // Import the module under test after mocking -const { - authenticate, - handleGoogleRevoked, - syncLocalEvents, - syncPendingLocalEvents, -} = require("./google.auth.util") as typeof import("./google.auth.util"); +const { handleGoogleRevoked, syncLocalEvents, syncPendingLocalEvents } = + require("./google.auth.util") as typeof import("./google.auth.util"); describe("google-auth.util", () => { beforeEach(() => { @@ -80,46 +75,6 @@ describe("google-auth.util", () => { clearGoogleRevokedState(); }); - describe("authenticate", () => { - const mockGoogleAuthConfig: GoogleAuthConfig = { - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "http://localhost", - redirectURIQueryParams: { - code: "test-code", - scope: "email profile", - state: "test-state", - }, - }, - }; - - it("returns success when authentication succeeds", async () => { - mockAuthApi.loginOrSignup.mockResolvedValue({ - createdNewRecipeUser: false, - status: "OK", - user: { - id: "user-id", - isPrimaryUser: false, - emails: ["test@example.com"], - tenantIds: ["public"], - phoneNumbers: [], - thirdParty: [{ id: "google", userId: "google-user-id" }], - webauthn: { credentialIds: [] }, - loginMethods: [], - timeJoined: Date.now(), - toJson: mock(), - }, - }); - - await authenticate(mockGoogleAuthConfig); - }); - - it("returns error when authentication fails", async () => { - expect(mockAuthApi.loginOrSignup).toBeDefined(); - }); - }); - describe("syncLocalEvents", () => { it("returns syncedCount and success when sync succeeds", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(5); diff --git a/packages/web/src/auth/google/util/google.auth.util.ts b/packages/web/src/auth/google/util/google.auth.util.ts index d1b161ba5..91b0b13cf 100644 --- a/packages/web/src/auth/google/util/google.auth.util.ts +++ b/packages/web/src/auth/google/util/google.auth.util.ts @@ -1,8 +1,6 @@ import { toast } from "react-toastify"; import { Origin } from "@core/constants/core.constants"; -import { type Result_Auth_Compass } from "@core/types/auth.types"; import { markGoogleAsRevoked } from "@web/auth/google/state/google.auth.state"; -import { AuthApi } from "@web/common/apis/auth.api"; import { GOOGLE_REVOKED_TOAST_ID, toastDefaultOptions, @@ -15,13 +13,6 @@ import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; import { closeStream, openStream } from "@web/sse/client/sse.client"; import { store } from "@web/store"; -import { type GoogleAuthConfig } from "../hooks/googe.auth.types"; - -export interface AuthenticateResult { - success: boolean; - data?: Result_Auth_Compass; - error?: Error; -} export interface SyncLocalEventsResult { syncedCount: number; @@ -32,20 +23,6 @@ export interface SyncLocalEventsResult { export const LOCAL_EVENTS_SYNC_ERROR_MESSAGE = "We could not sync your local events. Your changes are still saved on this device."; -/** - * Authenticate with Google using the provided credentials. - */ -export async function authenticate( - data: GoogleAuthConfig, -): Promise { - try { - const response = await AuthApi.loginOrSignup(data); - return { success: true, data: response }; - } catch (error) { - return { success: false, error: error as Error }; - } -} - /** Idempotent handler for Google access revocation. Safe to call from both API interceptor and socket handler. */ export const handleGoogleRevoked = () => { if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { 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..bd4d5a9bb 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -32,13 +32,16 @@ mock.module("@web/auth/compass/session/useSession", () => ({ useSession: () => mockUseSession(), })); -// Mock useGoogleAuth +// Mock Google authorization start hook const mockGoogleLogin = mock(); -mock.module("@web/auth/google/hooks/useGoogleAuth/useGoogleAuth", () => ({ - useGoogleAuth: () => ({ - login: mockGoogleLogin, +mock.module( + "@web/auth/google/authorization/useStartGoogleAuthorization", + () => ({ + useStartGoogleAuthorization: () => ({ + startGoogleAuthorization: mockGoogleLogin, + }), }), -})); +); const mockUseIsGoogleAvailable = mock(() => true); mock.module( diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index bd4b41452..832659cfb 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 { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization"; 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,11 +45,21 @@ function getInitialAuthToken(): string | undefined { export const AuthModal: FC = () => { const { isOpen, currentView, openModal, closeModal, setView } = useAuthModal(); - const googleAuth = useGoogleAuth(); + const handleGoogleAuthStart = useCallback(() => { + dismissErrorToast(SESSION_EXPIRED_TOAST_ID); + }, []); + const googleAuth = useStartGoogleAuthorization({ + intent: "signIn", + onStart: handleGoogleAuthStart, + }); + const { + loading: isGoogleAuthLoading, + startGoogleAuthorization: startGoogleSignIn, + } = googleAuth; const isGoogleAvailable = useIsGoogleAvailable(); const isLoginView = currentView === "login" || currentView === "loginAfterReset"; - const authToken = useRef(getInitialAuthToken()).current; + const [authToken] = useState(getInitialAuthToken); const { isSubmitting, submitError, @@ -78,9 +92,9 @@ export const AuthModal: FC = () => { ); const handleGoogleSignIn = useCallback(() => { - void googleAuth.login(); + void startGoogleSignIn(); closeModal(); - }, [googleAuth, closeModal]); + }, [closeModal, startGoogleSignIn]); const navigateToForgotPassword = useCallback(() => { setView("forgotPassword"); @@ -184,6 +198,7 @@ export const AuthModal: FC = () => { diff --git a/packages/web/src/components/AuthModal/components/GoogleButton.tsx b/packages/web/src/components/AuthModal/components/GoogleButton.tsx index b8850916a..552935fc5 100644 --- a/packages/web/src/components/AuthModal/components/GoogleButton.tsx +++ b/packages/web/src/components/AuthModal/components/GoogleButton.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import type React from "react"; /** @@ -49,35 +50,16 @@ export const GoogleButton = ({ onClick={onClick} disabled={disabled} aria-label={label} + className={clsx( + "inline-flex h-10 items-center justify-center gap-2.5 whitespace-nowrap rounded-full border border-[#1f1f1f] bg-white px-3 font-medium text-[#1f1f1f] text-sm transition-colors duration-200", + disabled + ? "cursor-not-allowed opacity-60" + : "cursor-pointer hover:bg-[#f8f8f8]", + )} style={{ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - gap: "10px", // Google guideline: 10px between icon and text - height: "40px", - paddingLeft: "12px", // Google guideline: 12px left padding - paddingRight: "12px", // Google guideline: 12px right padding - backgroundColor: "#ffffff", - border: "1px solid #1f1f1f", - borderRadius: "9999px", // Pill shape - cursor: disabled ? "not-allowed" : "pointer", - opacity: disabled ? 0.6 : 1, fontFamily: "'Roboto', sans-serif", - fontSize: "14px", - fontWeight: 500, - color: "#1f1f1f", - whiteSpace: "nowrap", - transition: "background-color 0.2s ease", ...style, }} - onMouseEnter={(e) => { - if (!disabled) { - e.currentTarget.style.backgroundColor = "#f8f8f8"; - } - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "#ffffff"; - }} > {label} 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 ( -