diff --git a/e2e/oauth/sidebar-connection-status.spec.ts b/e2e/oauth/sidebar-connection-status.spec.ts index d46a0c53b..f8fbfa2e1 100644 --- a/e2e/oauth/sidebar-connection-status.spec.ts +++ b/e2e/oauth/sidebar-connection-status.spec.ts @@ -1,7 +1,8 @@ -import { expect, test } from "@playwright/test"; +import { type Page, expect, test } from "@playwright/test"; import { type GoogleConnectionState, SIDEBAR_STATUS_LABELS, + expectGoogleConnectionStateInStore, markUserAsAuthenticated, prepareOAuthTestPage, setGoogleConnectionState, @@ -9,21 +10,11 @@ import { } from "../utils/oauth-test-utils"; /** - * E2E tests for sidebar Google Calendar connection status. + * E2E tests for Google Calendar connection state (Redux + header status when visible). * - * These tests verify that the sidebar status container correctly reflects the 5 connection - * states from the server (GoogleConnectionState), plus the client-only "checking" - * state that appears while metadata is loading. - * - * Connection states and their status messages: - * - NOT_CONNECTED: "Google Calendar not connected. Click to connect." - * - RECONNECT_REQUIRED: "Google Calendar needs reconnecting. Click to reconnect." - * - IMPORTING: "Google Calendar is syncing in the background." - * - HEALTHY: "Google Calendar connected." - * - ATTENTION: "Google Calendar needs repair. Click to repair." - * - "checking" (client-only): "Checking Google Calendar status…" - * - * The status is rendered in SidebarIconRow with role="status" and managed by useConnectGoogle hook. + * HeaderInfoIcon only renders role="status" for warning/error states (reconnect required, + * needs repair). Other states are reflected in Redux only; command palette still exposes + * connect/repair actions. * * NOTE: These tests are skipped on mobile because the MobileGate component * blocks the entire app on mobile viewports. @@ -35,10 +26,8 @@ test.describe("Sidebar Connection Status", () => { // Run tests serially to avoid state interference test.describe.configure({ mode: "serial" }); - // Helper to get the sidebar status container - // Filter: has aria-label (excludes DndLiveRegion), no aria-busy (excludes overlay) - const getSidebarStatus = (page: import("@playwright/test").Page) => - page.locator('#sidebar [role="status"][aria-label]:not([aria-busy])'); + const getHeaderGoogleStatus = (page: Page) => + page.locator("#cal").getByRole("status", { name: /Google Calendar/i }); test.beforeEach(async ({ page }) => { await prepareOAuthTestPage(page); @@ -50,35 +39,24 @@ test.describe("Sidebar Connection Status", () => { await setGoogleConnectionState(page, "NOT_CONNECTED"); }); - test("shows NOT_CONNECTED status", async ({ page }) => { - // State already set in beforeEach - just verify it - - const status = getSidebarStatus(page); - await expect(status).toHaveAttribute( - "aria-label", - SIDEBAR_STATUS_LABELS.notConnected, - ); + test("stores NOT_CONNECTED in Redux (header icon hidden for muted state)", async ({ + page, + }) => { + await expectGoogleConnectionStateInStore(page, "NOT_CONNECTED"); }); - test("shows checking status when metadata is loading", async ({ page }) => { - // The "checking" state requires: - // 1. userMetadataStatus === "loading" - // 2. hasUserEverAuthenticated() returns true (localStorage flag) - // - // Set the localStorage flag first, then force loading state. + test("checking path: authenticated user with metadata loading", async ({ + page, + }) => { await markUserAsAuthenticated(page); - // Force the loading state by dispatching both clear and setLoading await page.evaluate(() => { const store = window.__COMPASS_E2E_STORE__; if (!store) return; - // Clear any existing metadata store.dispatch({ type: "userMetadata/clear" }); - // Set to loading state store.dispatch({ type: "userMetadata/setLoading" }); }); - // Wait for state to be in loading await page.waitForFunction( () => window.__COMPASS_E2E_STORE__?.getState()?.userMetadata?.status === @@ -86,12 +64,8 @@ test.describe("Sidebar Connection Status", () => { { timeout: 5000 }, ); - // Wait for status container to show "checking" state via aria-label - const status = getSidebarStatus(page); - await expect(status).toHaveAttribute( - "aria-label", - /Checking Google Calendar/i, - ); + // "Checking" does not render HeaderInfoIcon (muted / no warning-error icon). + await expect(getHeaderGoogleStatus(page)).toHaveCount(0); }); test("shows IMPORTING status", async ({ page }) => { @@ -118,11 +92,6 @@ test.describe("Sidebar Connection Status - State Transitions", () => { // Run tests serially to avoid state interference test.describe.configure({ mode: "serial" }); - // Helper to get the sidebar status container - // Filter: has aria-label (excludes DndLiveRegion), no aria-busy (excludes overlay) - const getSidebarStatus = (page: import("@playwright/test").Page) => - page.locator('#sidebar [role="status"][aria-label]:not([aria-busy])'); - test.beforeEach(async ({ page }) => { await prepareOAuthTestPage(page); await page.goto("/week"); @@ -133,23 +102,20 @@ test.describe("Sidebar Connection Status - State Transitions", () => { await setGoogleConnectionState(page, "NOT_CONNECTED"); }); - test("transitions from NOT_CONNECTED to HEALTHY correctly", async ({ + test("transitions from RECONNECT_REQUIRED to ATTENTION correctly", async ({ page, }) => { - const status = getSidebarStatus(page); - - // State already set to NOT_CONNECTED in beforeEach - just verify it - await expect(status).toHaveAttribute( - "aria-label", - SIDEBAR_STATUS_LABELS.notConnected, - ); + await setGoogleConnectionState(page, "RECONNECT_REQUIRED"); + await expect( + page.getByRole("status", { + name: SIDEBAR_STATUS_LABELS.reconnectRequired, + }), + ).toBeVisible(); - // Transition to HEALTHY (simulating successful OAuth flow) - await setGoogleConnectionState(page, "HEALTHY"); - await expect(status).toHaveAttribute( - "aria-label", - SIDEBAR_STATUS_LABELS.connected, - ); + await setGoogleConnectionState(page, "ATTENTION"); + await expect( + page.getByRole("status", { name: SIDEBAR_STATUS_LABELS.needsRepair }), + ).toBeVisible(); }); test("cycles through connection states without visual glitches", async ({ @@ -165,14 +131,12 @@ test.describe("Sidebar Connection Status - State Transitions", () => { for (const state of states) { await setGoogleConnectionState(page, state); - // setGoogleConnectionState now waits for aria-label, no timeout needed } - // Should end on RECONNECT_REQUIRED - const status = getSidebarStatus(page); - await expect(status).toHaveAttribute( - "aria-label", - SIDEBAR_STATUS_LABELS.reconnectRequired, - ); + await expect( + page.getByRole("status", { + name: SIDEBAR_STATUS_LABELS.reconnectRequired, + }), + ).toBeVisible(); }); }); diff --git a/e2e/utils/oauth-test-utils.ts b/e2e/utils/oauth-test-utils.ts index 6cd0be68d..edd055e20 100644 --- a/e2e/utils/oauth-test-utils.ts +++ b/e2e/utils/oauth-test-utils.ts @@ -1,4 +1,4 @@ -import { Page, expect } from "@playwright/test"; +import { type Page, expect } from "@playwright/test"; import "./compass-window"; /** @@ -200,13 +200,20 @@ const CONNECTION_STATE_TO_LABEL: Record = { ATTENTION: /needs repair/i, }; -const SIDEBAR_STATUS_SELECTOR = - "#sidebar [role='status'][aria-label]:not([aria-busy])"; +/** + * HeaderInfoIcon only renders role="status" for warning/error connection states + * (see HeaderInfoIcon.tsx). Muted / checking / importing-without-background-import + * do not expose this region. + */ +const GOOGLE_HEADER_STATUS_VISIBLE_STATES: GoogleConnectionState[] = [ + "RECONNECT_REQUIRED", + "ATTENTION", +]; /** * Set the Google connection state via Redux userMetadata slice. - * Dispatches to the store and waits for the sidebar aria-label to update. - * waitForAppReady in beforeEach guarantees the store is ready before this runs. + * Dispatches to the store, waits for Redux to match, then asserts the header + * status region when the UI shows it (reconnect required / needs repair). */ export const setGoogleConnectionState = async ( page: Page, @@ -221,9 +228,37 @@ export const setGoogleConnectionState = async ( }); }, state); - await expect(page.locator(SIDEBAR_STATUS_SELECTOR)).toHaveAttribute( - "aria-label", - CONNECTION_STATE_TO_LABEL[state], + await page.waitForFunction( + (expected) => { + const cs = + window.__COMPASS_E2E_STORE__?.getState()?.userMetadata?.current?.google + ?.connectionState; + return cs === expected; + }, + state, + { timeout: 5000 }, + ); + + if (GOOGLE_HEADER_STATUS_VISIBLE_STATES.includes(state)) { + await expect( + page.getByRole("status", { name: CONNECTION_STATE_TO_LABEL[state] }), + ).toBeVisible(); + } +}; + +export const expectGoogleConnectionStateInStore = async ( + page: Page, + state: GoogleConnectionState, +) => { + await page.waitForFunction( + (expected) => { + const cs = + window.__COMPASS_E2E_STORE__?.getState()?.userMetadata?.current?.google + ?.connectionState; + return cs === expected; + }, + state, + { timeout: 5000 }, ); }; diff --git a/eslint.config.mjs b/eslint.config.mjs index c6881b9bd..628e3b637 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -111,6 +111,15 @@ export default [ "jest/unbound-method": "error", }, }, + { + files: ["e2e/**/*.ts"], + rules: { + // Playwright e2e is not React Testing Library; page.getByRole is correct. + "testing-library/prefer-screen-queries": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + }, + }, // Warn on console.log in packages/web to avoid leaking secure info { files: ["packages/web/**/*.{ts,tsx}"], diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index cd0d1a069..f8aa389c2 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -42,9 +42,10 @@ describe("SyncController", () => { const baseDriver = new BaseDriver(); const syncDriver = new SyncControllerDriver(baseDriver); const importTimeoutMs = 7_000; + type ImportOperation = "INCREMENTAL" | "REPAIR"; interface ImportSummary { - operation: "REPAIR"; + operation: ImportOperation; status: "COMPLETED"; eventsCount: number; calendarsCount: number; @@ -52,10 +53,11 @@ describe("SyncController", () => { function parseImportResult( result: ImportGCalEndPayload | undefined, + operation: ImportOperation = "INCREMENTAL", ): ImportSummary { expect(result).toEqual( expect.objectContaining({ - operation: "REPAIR", + operation, status: "COMPLETED", eventsCount: expect.any(Number) as number, calendarsCount: expect.any(Number) as number, @@ -607,7 +609,7 @@ describe("SyncController", () => { const result = (await importEndPromise) as ImportGCalEndPayload; stream.close(); - const parsed = parseImportResult(result); + const parsed = parseImportResult(result, "REPAIR"); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -643,7 +645,7 @@ describe("SyncController", () => { stream.close(); expect(failReason).toEqual({ - operation: "REPAIR", + operation: "INCREMENTAL", status: "IGNORED", message: `User ${userId} gcal import is in progress or completed, ignoring this request`, }); @@ -683,7 +685,7 @@ describe("SyncController", () => { stream.close(); expect(failReason).toEqual({ - operation: "REPAIR", + operation: "INCREMENTAL", status: "IGNORED", message: `User ${userId} gcal import is in progress or completed, ignoring this request`, }); diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts index abf005719..a43ac30fb 100644 --- a/packages/backend/src/sync/services/sync.service.test.ts +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -447,6 +447,7 @@ describe("SyncService", () => { it("restarts the import workflow and completes successfully", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); await userMetadataService.updateUserMetadata({ userId, @@ -462,11 +463,19 @@ describe("SyncService", () => { calendarService.getByUser.bind(calendarService); const calendars = await listCalendarsForUser(userId); expect(calendars.length).toBeGreaterThan(0); + expect(importEndSpy).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + operation: "INCREMENTAL", + status: "COMPLETED", + }), + ); }); it("skips restart when import is completed and not forced", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); await userMetadataService.updateUserMetadata({ userId, @@ -483,6 +492,11 @@ describe("SyncService", () => { const metadata = await userMetadataService.fetchUserMetadata(userId); expect(metadata.sync?.importGCal).toBe("COMPLETED"); + expect(importEndSpy).toHaveBeenCalledWith(userId, { + operation: "INCREMENTAL", + status: "IGNORED", + message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + }); stopSpy.mockRestore(); startSpy.mockRestore(); @@ -516,6 +530,64 @@ describe("SyncService", () => { startSpy.mockRestore(); }); + it("ignores a duplicate restart while the first full sync is still starting", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); + let resolveFetchMetadata: (() => void) | undefined; + const fetchMetadataDeferred = new Promise((resolve) => { + resolveFetchMetadata = resolve; + }); + let fetchMetadataCallCount = 0; + + await userMetadataService.updateUserMetadata({ + userId, + data: { sync: { importGCal: "RESTART" } }, + }); + + const fetchMetadataSpy = jest + .spyOn(userService, "fetchUserMetadata") + .mockImplementation(async (targetUserId) => { + fetchMetadataCallCount += 1; + + if (fetchMetadataCallCount === 1) { + await fetchMetadataDeferred; + } + + return userMetadataService.fetchUserMetadata(targetUserId); + }); + const startSpy = jest + .spyOn(syncService, "startGoogleCalendarSync") + .mockResolvedValue({ eventsCount: 0, calendarsCount: 0 }); + const stopSpy = jest + .spyOn(userService, "stopGoogleCalendarSync") + .mockResolvedValue(); + + const firstRestart = syncService.restartGoogleCalendarSync(userId, { + force: true, + }); + await Promise.resolve(); + + const secondRestart = syncService.restartGoogleCalendarSync(userId, { + force: true, + }); + + resolveFetchMetadata?.(); + + await Promise.all([firstRestart, secondRestart]); + + expect(startSpy).toHaveBeenCalledTimes(1); + expect(importEndSpy).toHaveBeenCalledWith(userId, { + operation: "REPAIR", + status: "IGNORED", + message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + }); + + fetchMetadataSpy.mockRestore(); + startSpy.mockRestore(); + stopSpy.mockRestore(); + }); + it("cleans up partial watch state when restart fails", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index b0f5a313c..ede52fee0 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -59,6 +59,8 @@ import userMetadataService from "@backend/user/services/user-metadata.service"; const logger = Logger("app:sync.service"); class SyncService { + private activeFullSyncRestarts = new Set(); + deleteAllByGcalId = async (gCalendarId: string, session?: ClientSession) => { const delRes = await mongoService.sync.deleteMany( { "google.events.gCalendarId": gCalendarId }, @@ -530,6 +532,19 @@ class SyncService { const { default: userService } = await import("@backend/user/services/user.service"); const isForce = options.force === true; + const operation = isForce ? "REPAIR" : "INCREMENTAL"; + const ignoreMessage = `User ${userId} gcal import is in progress or completed, ignoring this request`; + + if (this.activeFullSyncRestarts.has(userId)) { + sseServer.handleImportGCalEnd(userId, { + operation, + status: "IGNORED", + message: ignoreMessage, + }); + return; + } + + this.activeFullSyncRestarts.add(userId); try { const userMeta = await userService.fetchUserMetadata(userId); @@ -539,9 +554,9 @@ class SyncService { if (!proceed) { sseServer.handleImportGCalEnd(userId, { - operation: "REPAIR", + operation, status: "IGNORED", - message: `User ${userId} gcal import is in progress or completed, ignoring this request`, + message: ignoreMessage, }); return; @@ -572,7 +587,7 @@ class SyncService { }); sseServer.handleImportGCalEnd(userId, { - operation: "REPAIR", + operation, status: "COMPLETED", ...importResults, }); @@ -605,10 +620,12 @@ class SyncService { logger.error(`Re-sync failed for user: ${userId}`, err); sseServer.handleImportGCalEnd(userId, { - operation: "REPAIR", + operation, status: "ERRORED", message: getGoogleRepairErrorMessage(err), }); + } finally { + this.activeFullSyncRestarts.delete(userId); } }; diff --git a/packages/backend/src/user/services/user-metadata.service.test.ts b/packages/backend/src/user/services/user-metadata.service.test.ts index ddb85f23e..bd5b08bb5 100644 --- a/packages/backend/src/user/services/user-metadata.service.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.test.ts @@ -150,5 +150,19 @@ describe("UserMetadataService", () => { restartSpy.mockRestore(); }); + + it("returns ATTENTION when a restart is pending", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + + await driver.updateUserMetadata({ + userId, + data: { sync: { importGCal: "RESTART" } }, + }); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.google?.connectionState).toBe("ATTENTION"); + }); }); }); diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index fafcebafa..ef915f960 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -94,10 +94,14 @@ class UserMetadataService { } const importStatus = storedMetadata.sync?.importGCal; - if (importStatus === "IMPORTING" || importStatus === "RESTART") { + if (importStatus === "IMPORTING") { return { connectionState: "IMPORTING" }; } + if (importStatus === "RESTART") { + return { connectionState: "ATTENTION" }; + } + const isHealthy = await this.isGoogleSyncHealthy(userId); if (isHealthy) { return { connectionState: "HEALTHY" }; diff --git a/packages/web/src/__tests__/__mocks__/mock.setup.ts b/packages/web/src/__tests__/__mocks__/mock.setup.ts index ef2c5c059..bb3c8a68b 100644 --- a/packages/web/src/__tests__/__mocks__/mock.setup.ts +++ b/packages/web/src/__tests__/__mocks__/mock.setup.ts @@ -57,7 +57,7 @@ export function mockLinuxUserAgent() { } export function mockUseGoogleLogin() { - mockModule("@web/auth/hooks/google/useGoogleLogin/useGoogleLogin", () => ({ + mockModule("@web/auth/google/hooks/useGoogleLogin/useGoogleLogin", () => ({ useGoogleLogin: jest.fn().mockReturnValue({ login: jest.fn(), loading: false, diff --git a/packages/web/src/__tests__/__mocks__/state/state.weekEvents.ts b/packages/web/src/__tests__/__mocks__/state/state.weekEvents.ts index 514c90286..f20086c15 100644 --- a/packages/web/src/__tests__/__mocks__/state/state.weekEvents.ts +++ b/packages/web/src/__tests__/__mocks__/state/state.weekEvents.ts @@ -84,12 +84,6 @@ export const preloadedState: InitialReduxState = { status: "idle", }, sync: { - importGCal: { - isRepairing: false, - importResults: null, - pendingLocalEventsSynced: null, - importError: null, - }, importLatest: { isFetchNeeded: false, reason: null, diff --git a/packages/web/src/__tests__/utils/state/store.test.util.ts b/packages/web/src/__tests__/utils/state/store.test.util.ts index 6c6a12488..c92260dbd 100644 --- a/packages/web/src/__tests__/utils/state/store.test.util.ts +++ b/packages/web/src/__tests__/utils/state/store.test.util.ts @@ -122,12 +122,6 @@ export const createInitialState = ( status: "idle", }, sync: { - importGCal: { - isRepairing: false, - importResults: null, - pendingLocalEventsSynced: null, - importError: null, - }, importLatest: { isFetchNeeded: false, reason: null, diff --git a/packages/web/src/auth/hooks/compass/useCompleteAuthentication.test.ts b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts similarity index 65% rename from packages/web/src/auth/hooks/compass/useCompleteAuthentication.test.ts rename to packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts index 942b70bf3..c0336355f 100644 --- a/packages/web/src/auth/hooks/compass/useCompleteAuthentication.test.ts +++ b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.test.ts @@ -1,26 +1,29 @@ import { renderHook } from "@testing-library/react"; -import type * as GoogleAuthUtil from "@web/auth/google/google.auth.util"; -import { syncPendingLocalEvents } from "@web/auth/google/google.auth.util"; -import { useSession } from "@web/auth/hooks/session/useSession"; -import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; -import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; -import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; +import { useSession } from "@web/auth/compass/session/useSession"; +import { + clearAnonymousCalendarChangeSignUpPrompt, + markUserAsAuthenticated, +} from "@web/auth/compass/state/auth.state.util"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; +import type * as GoogleAuthUtil from "@web/auth/google/util/google.auth.util"; +import { syncPendingLocalEvents } from "@web/auth/google/util/google.auth.util"; import { useAppDispatch } from "@web/store/store.hooks"; import { useCompleteAuthentication } from "./useCompleteAuthentication"; -jest.mock("@web/auth/google/google.auth.util", () => ({ +jest.mock("@web/auth/google/util/google.auth.util", () => ({ ...jest.requireActual( - "@web/auth/google/google.auth.util", + "@web/auth/google/util/google.auth.util", ), syncPendingLocalEvents: jest.fn(), })); -jest.mock("@web/auth/hooks/session/useSession", () => ({ +jest.mock("@web/auth/compass/session/useSession", () => ({ useSession: jest.fn(), })); -jest.mock("@web/auth/session/user-metadata.util", () => ({ +jest.mock("@web/auth/compass/user/util/user-metadata.util", () => ({ refreshUserMetadata: jest.fn(), })); -jest.mock("@web/auth/state/auth.state.util", () => ({ +jest.mock("@web/auth/compass/state/auth.state.util", () => ({ + clearAnonymousCalendarChangeSignUpPrompt: jest.fn(), markUserAsAuthenticated: jest.fn(), })); jest.mock("@web/store/store.hooks", () => ({ @@ -37,6 +40,10 @@ const mockMarkUserAsAuthenticated = markUserAsAuthenticated as jest.MockedFunction< typeof markUserAsAuthenticated >; +const mockClearAnonymousCalendarChangeSignUpPrompt = + clearAnonymousCalendarChangeSignUpPrompt as jest.MockedFunction< + typeof clearAnonymousCalendarChangeSignUpPrompt + >; const mockUseAppDispatch = jest.mocked(useAppDispatch); describe("useCompleteAuthentication", () => { @@ -59,6 +66,7 @@ describe("useCompleteAuthentication", () => { await result.current({ email: "test@example.com" }); + expect(mockClearAnonymousCalendarChangeSignUpPrompt).toHaveBeenCalled(); expect(mockMarkUserAsAuthenticated).toHaveBeenCalledWith( "test@example.com", ); @@ -73,19 +81,11 @@ describe("useCompleteAuthentication", () => { }); it("records synced local events count", async () => { - mockSyncPendingLocalEvents.mockImplementation((dispatch) => { - dispatch(importGCalSlice.actions.setLocalEventsSynced(5)); - return Promise.resolve(true); - }); + mockSyncPendingLocalEvents.mockResolvedValue(true); const { result } = renderHook(() => useCompleteAuthentication()); await result.current({ email: "test@example.com" }); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: "async/importGCal/setLocalEventsSynced", - payload: 5, - }), - ); + expect(mockSyncPendingLocalEvents).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/web/src/auth/hooks/compass/useCompleteAuthentication.ts b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts similarity index 57% rename from packages/web/src/auth/hooks/compass/useCompleteAuthentication.ts rename to packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts index 7d164e82d..bbdaa37df 100644 --- a/packages/web/src/auth/hooks/compass/useCompleteAuthentication.ts +++ b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.ts @@ -1,7 +1,10 @@ -import { syncPendingLocalEvents } from "@web/auth/google/google.auth.util"; -import { useSession } from "@web/auth/hooks/session/useSession"; -import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; -import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; +import { useSession } from "@web/auth/compass/session/useSession"; +import { + clearAnonymousCalendarChangeSignUpPrompt, + markUserAsAuthenticated, +} from "@web/auth/compass/state/auth.state.util"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; +import { syncPendingLocalEvents } from "@web/auth/google/util/google.auth.util"; import { authSuccess } from "@web/ducks/auth/slices/auth.slice"; import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; @@ -17,13 +20,14 @@ export function useCompleteAuthentication() { email?: string; onComplete?: () => void; }) => { + clearAnonymousCalendarChangeSignUpPrompt(); markUserAsAuthenticated(email); setAuthenticated(true); dispatch(authSuccess()); void refreshUserMetadata(); - await syncPendingLocalEvents(dispatch); + await syncPendingLocalEvents(); dispatch(triggerFetch()); onComplete?.(); diff --git a/packages/web/src/auth/schemas/auth.schemas.test.ts b/packages/web/src/auth/compass/schemas/auth.schemas.test.ts similarity index 100% rename from packages/web/src/auth/schemas/auth.schemas.test.ts rename to packages/web/src/auth/compass/schemas/auth.schemas.test.ts diff --git a/packages/web/src/auth/schemas/auth.schemas.ts b/packages/web/src/auth/compass/schemas/auth.schemas.ts similarity index 100% rename from packages/web/src/auth/schemas/auth.schemas.ts rename to packages/web/src/auth/compass/schemas/auth.schemas.ts diff --git a/packages/web/src/auth/session/SessionProvider.test.tsx b/packages/web/src/auth/compass/session/SessionProvider.test.tsx similarity index 88% rename from packages/web/src/auth/session/SessionProvider.test.tsx rename to packages/web/src/auth/compass/session/SessionProvider.test.tsx index e8dd65e89..72985ef1f 100644 --- a/packages/web/src/auth/session/SessionProvider.test.tsx +++ b/packages/web/src/auth/compass/session/SessionProvider.test.tsx @@ -2,7 +2,6 @@ import { act } from "react"; import { waitFor } from "@testing-library/react"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; -import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; describe("SessionProvider sessionInit", () => { beforeEach(() => { @@ -18,7 +17,7 @@ describe("SessionProvider sessionInit", () => { const markUserAsAuthenticated = jest.fn(); const getLastKnownEmail = jest.fn().mockReturnValue("test@example.com"); - jest.doMock("@web/auth/session/user-metadata.util", () => ({ + jest.doMock("@web/auth/compass/user/util/user-metadata.util", () => ({ refreshUserMetadata, })); jest.doMock("@web/sse/provider/SSEProvider", () => ({ @@ -31,7 +30,7 @@ describe("SessionProvider sessionInit", () => { dispatch, }, })); - jest.doMock("@web/auth/state/auth.state.util", () => ({ + jest.doMock("@web/auth/compass/state/auth.state.util", () => ({ getLastKnownEmail, markUserAsAuthenticated, })); @@ -63,7 +62,7 @@ describe("SessionProvider sessionInit", () => { const markUserAsAuthenticated = jest.fn(); const getLastKnownEmail = jest.fn().mockReturnValue("test@example.com"); - jest.doMock("@web/auth/session/user-metadata.util", () => ({ + jest.doMock("@web/auth/compass/user/util/user-metadata.util", () => ({ refreshUserMetadata, })); jest.doMock("@web/sse/provider/SSEProvider", () => ({ @@ -76,7 +75,7 @@ describe("SessionProvider sessionInit", () => { dispatch, }, })); - jest.doMock("@web/auth/state/auth.state.util", () => ({ + jest.doMock("@web/auth/compass/state/auth.state.util", () => ({ getLastKnownEmail, markUserAsAuthenticated, })); @@ -104,9 +103,6 @@ describe("SessionProvider sessionInit", () => { session.emit("SIGN_OUT", { action: "SIGN_OUT" } as never); expect(dispatch).toHaveBeenCalledWith(authSlice.actions.resetAuth()); - expect(dispatch).toHaveBeenCalledWith( - importGCalSlice.actions.clearImportResults(undefined), - ); expect(dispatch).toHaveBeenCalledWith( userMetadataSlice.actions.clear(undefined), ); diff --git a/packages/web/src/auth/session/SessionProvider.tsx b/packages/web/src/auth/compass/session/SessionProvider.tsx similarity index 92% rename from packages/web/src/auth/session/SessionProvider.tsx rename to packages/web/src/auth/compass/session/SessionProvider.tsx index 33e6020de..a538da353 100644 --- a/packages/web/src/auth/session/SessionProvider.tsx +++ b/packages/web/src/auth/compass/session/SessionProvider.tsx @@ -19,17 +19,17 @@ import { APP_NAME } from "@core/constants/core.constants"; import { getLastKnownEmail, markUserAsAuthenticated, -} from "@web/auth/state/auth.state.util"; +} from "@web/auth/compass/state/auth.state.util"; import { session } from "@web/common/classes/Session"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; -import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; import * as sse from "@web/sse/provider/SSEProvider"; import { store } from "@web/store"; +import { clearGoogleSyncIndicatorOverride } from "../../google/state/google.sync.state"; +import { refreshUserMetadata } from "../user/util/user-metadata.util"; import { type CompassSession } from "./session.types"; -import { refreshUserMetadata } from "./user-metadata.util"; SuperTokens.init({ appInfo: { @@ -42,7 +42,7 @@ SuperTokens.init({ EmailPassword.init(), EmailVerification.init(), Session.init({ - postAPIHook: async (context) => { + postAPIHook: (context) => { session.emit(context.action, context); }, onHandleEvent: (event) => { @@ -69,8 +69,8 @@ const handleSessionExists = () => { const handleSessionMissing = () => { store.dispatch(authSlice.actions.resetAuth()); - store.dispatch(importGCalSlice.actions.clearImportResults(undefined)); store.dispatch(userMetadataSlice.actions.clear(undefined)); + clearGoogleSyncIndicatorOverride(); }; async function checkIfSessionExists(): Promise { @@ -108,11 +108,11 @@ async function checkIfSessionExists(): Promise { } export function sessionInit() { - checkIfSessionExists(); + void checkIfSessionExists(); // No need to unsubscribe as this runs for the lifetime of the app session.events.pipe(distinctUntilKeyChanged("action")).subscribe((e) => { - checkIfSessionExists(); + void checkIfSessionExists(); switch (e.action) { case "REFRESH_SESSION": @@ -132,7 +132,7 @@ export function sessionInit() { }); } -export function SessionProvider({ children }: PropsWithChildren<{}>) { +export function SessionProvider({ children }: PropsWithChildren) { const [authenticated, setAuthenticated] = useState(authenticated$.value); useEffect(() => { diff --git a/packages/web/src/auth/session/session.types.ts b/packages/web/src/auth/compass/session/session.types.ts similarity index 100% rename from packages/web/src/auth/session/session.types.ts rename to packages/web/src/auth/compass/session/session.types.ts diff --git a/packages/web/src/auth/session/session.util.test.ts b/packages/web/src/auth/compass/session/session.util.test.ts similarity index 100% rename from packages/web/src/auth/session/session.util.test.ts rename to packages/web/src/auth/compass/session/session.util.test.ts diff --git a/packages/web/src/auth/session/session.util.ts b/packages/web/src/auth/compass/session/session.util.ts similarity index 100% rename from packages/web/src/auth/session/session.util.ts rename to packages/web/src/auth/compass/session/session.util.ts diff --git a/packages/web/src/auth/hooks/session/useSession.ts b/packages/web/src/auth/compass/session/useSession.ts similarity index 56% rename from packages/web/src/auth/hooks/session/useSession.ts rename to packages/web/src/auth/compass/session/useSession.ts index 1b971879e..972f004f7 100644 --- a/packages/web/src/auth/hooks/session/useSession.ts +++ b/packages/web/src/auth/compass/session/useSession.ts @@ -1,4 +1,4 @@ import { useContext } from "react"; -import { SessionContext } from "@web/auth/session/SessionProvider"; +import { SessionContext } from "@web/auth/compass/session/SessionProvider"; export const useSession = () => useContext(SessionContext); diff --git a/packages/web/src/auth/state/auth.state.util.test.ts b/packages/web/src/auth/compass/state/auth.state.util.test.ts similarity index 79% rename from packages/web/src/auth/state/auth.state.util.test.ts rename to packages/web/src/auth/compass/state/auth.state.util.test.ts index 18917869b..405364bbf 100644 --- a/packages/web/src/auth/state/auth.state.util.test.ts +++ b/packages/web/src/auth/compass/state/auth.state.util.test.ts @@ -4,13 +4,17 @@ import { clearGoogleRevokedState, isGoogleRevoked, markGoogleAsRevoked, -} from "../google/google.auth.state"; +} from "../../google/state/google.auth.state"; import { + clearAnonymousCalendarChangeSignUpPrompt, clearAuthenticationState, getAuthState, getLastKnownEmail, hasUserEverAuthenticated, + markAnonymousCalendarChangeForSignUpPrompt, markUserAsAuthenticated, + shouldShowAnonymousCalendarChangeSignUpPrompt, + subscribeToAuthState, updateAuthState, } from "./auth.state.util"; @@ -39,7 +43,10 @@ describe("auth-state.util", () => { localStorage.setItem(STORAGE_KEYS.AUTH, JSON.stringify(testState)); const state = getAuthState(); - expect(state).toEqual(testState); + expect(state).toEqual({ + hasAuthenticated: true, + shouldPromptSignUpAfterAnonymousCalendarChange: false, + }); }); it("should migrate legacy stored state from isGoogleAuthenticated", () => { @@ -48,7 +55,10 @@ describe("auth-state.util", () => { JSON.stringify({ isGoogleAuthenticated: true }), ); - expect(getAuthState()).toEqual({ hasAuthenticated: true }); + expect(getAuthState()).toEqual({ + hasAuthenticated: true, + shouldPromptSignUpAfterAnonymousCalendarChange: false, + }); }); it("should handle invalid JSON gracefully", () => { @@ -73,7 +83,12 @@ describe("auth-state.util", () => { const stored = localStorage.getItem(STORAGE_KEYS.AUTH); expect(stored).toBeTruthy(); - const parsed = JSON.parse(stored!); + const parsed: unknown = JSON.parse(stored ?? "{}"); + expect(typeof parsed).toBe("object"); + expect(parsed).not.toBeNull(); + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Expected parsed auth state to be an object"); + } expect(parsed.hasAuthenticated).toBe(true); }); @@ -94,6 +109,7 @@ describe("auth-state.util", () => { expect(getAuthState()).toEqual({ hasAuthenticated: true, lastKnownEmail: "foo@bar.com", + shouldPromptSignUpAfterAnonymousCalendarChange: false, }); }); @@ -146,6 +162,37 @@ describe("auth-state.util", () => { }); }); + describe("anonymous calendar sign-up prompt", () => { + it("defaults the prompt flag to false for legacy state", () => { + localStorage.setItem( + STORAGE_KEYS.AUTH, + JSON.stringify({ hasAuthenticated: false }), + ); + + expect(shouldShowAnonymousCalendarChangeSignUpPrompt()).toBe(false); + }); + + it("marks and clears the prompt flag", () => { + markAnonymousCalendarChangeForSignUpPrompt(); + expect(shouldShowAnonymousCalendarChangeSignUpPrompt()).toBe(true); + + clearAnonymousCalendarChangeSignUpPrompt(); + expect(shouldShowAnonymousCalendarChangeSignUpPrompt()).toBe(false); + }); + + it("notifies subscribers when auth state changes", () => { + const listener = jest.fn(); + const unsubscribe = subscribeToAuthState(listener); + + markAnonymousCalendarChangeForSignUpPrompt(); + clearAnonymousCalendarChangeSignUpPrompt(); + + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + }); + describe("hasUserEverAuthenticated", () => { it("should return true when authentication flag is set", () => { updateAuthState({ hasAuthenticated: true }); diff --git a/packages/web/src/auth/state/auth.state.util.ts b/packages/web/src/auth/compass/state/auth.state.util.ts similarity index 69% rename from packages/web/src/auth/state/auth.state.util.ts rename to packages/web/src/auth/compass/state/auth.state.util.ts index ed9fe48e2..cfc957eca 100644 --- a/packages/web/src/auth/state/auth.state.util.ts +++ b/packages/web/src/auth/compass/state/auth.state.util.ts @@ -4,7 +4,13 @@ import { DEFAULT_AUTH_STATE, } from "@web/common/constants/auth.constants"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; -import { clearGoogleRevokedState } from "../google/google.auth.state"; +import { clearGoogleRevokedState } from "../../google/state/google.auth.state"; + +const authStateListeners = new Set<() => void>(); + +function emitAuthStateChange(): void { + authStateListeners.forEach((listener) => listener()); +} function normalizeStoredAuthState(parsed: unknown): AuthState { if (typeof parsed !== "object" || parsed === null) { @@ -15,6 +21,7 @@ function normalizeStoredAuthState(parsed: unknown): AuthState { isGoogleAuthenticated?: unknown; hasAuthenticated?: unknown; lastKnownEmail?: unknown; + shouldPromptSignUpAfterAnonymousCalendarChange?: unknown; }; // Migrate legacy isGoogleAuthenticated to hasAuthenticated @@ -28,6 +35,8 @@ function normalizeStoredAuthState(parsed: unknown): AuthState { const migratedState = { hasAuthenticated, lastKnownEmail: legacyState.lastKnownEmail, + shouldPromptSignUpAfterAnonymousCalendarChange: + legacyState.shouldPromptSignUpAfterAnonymousCalendarChange, }; const result = AuthStateSchema.safeParse(migratedState); @@ -44,7 +53,7 @@ export function getAuthState(): AuthState { try { const stored = localStorage.getItem(STORAGE_KEYS.AUTH); if (stored) { - const parsed = JSON.parse(stored); + const parsed: unknown = JSON.parse(stored); return normalizeStoredAuthState(parsed); } @@ -72,6 +81,7 @@ export function updateAuthState(updates: Partial): void { const result = AuthStateSchema.safeParse(updated); if (result.success) { localStorage.setItem(STORAGE_KEYS.AUTH, JSON.stringify(result.data)); + emitAuthStateChange(); } } catch { // Silently fail if localStorage is unavailable @@ -129,7 +139,47 @@ export function clearAuthenticationState(): void { try { localStorage.removeItem(STORAGE_KEYS.AUTH); + emitAuthStateChange(); } catch { // Silently fail if localStorage is unavailable } } + +export function clearAnonymousCalendarChangeSignUpPrompt(): void { + updateAuthState({ shouldPromptSignUpAfterAnonymousCalendarChange: false }); +} + +export function markAnonymousCalendarChangeForSignUpPrompt(): void { + updateAuthState({ shouldPromptSignUpAfterAnonymousCalendarChange: true }); +} + +export function shouldShowAnonymousCalendarChangeSignUpPrompt(): boolean { + try { + return getAuthState().shouldPromptSignUpAfterAnonymousCalendarChange; + } catch { + return false; + } +} + +export function subscribeToAuthState(listener: () => void): () => void { + authStateListeners.add(listener); + + if (typeof window === "undefined") { + return () => { + authStateListeners.delete(listener); + }; + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEYS.AUTH) { + listener(); + } + }; + + window.addEventListener("storage", handleStorage); + + return () => { + authStateListeners.delete(listener); + window.removeEventListener("storage", handleStorage); + }; +} diff --git a/packages/web/src/auth/context/UserContext.tsx b/packages/web/src/auth/compass/user/context/UserContext.tsx similarity index 58% rename from packages/web/src/auth/context/UserContext.tsx rename to packages/web/src/auth/compass/user/context/UserContext.tsx index f222ba219..d9b57eeb2 100644 --- a/packages/web/src/auth/context/UserContext.tsx +++ b/packages/web/src/auth/compass/user/context/UserContext.tsx @@ -2,8 +2,5 @@ import { createContext } from "react"; import { type UserProfile } from "@core/types/user.types"; export const UserContext = createContext< - | Partial< - { isLoadingUser: boolean; userId: string } & Omit - > - | undefined + Partial<{ userId: string } & Omit> | undefined >(undefined); diff --git a/packages/web/src/auth/compass/user/context/UserProvider.test.tsx b/packages/web/src/auth/compass/user/context/UserProvider.test.tsx new file mode 100644 index 000000000..5ab5a53c8 --- /dev/null +++ b/packages/web/src/auth/compass/user/context/UserProvider.test.tsx @@ -0,0 +1,79 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { useSession } from "@web/auth/compass/session/useSession"; +import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; +import { UserProvider } from "@web/auth/compass/user/context/UserProvider"; +import { useUser } from "@web/auth/compass/user/hooks/useUser"; + +jest.mock("@web/auth/compass/session/useSession", () => ({ + useSession: jest.fn(), +})); +const mockUseSession = jest.mocked(useSession); + +jest.mock("@web/auth/compass/state/auth.state.util", () => { + const actual = jest.requireActual( + "@web/auth/compass/state/auth.state.util", + ); + return { + ...actual, + getLastKnownEmail: jest.fn(), + hasUserEverAuthenticated: jest.fn(), + }; +}); +const mockGetLastKnownEmail = jest.mocked(authStateUtil.getLastKnownEmail); +const mockHasUserEverAuthenticated = jest.mocked( + authStateUtil.hasUserEverAuthenticated, +); + +const UserEmail = () =>
{useUser().email ?? "no-email"}
; + +describe("UserProvider", () => { + let isAuthenticated = false; + + beforeEach(() => { + jest.clearAllMocks(); + isAuthenticated = false; + mockGetLastKnownEmail.mockReturnValue("last-known@example.com"); + mockHasUserEverAuthenticated.mockReturnValue(true); + mockUseSession.mockImplementation(() => ({ + authenticated: isAuthenticated, + setAuthenticated: jest.fn(), + })); + }); + + it("renders children", () => { + render( + +
Test Child
+
, + ); + + expect(screen.getByText("Test Child")).toBeInTheDocument(); + }); + + it("fetches the profile when session auth completes after mount (hasAuthenticatedBefore becomes true)", async () => { + mockHasUserEverAuthenticated.mockReturnValue(false); + + const { rerender } = render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("no-email")).toBeInTheDocument(); + }); + + isAuthenticated = true; + + rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/src/auth/compass/user/context/UserProvider.tsx b/packages/web/src/auth/compass/user/context/UserProvider.tsx new file mode 100644 index 000000000..c66ae375f --- /dev/null +++ b/packages/web/src/auth/compass/user/context/UserProvider.tsx @@ -0,0 +1,27 @@ +import { type ReactNode } from "react"; +import { useSession } from "@web/auth/compass/session/useSession"; +import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; +import { useLoadProfile } from "@web/auth/compass/user/hooks/useLoadProfile"; +import { useIdentifyUser } from "@web/auth/posthog/useIdentifyUser"; +import { UserContext } from "./UserContext"; + +export const UserProvider = ({ children }: { children: ReactNode }) => { + const { authenticated } = useSession(); + const hasAuthenticatedBefore = authenticated || hasUserEverAuthenticated(); + const { email, profile, profileEmail, userId } = useLoadProfile( + hasAuthenticatedBefore, + ); + useIdentifyUser(profileEmail, userId); + + return ( + + {children} + + ); +}; diff --git a/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts b/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts new file mode 100644 index 000000000..479d5407a --- /dev/null +++ b/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts @@ -0,0 +1,196 @@ +import { rest } from "msw"; +import { type ReactElement, isValidElement } from "react"; +import { toast } from "react-toastify"; +import "@testing-library/jest-dom"; +import { renderHook, waitFor } from "@testing-library/react"; +import { Status } from "@core/errors/status.codes"; +// eslint-disable-next-line jest/no-mocks-import +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; +import { UserApi } from "@web/common/apis/user.api"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { SessionExpiredToast } from "@web/common/utils/toast/session-expired.toast"; +import { useLoadProfile } from "./useLoadProfile"; + +jest.mock("@web/auth/compass/state/auth.state.util", () => { + const actual = jest.requireActual( + "@web/auth/compass/state/auth.state.util", + ); + return { + ...actual, + getLastKnownEmail: jest.fn(), + }; +}); +const mockGetLastKnownEmail = jest.mocked(authStateUtil.getLastKnownEmail); + +const mockToastError = jest.mocked(toast.error); + +describe("useLoadProfile", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetLastKnownEmail.mockReturnValue("last-known@example.com"); + }); + + it("does not call getProfile when user has never authenticated", async () => { + const getProfileSpy = jest.spyOn(UserApi, "getProfile"); + + renderHook(() => useLoadProfile(false)); + + await waitFor(() => { + expect(getProfileSpy).not.toHaveBeenCalled(); + }); + + getProfileSpy.mockRestore(); + }); + + it("calls getProfile when hasAuthenticatedBefore is true", async () => { + const getProfileSpy = jest.spyOn(UserApi, "getProfile"); + + renderHook(() => useLoadProfile(true)); + + await waitFor(() => expect(getProfileSpy).toHaveBeenCalled()); + expect(getProfileSpy).toHaveBeenCalledTimes(1); + + getProfileSpy.mockRestore(); + }); + + it("fetches the profile when hasAuthenticatedBefore becomes true after mount", async () => { + const getProfileSpy = jest.spyOn(UserApi, "getProfile"); + + const { result, rerender } = renderHook( + ({ hasAuthenticatedBefore }: { hasAuthenticatedBefore: boolean }) => + useLoadProfile(hasAuthenticatedBefore), + { initialProps: { hasAuthenticatedBefore: false } }, + ); + + await waitFor(() => { + expect(result.current.email).toBeNull(); + }); + expect(getProfileSpy).not.toHaveBeenCalled(); + + rerender({ hasAuthenticatedBefore: true }); + + await waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(result.current.email).toBe("test@example.com"); + }); + + getProfileSpy.mockRestore(); + }); + + it("shows the last known email while the profile is loading", async () => { + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { + return res( + ctx.delay(100), + ctx.status(Status.OK), + ctx.json({ + userId: "test-user-123", + email: "test@example.com", + }), + ); + }), + ); + + const { result } = renderHook(() => useLoadProfile(true)); + + expect(result.current.email).toBe("last-known@example.com"); + + await waitFor(() => { + expect(result.current.email).toBe("test@example.com"); + }); + }); + + it("returns profile without email when the API omits email", async () => { + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { + return res( + ctx.status(Status.OK), + ctx.json({ userId: "test-user-123" }), + ); + }), + ); + + const { result } = renderHook(() => useLoadProfile(true)); + + await waitFor(() => { + expect(result.current.profile).not.toBeNull(); + }); + expect(result.current.profileEmail).toBeNull(); + expect(result.current.userId).toBe("test-user-123"); + }); + + it("returns profile without userId when the API omits userId", async () => { + const getProfileSpy = jest.spyOn(UserApi, "getProfile"); + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { + return res( + ctx.status(Status.OK), + ctx.json({ email: "test@example.com" }), + ); + }), + ); + + const { result } = renderHook(() => useLoadProfile(true)); + + await waitFor(() => expect(getProfileSpy).toHaveBeenCalled()); + await getProfileSpy.mock.results[0].value; + + await waitFor(() => { + expect(result.current.profile).not.toBeNull(); + }); + expect(result.current.userId).toBeNull(); + expect(result.current.profileEmail).toBe("test@example.com"); + + getProfileSpy.mockRestore(); + }); + + it("shows a login toast when profile fetch returns unauthorized", async () => { + const assignMock = jest.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { ...originalLocation, assign: assignMock }, + configurable: true, + }); + + const getProfileSpy = jest.spyOn(UserApi, "getProfile"); + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { + return res(ctx.status(Status.UNAUTHORIZED)); + }), + ); + + renderHook(() => useLoadProfile(true)); + + await waitFor(() => expect(getProfileSpy).toHaveBeenCalled()); + try { + await getProfileSpy.mock.results[0].value; + } catch { + // expected — profile fetch rejects on 401 + } + + expect(mockToastError).toHaveBeenCalled(); + const latestToastCall = + mockToastError.mock.calls[mockToastError.mock.calls.length - 1]; + expect(latestToastCall[1]).toEqual( + expect.objectContaining({ + toastId: "session-expired-api", + autoClose: false, + closeOnClick: false, + draggable: false, + }), + ); + + const toastContent = latestToastCall[0]; + expect(isValidElement(toastContent)).toBe(true); + const toastElement = toastContent as ReactElement<{ toastId: string }>; + expect(toastElement.type).toBe(SessionExpiredToast); + expect(toastElement.props.toastId).toBe("session-expired-api"); + + getProfileSpy.mockRestore(); + Object.defineProperty(window, "location", { + value: originalLocation, + configurable: true, + }); + }); +}); diff --git a/packages/web/src/auth/compass/user/hooks/useLoadProfile.ts b/packages/web/src/auth/compass/user/hooks/useLoadProfile.ts new file mode 100644 index 000000000..f258b08fc --- /dev/null +++ b/packages/web/src/auth/compass/user/hooks/useLoadProfile.ts @@ -0,0 +1,84 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { Status } from "@core/errors/status.codes"; +import { type UserProfile } from "@core/types/user.types"; +import { + getLastKnownEmail, + markUserAsAuthenticated, +} from "@web/auth/compass/state/auth.state.util"; +import { UserApi } from "@web/common/apis/user.api"; +import { showSessionExpiredToast } from "@web/common/utils/toast/error-toast.util"; + +export type UseLoadProfileResult = { + email: string | null; + profile: UserProfile | null; + profileEmail: string | null; + userId: string | null; +}; + +/** + * Fetches the authenticated user profile when `hasAuthenticatedBefore` is true. + * While loading, exposes the last known email from storage when available. + */ +export function useLoadProfile( + hasAuthenticatedBefore: boolean, +): UseLoadProfileResult { + const [profile, setProfile] = useState(null); + const [isLoadingUser, setIsLoadingUser] = useState(hasAuthenticatedBefore); + const profileRequest = useRef | null>(null); + const userId = profile?.userId ?? null; + const profileEmail = profile?.email ?? null; + const email = + profileEmail ?? + (profile === null && isLoadingUser ? (getLastKnownEmail() ?? null) : null); + + const loadProfile = useCallback(() => { + if (profileRequest.current) { + return profileRequest.current; + } + + setIsLoadingUser(true); + + profileRequest.current = UserApi.getProfile() + .then((userProfile) => { + setProfile(userProfile); + markUserAsAuthenticated(userProfile.email); + }) + .catch((e) => { + const status = (e as { response?: { status?: number } })?.response + ?.status; + const isUnauthorized = + status === Status.UNAUTHORIZED || status === Status.FORBIDDEN; + + if (isUnauthorized) { + showSessionExpiredToast(); + return; + } + + console.error("Failed to get user profile", e); + }) + .finally(() => { + profileRequest.current = null; + setIsLoadingUser(false); + }); + + return profileRequest.current; + }, []); + + useLayoutEffect(() => { + if (profile) return; + + if (!hasAuthenticatedBefore) { + setIsLoadingUser(false); + return; + } + + void loadProfile(); + }, [hasAuthenticatedBefore, loadProfile, profile]); + + return { + email, + profile, + profileEmail, + userId, + }; +} diff --git a/packages/web/src/auth/hooks/user/useUser.test.tsx b/packages/web/src/auth/compass/user/hooks/useUser.test.tsx similarity index 91% rename from packages/web/src/auth/hooks/user/useUser.test.tsx rename to packages/web/src/auth/compass/user/hooks/useUser.test.tsx index 8969968b4..61dbef7a5 100644 --- a/packages/web/src/auth/hooks/user/useUser.test.tsx +++ b/packages/web/src/auth/compass/user/hooks/useUser.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from "@testing-library/react"; -import { UserContext } from "../../context/UserContext"; +import { UserContext } from "../context/UserContext"; import { useUser } from "./useUser"; describe("useUser", () => { @@ -7,7 +7,6 @@ describe("useUser", () => { const mockUser = { userId: "user123", email: "test@example.com", - isLoadingUser: false, }; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} diff --git a/packages/web/src/auth/hooks/user/useUser.ts b/packages/web/src/auth/compass/user/hooks/useUser.ts similarity index 80% rename from packages/web/src/auth/hooks/user/useUser.ts rename to packages/web/src/auth/compass/user/hooks/useUser.ts index 5e0aa9146..e9d81d061 100644 --- a/packages/web/src/auth/hooks/user/useUser.ts +++ b/packages/web/src/auth/compass/user/hooks/useUser.ts @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { UserContext } from "../../context/UserContext"; +import { UserContext } from "../context/UserContext"; export const useUser = () => { const context = useContext(UserContext); diff --git a/packages/web/src/auth/compass/user/util/user-metadata.import.util.test.ts b/packages/web/src/auth/compass/user/util/user-metadata.import.util.test.ts new file mode 100644 index 000000000..b0264679d --- /dev/null +++ b/packages/web/src/auth/compass/user/util/user-metadata.import.util.test.ts @@ -0,0 +1,65 @@ +import { isGoogleCalendarImportActive } from "./user-metadata.import.util"; + +describe("isGoogleCalendarImportActive", () => { + it("returns true when import is actively running", () => { + expect( + isGoogleCalendarImportActive({ + google: { connectionState: "HEALTHY" }, + sync: { importGCal: "IMPORTING" }, + }), + ).toBe(true); + }); + + it("returns false for RESTART — import hasn't started yet, no spinner", () => { + // RESTART means the backend wants to re-import, not that it's running. + // Showing a spinner for RESTART caused visible flicker before this fix. + expect( + isGoogleCalendarImportActive({ + google: { connectionState: "ATTENTION" }, + sync: { importGCal: "RESTART" }, + }), + ).toBe(false); + }); + + it("returns false when COMPLETED", () => { + expect( + isGoogleCalendarImportActive({ + google: { connectionState: "HEALTHY" }, + sync: { importGCal: "COMPLETED" }, + }), + ).toBe(false); + }); + + it("returns false when ERRORED", () => { + expect( + isGoogleCalendarImportActive({ + google: { connectionState: "HEALTHY" }, + sync: { importGCal: "ERRORED" }, + }), + ).toBe(false); + }); + + it("returns false when sync status is absent", () => { + expect( + isGoogleCalendarImportActive({ google: { connectionState: "HEALTHY" } }), + ).toBe(false); + }); + + it("returns false when IMPORTING but Google is not connected", () => { + expect( + isGoogleCalendarImportActive({ + google: { connectionState: "NOT_CONNECTED" }, + sync: { importGCal: "IMPORTING" }, + }), + ).toBe(false); + }); + + it("returns false when IMPORTING but reconnect is required", () => { + expect( + isGoogleCalendarImportActive({ + google: { connectionState: "RECONNECT_REQUIRED" }, + sync: { importGCal: "IMPORTING" }, + }), + ).toBe(false); + }); +}); diff --git a/packages/web/src/auth/compass/user/util/user-metadata.import.util.ts b/packages/web/src/auth/compass/user/util/user-metadata.import.util.ts new file mode 100644 index 000000000..09c7591f1 --- /dev/null +++ b/packages/web/src/auth/compass/user/util/user-metadata.import.util.ts @@ -0,0 +1,16 @@ +import { type UserMetadata } from "@core/types/user.types"; + +const isGoogleConnected = (metadata: UserMetadata) => { + const connectionState = metadata.google?.connectionState; + return ( + connectionState !== "NOT_CONNECTED" && + connectionState !== "RECONNECT_REQUIRED" + ); +}; + +/** Returns true when an import is actively in progress (show spinner). */ +export const isGoogleCalendarImportActive = (metadata: UserMetadata) => { + return ( + metadata.sync?.importGCal === "IMPORTING" && isGoogleConnected(metadata) + ); +}; diff --git a/packages/web/src/auth/session/user-metadata.util.test.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts similarity index 61% rename from packages/web/src/auth/session/user-metadata.util.test.ts rename to packages/web/src/auth/compass/user/util/user-metadata.util.test.ts index 3507c6b0f..ad7aaa764 100644 --- a/packages/web/src/auth/session/user-metadata.util.test.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts @@ -1,4 +1,5 @@ import { Status } from "@core/errors/status.codes"; +import { resetGoogleSyncUIStateForTests } from "@web/auth/google/state/google.sync.state"; import { UserApi } from "@web/common/apis/user.api"; import { store } from "@web/store"; import { refreshUserMetadata } from "./user-metadata.util"; @@ -16,14 +17,12 @@ jest.mock("@web/store", () => ({ })); describe("refreshUserMetadata", () => { - const api = UserApi as unknown as { - getMetadata: jest.MockedFunction; - }; - const getDispatchMock = () => - store.dispatch as jest.MockedFunction; + const api = UserApi as jest.Mocked; + const dispatch = store.dispatch as jest.MockedFunction; beforeEach(() => { jest.clearAllMocks(); + resetGoogleSyncUIStateForTests(); }); it("loads metadata into the store", async () => { @@ -36,15 +35,35 @@ describe("refreshUserMetadata", () => { await refreshUserMetadata(); - expect(getDispatchMock()).toHaveBeenNthCalledWith( + expect(dispatch).toHaveBeenNthCalledWith( 1, expect.objectContaining({ type: "userMetadata/setLoading" }), ); - expect(getDispatchMock()).toHaveBeenNthCalledWith( + expect(dispatch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "userMetadata/set", payload: metadata }), + ); + expect(dispatch).toHaveBeenCalledTimes(2); + }); + + it("does not trigger a client-side import when metadata says RESTART", async () => { + const metadata = { + google: { + connectionState: "ATTENTION" as const, + }, + sync: { + importGCal: "RESTART" as const, + }, + }; + api.getMetadata.mockResolvedValue(metadata); + + await refreshUserMetadata(); + await refreshUserMetadata(); + + expect(dispatch).toHaveBeenNthCalledWith( 2, expect.objectContaining({ type: "userMetadata/set", payload: metadata }), ); - expect(getDispatchMock()).toHaveBeenCalledTimes(2); }); it("clears metadata when the request is unauthorized", async () => { @@ -56,11 +75,11 @@ describe("refreshUserMetadata", () => { await refreshUserMetadata(); - expect(getDispatchMock()).toHaveBeenNthCalledWith( + expect(dispatch).toHaveBeenNthCalledWith( 1, expect.objectContaining({ type: "userMetadata/setLoading" }), ); - expect(getDispatchMock()).toHaveBeenNthCalledWith( + expect(dispatch).toHaveBeenNthCalledWith( 2, expect.objectContaining({ type: "userMetadata/clear" }), ); @@ -74,11 +93,11 @@ describe("refreshUserMetadata", () => { await refreshUserMetadata(); - expect(getDispatchMock()).toHaveBeenNthCalledWith( + expect(dispatch).toHaveBeenNthCalledWith( 1, expect.objectContaining({ type: "userMetadata/setLoading" }), ); - expect(getDispatchMock()).toHaveBeenNthCalledWith( + expect(dispatch).toHaveBeenNthCalledWith( 2, expect.objectContaining({ type: "userMetadata/finishLoading" }), ); diff --git a/packages/web/src/auth/session/user-metadata.util.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.ts similarity index 100% rename from packages/web/src/auth/session/user-metadata.util.ts rename to packages/web/src/auth/compass/user/util/user-metadata.util.ts diff --git a/packages/web/src/auth/context/UserProvider.test.tsx b/packages/web/src/auth/context/UserProvider.test.tsx deleted file mode 100644 index a4d588c6a..000000000 --- a/packages/web/src/auth/context/UserProvider.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { rest } from "msw"; -import { type PostHog } from "posthog-js"; -import { usePostHog } from "posthog-js/react"; -import { act, isValidElement } from "react"; -import { toast } from "react-toastify"; -import "@testing-library/jest-dom"; -import { render, screen, waitFor } from "@testing-library/react"; -import { Status } from "@core/errors/status.codes"; -import { server } from "@web/__tests__/__mocks__/server/mock.server"; -import { UserProvider } from "@web/auth/context/UserProvider"; -import * as authStateUtil from "@web/auth/state/auth.state.util"; -import { UserApi } from "@web/common/apis/user.api"; -import { ENV_WEB } from "@web/common/constants/env.constants"; -import { SessionExpiredToast } from "@web/common/utils/toast/session-expired.toast"; - -jest.mock("posthog-js/react"); -const mockUsePostHog = jest.mocked(usePostHog); -const mockToastError = jest.mocked(toast.error); - -jest.mock("@web/auth/state/auth.state.util", () => { - const actual = jest.requireActual( - "@web/auth/state/auth.state.util", - ); - return { - ...actual, - hasUserEverAuthenticated: jest.fn(), - }; -}); -const mockHasUserEverAuthenticated = jest.mocked( - authStateUtil.hasUserEverAuthenticated, -); - -// Mock AbsoluteOverflowLoader -jest.mock("@web/components/AbsoluteOverflowLoader", () => ({ - AbsoluteOverflowLoader: () =>
Loading...
, -})); - -const mockIdentify = jest.fn(); - -function mockPostHogEnabled(overrides?: Partial): void { - mockUsePostHog.mockReturnValue({ - identify: mockIdentify, - ...overrides, - } as unknown as PostHog); -} - -function mockPostHogDisabled(): void { - mockUsePostHog.mockReturnValue(undefined as unknown as PostHog); -} - -describe("UserProvider", () => { - beforeEach(() => { - jest.clearAllMocks(); - mockHasUserEverAuthenticated.mockReturnValue(true); - mockPostHogEnabled(); - }); - - describe("PostHog Integration", () => { - it("should call posthog.identify when PostHog is enabled and user data is available", async () => { - const testUserId = "test-user-123"; - const testEmail = "test@example.com"; - - render( - -
Test Child
-
, - ); - - await waitFor(() => { - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - expect(mockIdentify).toHaveBeenCalledWith(testEmail, { - email: testEmail, - userId: testUserId, - }); - - expect(mockIdentify).toHaveBeenCalledTimes(1); - }); - - it("should NOT call posthog.identify when PostHog is disabled", async () => { - mockPostHogDisabled(); - - render( - -
Test Child
-
, - ); - - await waitFor(() => { - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - expect(mockIdentify).not.toHaveBeenCalled(); - }); - - it("should NOT call posthog.identify when email is missing from session", async () => { - server.use( - rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { - return res( - ctx.status(Status.OK), - ctx.json({ userId: "test-user-123" }), - ); - }), - ); - - render( - -
Test Child
-
, - ); - - await waitFor(() => { - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - expect(mockIdentify).not.toHaveBeenCalled(); - }); - - it("should NOT call posthog.identify when userId is missing", async () => { - const getProfileSpy = jest.spyOn(UserApi, "getProfile"); - server.use( - rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { - return res( - ctx.status(Status.OK), - ctx.json({ email: "test@example.com" }), - ); - }), - ); - - render( - -
Test Child
-
, - ); - - await waitFor(() => expect(getProfileSpy).toHaveBeenCalled()); - await getProfileSpy.mock.results[0].value; - - expect(mockIdentify).not.toHaveBeenCalled(); - getProfileSpy.mockRestore(); - }); - - it("should handle posthog.identify not being a function gracefully", async () => { - mockPostHogEnabled({ - identify: null as unknown as PostHog["identify"], - }); - - expect(() => { - render( - -
Test Child
-
, - ); - }).not.toThrow(); - - await waitFor(() => { - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - expect(mockIdentify).not.toHaveBeenCalled(); - }); - - it("should render children after user data is loaded", async () => { - server.use( - rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { - return waitFor( - () => - res( - ctx.status(Status.OK), - ctx.json({ - userId: "test-user-123", - email: "test@example.com", - }), - ), - { timeout: 100 }, - ); - }), - ); - - render( - -
Test Child Content
-
, - ); - - expect(screen.getByText("Loading...")).toBeInTheDocument(); - - await waitFor(() => { - expect(screen.getByText("Test Child Content")).toBeInTheDocument(); - }); - }); - - it("shows a login toast when profile fetch returns unauthorized", async () => { - const assignMock = jest.fn(); - const originalLocation = window.location; - Object.defineProperty(window, "location", { - value: { ...originalLocation, assign: assignMock }, - configurable: true, - }); - - const getProfileSpy = jest.spyOn(UserApi, "getProfile"); - server.use( - rest.get(`${ENV_WEB.API_BASEURL}/user/profile`, (_req, res, ctx) => { - return res(ctx.status(Status.UNAUTHORIZED)); - }), - ); - - render( - -
Test Child
-
, - ); - - await waitFor(() => expect(getProfileSpy).toHaveBeenCalled()); - try { - await getProfileSpy.mock.results[0].value; - } catch { - // expected — profile fetch rejects on 401 - } - - expect(mockToastError).toHaveBeenCalled(); - const latestToastCall = - mockToastError.mock.calls[mockToastError.mock.calls.length - 1]; - expect(latestToastCall[1]).toEqual( - expect.objectContaining({ - toastId: "session-expired-api", - autoClose: false, - closeOnClick: false, - draggable: false, - }), - ); - - const toastContent = latestToastCall[0]; - expect(isValidElement(toastContent)).toBe(true); - if (isValidElement<{ toastId: string }>(toastContent)) { - expect(toastContent.type).toBe(SessionExpiredToast); - expect(toastContent.props.toastId).toBe("session-expired-api"); - } - - expect(mockIdentify).not.toHaveBeenCalled(); - - getProfileSpy.mockRestore(); - Object.defineProperty(window, "location", { - value: originalLocation, - configurable: true, - }); - }); - }); - - describe("Authentication Gating", () => { - it("should NOT call getProfile when user has never authenticated", async () => { - mockHasUserEverAuthenticated.mockReturnValue(false); - const getProfileSpy = jest.spyOn(UserApi, "getProfile"); - - render( - -
Test Child
-
, - ); - - await waitFor(() => { - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - expect(getProfileSpy).not.toHaveBeenCalled(); - getProfileSpy.mockRestore(); - }); - - it("should call getProfile when user has authenticated with Google", async () => { - mockHasUserEverAuthenticated.mockReturnValue(true); - const getProfileSpy = jest.spyOn(UserApi, "getProfile"); - - render( - -
Test Child
-
, - ); - - await waitFor(() => expect(getProfileSpy).toHaveBeenCalled()); - expect(getProfileSpy).toHaveBeenCalledTimes(1); - - await waitFor(() => { - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - getProfileSpy.mockRestore(); - }); - }); -}); diff --git a/packages/web/src/auth/context/UserProvider.tsx b/packages/web/src/auth/context/UserProvider.tsx deleted file mode 100644 index c57a6b273..000000000 --- a/packages/web/src/auth/context/UserProvider.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { usePostHog } from "posthog-js/react"; -import { - type ReactNode, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { Status } from "@core/errors/status.codes"; -import { type UserProfile } from "@core/types/user.types"; -import { - hasUserEverAuthenticated, - markUserAsAuthenticated, -} from "@web/auth/state/auth.state.util"; -import { UserApi } from "@web/common/apis/user.api"; -import { showSessionExpiredToast } from "@web/common/utils/toast/error-toast.util"; -import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader"; -import { UserContext } from "./UserContext"; - -export const UserProvider = ({ children }: { children: ReactNode }) => { - const profile = useRef(null); - const [isLoadingUser, setIsLoadingUser] = useState(false); - const posthog = usePostHog(); - const userId = profile.current?.userId ?? null; - const email = profile.current?.email ?? null; - - useLayoutEffect(() => { - if (profile.current) return; - - // Only fetch profile if user has authenticated before. - if (!hasUserEverAuthenticated()) { - return; - } - - setIsLoadingUser(true); - - UserApi.getProfile() - .then((userProfile) => { - profile.current = userProfile; - markUserAsAuthenticated(userProfile.email); - }) - .catch((e) => { - // Existing authenticated users can hit this when their session expires. - const status = (e as { response?: { status?: number } })?.response - ?.status; - const isUnauthorized = - status === Status.UNAUTHORIZED || status === Status.FORBIDDEN; - - if (isUnauthorized) { - showSessionExpiredToast(); - return; - } - - console.error("Failed to get user profile", e); - }) - .finally(() => { - setIsLoadingUser(false); - }); - }, []); - - // Identify user in PostHog when userId and email are available - // Only runs if PostHog is enabled (POSTHOG_HOST and POSTHOG_KEY are set) - useEffect(() => { - if (userId && email && posthog && typeof posthog.identify === "function") { - posthog.identify(email, { email, userId }); - } - }, [userId, email, posthog]); - - // Allow unauthenticated users to proceed without blocking - // Only show loader briefly while checking auth status - // Unauthenticated users will have profile.current === null, which is fine - if (isLoadingUser && userId === null) { - // Brief loading state - but don't block indefinitely - // The route loader handles auth redirects - return ; - } - - return ( - - {children} - - ); -}; diff --git a/packages/web/src/auth/hooks/google/googe.auth.types.ts b/packages/web/src/auth/google/hooks/googe.auth.types.ts similarity index 100% rename from packages/web/src/auth/hooks/google/googe.auth.types.ts rename to packages/web/src/auth/google/hooks/googe.auth.types.ts diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.test.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.test.ts new file mode 100644 index 000000000..f05202303 --- /dev/null +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.test.ts @@ -0,0 +1,332 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; +import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { useGoogleAuth } from "@web/auth/google/hooks/useGoogleAuth/useGoogleAuth"; +import { + resetGoogleSyncUIStateForTests, + setRepairingSyncIndicatorOverride, +} from "@web/auth/google/state/google.sync.state"; +import type * as GoogleAuthUtil from "@web/auth/google/util/google.auth.util"; +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 { showErrorToast } from "@web/common/utils/toast/error-toast.util"; +import { + selectGoogleConnectionState, + selectUserMetadataStatus, +} from "@web/ducks/auth/selectors/user-metadata.selectors"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; +import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; + +jest.mock("@web/auth/google/hooks/useGoogleAuth/useGoogleAuth"); +jest.mock("@web/auth/google/util/google.auth.util", () => ({ + ...jest.requireActual( + "@web/auth/google/util/google.auth.util", + ), + syncPendingLocalEvents: jest.fn(), +})); +jest.mock("@web/auth/compass/user/util/user-metadata.util"); +jest.mock("@web/auth/compass/state/auth.state.util"); +jest.mock("@web/common/apis/auth.api"); +jest.mock("@web/common/apis/sync.api"); +jest.mock("@web/common/utils/toast/error-toast.util"); +jest.mock("@web/store/store.hooks"); + +const mockUseGoogleAuth = useGoogleAuth as jest.MockedFunction< + typeof useGoogleAuth +>; +const mockSyncPendingLocalEvents = + syncPendingLocalEvents as jest.MockedFunction; +const mockShowErrorToast = showErrorToast as jest.MockedFunction< + typeof showErrorToast +>; +const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< + typeof useAppDispatch +>; +const mockUseAppSelector = useAppSelector as jest.MockedFunction< + typeof useAppSelector +>; +const mockHasUserEverAuthenticated = + hasUserEverAuthenticated as jest.MockedFunction< + typeof hasUserEverAuthenticated + >; +const mockAuthApi = AuthApi as jest.Mocked; +const mockSyncApi = SyncApi as jest.Mocked; +const mockRefreshUserMetadata = refreshUserMetadata as jest.MockedFunction< + typeof refreshUserMetadata +>; + +const getUseGoogleAuthArg = (): NonNullable< + Parameters[0] +> => { + const firstCall = mockUseGoogleAuth.mock.calls.at(0); + + if (!firstCall) { + throw new Error("Expected useGoogleAuth to be called"); + } + + return firstCall[0] ?? {}; +}; + +describe("useConnectGoogle", () => { + const mockDispatch = jest.fn(); + const mockLogin = jest.fn(); + + const setSelectorState = ({ + connectionState = "NOT_CONNECTED", + userMetadataStatus = "loading", + }: { + connectionState?: + | "ATTENTION" + | "HEALTHY" + | "IMPORTING" + | "NOT_CONNECTED" + | "RECONNECT_REQUIRED"; + userMetadataStatus?: "idle" | "loaded" | "loading"; + } = {}) => { + mockUseAppSelector.mockImplementation((selector) => { + if (selector === selectGoogleConnectionState) { + return connectionState; + } + + if (selector === selectUserMetadataStatus) { + return userMetadataStatus; + } + + return undefined; + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetGoogleSyncUIStateForTests(); + mockUseAppDispatch.mockReturnValue(mockDispatch); + mockUseGoogleAuth.mockReturnValue({ + login: mockLogin, + data: null, + loading: false, + }); + mockAuthApi.connectGoogle.mockResolvedValue({ status: "OK" }); + mockSyncApi.importGCal.mockResolvedValue(undefined); + mockHasUserEverAuthenticated.mockReturnValue(true); + mockRefreshUserMetadata.mockResolvedValue(); + mockSyncPendingLocalEvents.mockResolvedValue(true); + setSelectorState(); + }); + + it("returns checking state when metadata is still loading", () => { + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.label).toBe( + "Checking Google Calendar…", + ); + expect(result.current.commandAction.isDisabled).toBe(true); + expect(result.current.sidebarStatus.tooltip).toBe( + "Checking Google Calendar status…", + ); + }); + + it("returns connect state when metadata is loaded and Google is not connected", () => { + setSelectorState({ + connectionState: "NOT_CONNECTED", + userMetadataStatus: "loaded", + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.label).toBe("Connect Google Calendar"); + expect(result.current.commandAction.isDisabled).toBe(false); + expect(result.current.sidebarStatus.tooltip).toBe( + "Google Calendar not connected. Click to connect.", + ); + }); + + it("returns connected state when metadata is healthy", () => { + setSelectorState({ + connectionState: "HEALTHY", + userMetadataStatus: "loaded", + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.commandAction.label).toBe( + "Google Calendar Connected", + ); + expect(result.current.commandAction.isDisabled).toBe(true); + }); + + it("shows repairing state from the local repair store", () => { + setRepairingSyncIndicatorOverride(); + setSelectorState({ + connectionState: "ATTENTION", + userMetadataStatus: "loaded", + }); + + const { result } = renderHook(() => useConnectGoogle()); + + expect(result.current.state).toBe("repairing"); + expect(result.current.commandAction.label).toBe( + "Repairing Google Calendar…", + ); + expect(result.current.isRepairing).toBe(true); + }); + + it("starts a forced repair directly and moves the UI into repairing", async () => { + setSelectorState({ + connectionState: "ATTENTION", + userMetadataStatus: "loaded", + }); + + const { result } = renderHook(() => useConnectGoogle()); + + act(() => { + result.current.commandAction.onSelect?.(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + settingsSlice.actions.closeCmdPalette(), + ); + await waitFor(() => { + expect(mockSyncApi.importGCal).toHaveBeenCalledWith({ force: true }); + expect(result.current.state).toBe("repairing"); + }); + }); + + it("clears the repair flag and shows a toast if repair start fails", async () => { + mockSyncApi.importGCal.mockRejectedValueOnce(new Error("boom")); + setSelectorState({ + connectionState: "ATTENTION", + userMetadataStatus: "loaded", + }); + + const { result } = renderHook(() => useConnectGoogle()); + + act(() => { + result.current.commandAction.onSelect?.(); + }); + + await waitFor(() => { + expect(mockShowErrorToast).toHaveBeenCalledWith( + "Google Calendar repair failed. Please try again.", + expect.anything(), + ); + expect(result.current.state).toBe("ATTENTION"); + }); + }); + + it("connects Google through the backend endpoint and refreshes metadata", async () => { + setSelectorState({ + connectionState: "ATTENTION", + userMetadataStatus: "loaded", + }); + + renderHook(() => useConnectGoogle()); + + const useGoogleAuthArg = getUseGoogleAuthArg(); + if (!useGoogleAuthArg?.onSuccess) { + throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); + } + + const payload = { + clientType: "web" as const, + thirdPartyId: "google" as const, + redirectURIInfo: { + redirectURIOnProviderDashboard: window.location.origin, + redirectURIQueryParams: { + code: "auth-code", + scope: "scope", + state: "state", + }, + }, + }; + + await act(async () => { + await useGoogleAuthArg.onSuccess(payload); + }); + + expect(mockSyncPendingLocalEvents).toHaveBeenCalledTimes(1); + expect(mockAuthApi.connectGoogle).toHaveBeenCalledWith(payload); + expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(triggerFetch()); + expect(mockSyncApi.importGCal).not.toHaveBeenCalled(); + }); + + it("shows syncing UI immediately after Google auth succeeds", async () => { + setSelectorState({ + connectionState: "ATTENTION", + userMetadataStatus: "loaded", + }); + + const { result } = renderHook(() => useConnectGoogle()); + const useGoogleAuthArg = getUseGoogleAuthArg(); + if (!useGoogleAuthArg?.onSuccess) { + throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); + } + + const payload = { + clientType: "web" as const, + thirdPartyId: "google" as const, + redirectURIInfo: { + redirectURIOnProviderDashboard: window.location.origin, + redirectURIQueryParams: { + code: "auth-code", + scope: "scope", + state: "state", + }, + }, + }; + + await act(async () => { + await useGoogleAuthArg.onSuccess(payload); + }); + + expect(result.current.state).toBe("IMPORTING"); + expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); + }); + + it("shows the server message when Google connect fails", async () => { + setSelectorState({ + connectionState: "NOT_CONNECTED", + userMetadataStatus: "loaded", + }); + mockAuthApi.connectGoogle.mockRejectedValueOnce({ + isAxiosError: true, + response: { + data: { + code: "GOOGLE_ACCOUNT_ALREADY_CONNECTED", + message: + "Google account is already connected to another Compass user", + }, + }, + } as never); + + renderHook(() => useConnectGoogle()); + const useGoogleAuthArg = getUseGoogleAuthArg(); + if (!useGoogleAuthArg?.onSuccess) { + throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); + } + + const payload = { + clientType: "web" as const, + thirdPartyId: "google" as const, + redirectURIInfo: { + redirectURIOnProviderDashboard: window.location.origin, + redirectURIQueryParams: { + code: "auth-code", + scope: "scope", + state: "state", + }, + }, + }; + + await expect(useGoogleAuthArg.onSuccess(payload)).resolves.toBe(false); + + expect(mockShowErrorToast).toHaveBeenCalledWith( + "Google account is already connected to another Compass user", + ); + expect(mockRefreshUserMetadata).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith(triggerFetch()); + }); +}); diff --git a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts similarity index 61% rename from packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts rename to packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index 3b4c8a439..164b412c0 100644 --- a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts @@ -1,11 +1,18 @@ import { type AxiosError, isAxiosError } from "axios"; -import { useCallback } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { type GoogleConnectionState } from "@core/types/user.types"; -import { syncPendingLocalEvents } from "@web/auth/google/google.auth.util"; -import { useGoogleAuth } from "@web/auth/hooks/google/useGoogleAuth/useGoogleAuth"; -import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; -import { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; +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 { + 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 { getApiErrorCode, @@ -19,21 +26,24 @@ import { selectUserMetadataStatus, } from "@web/ducks/auth/selectors/user-metadata.selectors"; import type { UserMetadataStatus } from "@web/ducks/auth/slices/user-metadata.slice"; -import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; -import { - importGCalSlice, - triggerFetch, -} from "@web/ducks/events/slices/sync.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"; -import { type GoogleUiState } from "./useConnectGoogle.types"; +import { + type GoogleUiState, + type UseConnectGoogleResult, +} from "./useConnectGoogle.types"; import { buildGoogleConnectRequest, getGoogleConnectionConfig, } from "./useConnectGoogle.util"; -export const useConnectGoogle = () => { +// Merges Redux-derived Google connection state with transient UI overrides from +// google.sync.ui.state.ts; the override is read via useSyncExternalStore so React +// stays aligned with that external store (see comments there). + +export const useConnectGoogle = (): UseConnectGoogleResult => { const dispatch = useAppDispatch(); const connectionState = useAppSelector( selectGoogleConnectionState as (state: RootState) => GoogleConnectionState, @@ -41,14 +51,14 @@ export const useConnectGoogle = () => { const userMetadataStatus = useAppSelector( selectUserMetadataStatus as (state: RootState) => UserMetadataStatus, ); - const { isRepairing } = useAppSelector( - selectImportGCalState as (state: RootState) => { - isRepairing: boolean; - }, + const syncIndicator = useSyncExternalStore( + subscribeToGoogleSyncUIState, + getGoogleSyncIndicatorOverride, + getGoogleSyncIndicatorOverride, ); const { login } = useGoogleAuth({ onSuccess: async (data) => { - const didSyncLocalEvents = await syncPendingLocalEvents(dispatch); + const didSyncLocalEvents = await syncPendingLocalEvents(); if (!didSyncLocalEvents) { return false; } @@ -70,6 +80,8 @@ export const useConnectGoogle = () => { throw error; } + + setSyncingSyncIndicatorOverride(); await refreshUserMetadata(); dispatch(triggerFetch()); }, @@ -82,32 +94,28 @@ export const useConnectGoogle = () => { }, [dispatch, login]); const onRepairGoogle = useCallback(() => { - dispatch(settingsSlice.actions.closeCmdPalette()); - const run = async () => { - dispatch(importGCalSlice.actions.clearImportResults(undefined)); - dispatch(importGCalSlice.actions.startRepair()); + const startRepair = async () => { + dispatch(settingsSlice.actions.closeCmdPalette()); + setRepairingSyncIndicatorOverride(); + try { await SyncApi.importGCal({ force: true }); } catch (error) { - dispatch(importGCalSlice.actions.stopRepair()); - + clearGoogleSyncIndicatorOverride(); const isGoogleRevoked = getApiErrorCode(error as AxiosError) === GOOGLE_REVOKED; + if (isGoogleRevoked) { return; } - dispatch( - importGCalSlice.actions.setImportError( - "Google Calendar repair failed. Please try again.", - ), - ); showErrorToast("Google Calendar repair failed. Please try again.", { toastId: GOOGLE_REPAIR_FAILED_TOAST_ID, }); } }; - void run(); + + void startRepair(); }, [dispatch]); // "checking" is a UI-only state until we have loaded metadata from the server. @@ -116,11 +124,18 @@ export const useConnectGoogle = () => { const isCheckingStatus = hasUserEverAuthenticated() && userMetadataStatus !== "loaded"; - const state: GoogleUiState = isRepairing - ? "repairing" - : isCheckingStatus - ? "checking" - : connectionState; + const state: GoogleUiState = + syncIndicator === "repairing" + ? "repairing" + : syncIndicator === "syncing" + ? "IMPORTING" + : isCheckingStatus + ? "checking" + : connectionState; - return getGoogleConnectionConfig(state, onOpenGoogleAuth, onRepairGoogle); + return { + ...getGoogleConnectionConfig(state, onOpenGoogleAuth, onRepairGoogle), + isRepairing: state === "repairing", + state, + }; }; diff --git a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.types.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts similarity index 60% rename from packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.types.ts rename to packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts index 05de13601..7e22854eb 100644 --- a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.types.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts @@ -1,10 +1,11 @@ import { type GoogleConnectionState } from "@core/types/user.types"; -import { type ConnectionStatusIcon } from "@web/common/types/icon.types"; export type GoogleUiState = "checking" | "repairing" | GoogleConnectionState; export type CommandActionIcon = "CloudArrowUpIcon"; +export type IconColor = "muted" | "warning" | "error"; + export type GoogleUiConfig = { commandAction: { label: string; @@ -13,10 +14,21 @@ export type GoogleUiConfig = { onSelect?: () => void; }; sidebarStatus: { - icon: ConnectionStatusIcon; tooltip: string; tone?: "default" | "warning"; isDisabled: boolean; + iconColor?: IconColor; + dialog?: { + title: string; + description: string; + repairLabel: string; + onRepair: () => void; + }; onSelect?: () => void; }; }; + +export type UseConnectGoogleResult = GoogleUiConfig & { + isRepairing: boolean; + state: GoogleUiState; +}; diff --git a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.util.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts similarity index 79% rename from packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.util.ts rename to packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts index 507591152..1d8288707 100644 --- a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.util.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts @@ -29,7 +29,6 @@ export const getGoogleConnectionConfig = ( isDisabled: true, }, sidebarStatus: { - icon: "SpinnerIcon", tooltip: "Checking Google Calendar status…", tone: "default", isDisabled: true, @@ -43,10 +42,17 @@ export const getGoogleConnectionConfig = ( isDisabled: true, }, sidebarStatus: { - icon: "SpinnerIcon", + iconColor: "warning", tooltip: "Repairing Google Calendar in the background.", tone: "warning", isDisabled: true, + dialog: { + title: "Calendar sync needs repair", + description: + "Your Google Calendar has run into a sync issue. Repairing will re-import your recent events to make sure everything is up to date.", + repairLabel: "Repair", + onRepair: onRepairGoogle, + }, }, }; case "NOT_CONNECTED": @@ -58,7 +64,7 @@ export const getGoogleConnectionConfig = ( onSelect: onConnectGoogle, }, sidebarStatus: { - icon: "CloudArrowUpIcon", + iconColor: "muted", tooltip: "Google Calendar not connected. Click to connect.", tone: "default", isDisabled: false, @@ -74,7 +80,7 @@ export const getGoogleConnectionConfig = ( onSelect: onConnectGoogle, }, sidebarStatus: { - icon: "LinkBreakIcon", + iconColor: "error", tooltip: "Google Calendar needs reconnecting. Click to reconnect.", tone: "default", isDisabled: false, @@ -89,7 +95,6 @@ export const getGoogleConnectionConfig = ( isDisabled: true, }, sidebarStatus: { - icon: "SpinnerIcon", tooltip: "Google Calendar is syncing in the background.", tone: "default", isDisabled: true, @@ -104,11 +109,17 @@ export const getGoogleConnectionConfig = ( onSelect: onRepairGoogle, }, sidebarStatus: { - icon: "CloudWarningIcon", + iconColor: "warning", tooltip: "Google Calendar needs repair. Click to repair.", tone: "warning", isDisabled: false, - onSelect: onRepairGoogle, + dialog: { + title: "Calendar sync needs repair", + description: + "Your Google Calendar has run into a sync issue. Repairing will re-import your recent events to make sure everything is up to date.", + repairLabel: "Repair", + onRepair: onRepairGoogle, + }, }, }; case "HEALTHY": @@ -119,7 +130,7 @@ export const getGoogleConnectionConfig = ( isDisabled: true, }, sidebarStatus: { - icon: "LinkIcon", + iconColor: "muted", tooltip: "Google Calendar connected.", tone: "default", isDisabled: true, diff --git a/packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.test.ts b/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.test.ts similarity index 93% rename from packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.test.ts rename to packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.test.ts index adf2f389c..da11ae2de 100644 --- a/packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.test.ts @@ -1,23 +1,23 @@ import { toast } from "react-toastify"; import { renderHook, waitFor } from "@testing-library/react"; +import { useSession } from "@web/auth/compass/session/useSession"; +import { markUserAsAuthenticated } from "@web/auth/compass/state/auth.state.util"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; +import { useGoogleAuth } from "@web/auth/google/hooks/useGoogleAuth/useGoogleAuth"; +import { useGoogleLogin } from "@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"; import { authenticate, syncLocalEvents, -} from "@web/auth/google/google.auth.util"; -import { useGoogleAuth } from "@web/auth/hooks/google/useGoogleAuth/useGoogleAuth"; -import { useGoogleLogin } from "@web/auth/hooks/google/useGoogleLogin/useGoogleLogin"; -import { useSession } from "@web/auth/hooks/session/useSession"; -import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; -import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; +} from "@web/auth/google/util/google.auth.util"; import { useAppDispatch } from "@web/store/store.hooks"; import { type GoogleAuthConfig } from "../googe.auth.types"; // Mock dependencies -jest.mock("@web/auth/google/google.auth.util"); -jest.mock("@web/auth/hooks/session/useSession"); -jest.mock("@web/auth/session/user-metadata.util"); -jest.mock("@web/auth/hooks/google/useGoogleLogin/useGoogleLogin"); -jest.mock("@web/auth/state/auth.state.util"); +jest.mock("@web/auth/google/util/google.auth.util"); +jest.mock("@web/auth/compass/session/useSession"); +jest.mock("@web/auth/compass/user/util/user-metadata.util"); +jest.mock("@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"); +jest.mock("@web/auth/compass/state/auth.state.util"); jest.mock("@web/store/store.hooks", () => ({ useAppDispatch: jest.fn(), })); @@ -160,7 +160,7 @@ describe("useGoogleAuth", () => { }); describe("onStart callback", () => { - it("shows overlay immediately when login starts and clears prior import results", () => { + it("shows overlay immediately when login starts", () => { mockUseGoogleLogin.mockReturnValue({ login: mockLogin, loading: false, @@ -175,11 +175,6 @@ describe("useGoogleAuth", () => { expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ type: "auth/startAuthenticating" }), ); - expect(mockDispatchFn).toHaveBeenCalledWith( - expect.objectContaining({ - type: "async/importGCal/clearImportResults", - }), - ); expect(mockToast.dismiss).toHaveBeenCalledWith("session-expired-api"); }); }); diff --git a/packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.ts b/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts similarity index 86% rename from packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.ts rename to packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts index 11f620465..e653a0158 100644 --- a/packages/web/src/auth/hooks/google/useGoogleAuth/useGoogleAuth.ts +++ b/packages/web/src/auth/google/hooks/useGoogleAuth/useGoogleAuth.ts @@ -1,8 +1,8 @@ import { toast } from "react-toastify"; -import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; -import { authenticate } from "@web/auth/google/google.auth.util"; -import { useCompleteAuthentication } from "@web/auth/hooks/compass/useCompleteAuthentication"; -import { useGoogleAuthWithOverlay } from "@web/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay"; +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 { SESSION_EXPIRED_TOAST_ID, @@ -14,7 +14,6 @@ import { resetAuth, startAuthenticating, } from "@web/ducks/auth/slices/auth.slice"; -import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; import { type AppDispatch } from "@web/store"; import { useAppDispatch } from "@web/store/store.hooks"; import { type GoogleAuthConfig } from "../googe.auth.types"; @@ -52,7 +51,6 @@ export function useGoogleAuth( onStart: () => { dismissErrorToast(SESSION_EXPIRED_TOAST_ID); dispatch(startAuthenticating()); - dispatch(importGCalSlice.actions.clearImportResults(undefined)); }, onSuccess: async (data) => { if (onSuccess) { diff --git a/packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts b/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts similarity index 94% rename from packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts rename to packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts index b6a7864f4..a0db5d1a4 100644 --- a/packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts +++ b/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.test.ts @@ -1,9 +1,9 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { useGoogleAuthWithOverlay } from "@web/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay"; -import { useGoogleLogin } from "@web/auth/hooks/google/useGoogleLogin/useGoogleLogin"; +import { useGoogleAuthWithOverlay } from "@web/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay"; +import { useGoogleLogin } from "@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"; import { type GoogleAuthConfig } from "../googe.auth.types"; -jest.mock("@web/auth/hooks/google/useGoogleLogin/useGoogleLogin"); +jest.mock("@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"); const mockUseGoogleLogin = useGoogleLogin as jest.MockedFunction< typeof useGoogleLogin diff --git a/packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts b/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts similarity index 94% rename from packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts rename to packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts index 0018394ee..185eeffcb 100644 --- a/packages/web/src/auth/hooks/google/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts +++ b/packages/web/src/auth/google/hooks/useGoogleAuthWithOverlay/useGoogleAuthWithOverlay.ts @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { useGoogleLogin } from "@web/auth/hooks/google/useGoogleLogin/useGoogleLogin"; +import { useGoogleLogin } from "@web/auth/google/hooks/useGoogleLogin/useGoogleLogin"; import { type GoogleAuthConfig } from "../googe.auth.types"; interface UseGoogleAuthWithOverlayOptions { diff --git a/packages/web/src/auth/hooks/google/useGoogleLogin/useGoogleLogin.ts b/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts similarity index 96% rename from packages/web/src/auth/hooks/google/useGoogleLogin/useGoogleLogin.ts rename to packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts index e45696833..15f75cb10 100644 --- a/packages/web/src/auth/hooks/google/useGoogleLogin/useGoogleLogin.ts +++ b/packages/web/src/auth/google/hooks/useGoogleLogin/useGoogleLogin.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { useGoogleLogin as useGoogleLoginBase } from "@react-oauth/google"; -import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; +import { isGooglePopupClosedError } from "@web/auth/google/util/google.oauth.error.util"; import { type GoogleAuthConfig } from "../googe.auth.types"; const SCOPES_REQUIRED = [ diff --git a/packages/web/src/auth/google/google.auth.state.test.ts b/packages/web/src/auth/google/state/google.auth.state.test.ts similarity index 100% rename from packages/web/src/auth/google/google.auth.state.test.ts rename to packages/web/src/auth/google/state/google.auth.state.test.ts diff --git a/packages/web/src/auth/google/google.auth.state.ts b/packages/web/src/auth/google/state/google.auth.state.ts similarity index 100% rename from packages/web/src/auth/google/google.auth.state.ts rename to packages/web/src/auth/google/state/google.auth.state.ts diff --git a/packages/web/src/auth/google/state/google.sync.state.ts b/packages/web/src/auth/google/state/google.sync.state.ts new file mode 100644 index 000000000..f90260b8a --- /dev/null +++ b/packages/web/src/auth/google/state/google.sync.state.ts @@ -0,0 +1,50 @@ +// Transient Google sync UI overrides (e.g. repairing, post-connect syncing) live outside +// Redux so auth/sync helpers can update them from async callbacks without dispatch. +// This module is a minimal external store (mutable snapshot + listener set). Consumers +// subscribe with useSyncExternalStore(subscribeToGoogleSyncUIState, getGoogleSyncIndicatorOverride, …). + +const googleSyncUIListeners = new Set<() => void>(); + +type SyncIndicator = null | "repairing" | "syncing"; + +let gSyncIndicator: SyncIndicator = null; + +const emitGoogleSyncUIChange = () => { + googleSyncUIListeners.forEach((listener) => listener()); +}; + +const setGoogleSyncIndicatorOverride = (nextOverride: SyncIndicator) => { + if (gSyncIndicator === nextOverride) { + return; + } + + gSyncIndicator = nextOverride; + emitGoogleSyncUIChange(); +}; + +export const clearGoogleSyncIndicatorOverride = () => { + setGoogleSyncIndicatorOverride(null); +}; + +export const getGoogleSyncIndicatorOverride = () => gSyncIndicator; + +export const setRepairingSyncIndicatorOverride = () => { + setGoogleSyncIndicatorOverride("repairing"); +}; + +export const setSyncingSyncIndicatorOverride = () => { + setGoogleSyncIndicatorOverride("syncing"); +}; + +export const resetGoogleSyncUIStateForTests = () => { + gSyncIndicator = null; + emitGoogleSyncUIChange(); +}; + +export const subscribeToGoogleSyncUIState = (listener: () => void) => { + googleSyncUIListeners.add(listener); + + return () => { + googleSyncUIListeners.delete(listener); + }; +}; diff --git a/packages/web/src/auth/google/google.auth.util.test.ts b/packages/web/src/auth/google/util/google.auth.util.test.ts similarity index 89% rename from packages/web/src/auth/google/google.auth.util.test.ts rename to packages/web/src/auth/google/util/google.auth.util.test.ts index 28177ae9b..6eebac566 100644 --- a/packages/web/src/auth/google/google.auth.util.test.ts +++ b/packages/web/src/auth/google/util/google.auth.util.test.ts @@ -3,7 +3,7 @@ import { Origin } from "@core/constants/core.constants"; import { clearGoogleRevokedState, isGoogleRevoked, -} from "@web/auth/google/google.auth.state"; +} from "@web/auth/google/state/google.auth.state"; import { AuthApi } from "@web/common/apis/auth.api"; import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; @@ -11,13 +11,10 @@ import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; -import { - importGCalSlice, - triggerFetch, -} from "@web/ducks/events/slices/sync.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/google/googe.auth.types"; +import { type GoogleAuthConfig } from "../hooks/googe.auth.types"; import { LOCAL_EVENTS_SYNC_ERROR_MESSAGE, authenticate, @@ -152,41 +149,33 @@ describe("google-auth.util", () => { jest.restoreAllMocks(); }); - it("dispatches setLocalEventsSynced when sync succeeds with events", async () => { + it("returns true when sync succeeds with events", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(3); - const dispatch = jest.fn(); - const ok = await syncPendingLocalEvents(dispatch); + const ok = await syncPendingLocalEvents(); expect(ok).toBe(true); - expect(dispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setLocalEventsSynced(3), - ); }); - it("does not dispatch when syncedCount is zero", async () => { + it("returns true when syncedCount is zero", async () => { mockSyncLocalEventsToCloud.mockResolvedValue(0); - const dispatch = jest.fn(); - const ok = await syncPendingLocalEvents(dispatch); + const ok = await syncPendingLocalEvents(); expect(ok).toBe(true); - expect(dispatch).not.toHaveBeenCalled(); }); it("shows toast and returns false on sync failure", async () => { const error = new Error("fail"); mockSyncLocalEventsToCloud.mockRejectedValue(error); - const dispatch = jest.fn(); - const ok = await syncPendingLocalEvents(dispatch); + const ok = await syncPendingLocalEvents(); expect(ok).toBe(false); expect(toast.error).toHaveBeenCalledWith( LOCAL_EVENTS_SYNC_ERROR_MESSAGE, expect.anything(), ); - expect(dispatch).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/auth/google/google.auth.util.ts b/packages/web/src/auth/google/util/google.auth.util.ts similarity index 83% rename from packages/web/src/auth/google/google.auth.util.ts rename to packages/web/src/auth/google/util/google.auth.util.ts index 760653d22..d1b161ba5 100644 --- a/packages/web/src/auth/google/google.auth.util.ts +++ b/packages/web/src/auth/google/util/google.auth.util.ts @@ -1,7 +1,7 @@ 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/google.auth.state"; +import { markGoogleAsRevoked } from "@web/auth/google/state/google.auth.state"; import { AuthApi } from "@web/common/apis/auth.api"; import { GOOGLE_REVOKED_TOAST_ID, @@ -12,13 +12,10 @@ import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; -import { - importGCalSlice, - triggerFetch, -} from "@web/ducks/events/slices/sync.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; import { closeStream, openStream } from "@web/sse/client/sse.client"; -import { type AppDispatch, store } from "@web/store"; -import { type GoogleAuthConfig } from "../hooks/google/googe.auth.types"; +import { store } from "@web/store"; +import { type GoogleAuthConfig } from "../hooks/googe.auth.types"; export interface AuthenticateResult { success: boolean; @@ -98,12 +95,10 @@ export async function syncLocalEvents(): Promise { } /** - * Runs {@link syncLocalEvents}, surfaces failures with a toast, and records - * synced counts in Redux when migration succeeds. Returns whether sync succeeded. + * Runs {@link syncLocalEvents}, surfaces failures with a toast, and returns + * whether sync succeeded. */ -export async function syncPendingLocalEvents( - dispatch: AppDispatch, -): Promise { +export async function syncPendingLocalEvents(): Promise { const syncResult = await syncLocalEvents(); if (!syncResult.success) { @@ -111,11 +106,5 @@ export async function syncPendingLocalEvents( return false; } - if (syncResult.syncedCount > 0) { - dispatch( - importGCalSlice.actions.setLocalEventsSynced(syncResult.syncedCount), - ); - } - return true; } diff --git a/packages/web/src/auth/google/google-oauth-error.util.test.ts b/packages/web/src/auth/google/util/google.oauth.error.util.test.ts similarity index 86% rename from packages/web/src/auth/google/google-oauth-error.util.test.ts rename to packages/web/src/auth/google/util/google.oauth.error.util.test.ts index 46ea8958c..4ac48a4c4 100644 --- a/packages/web/src/auth/google/google-oauth-error.util.test.ts +++ b/packages/web/src/auth/google/util/google.oauth.error.util.test.ts @@ -1,4 +1,4 @@ -import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; +import { isGooglePopupClosedError } from "@web/auth/google/util/google.oauth.error.util"; describe("isGooglePopupClosedError", () => { it("returns true for non-oauth popup_closed type", () => { diff --git a/packages/web/src/auth/google/google-oauth-error.util.ts b/packages/web/src/auth/google/util/google.oauth.error.util.ts similarity index 100% rename from packages/web/src/auth/google/google-oauth-error.util.ts rename to packages/web/src/auth/google/util/google.oauth.error.util.ts diff --git a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.test.ts deleted file mode 100644 index b41ce3a93..000000000 --- a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.test.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { toast } from "react-toastify"; -import { renderHook, waitFor } from "@testing-library/react"; -import type * as GoogleAuthUtil from "@web/auth/google/google.auth.util"; -import { syncPendingLocalEvents } from "@web/auth/google/google.auth.util"; -import { useConnectGoogle } from "@web/auth/hooks/google/useConnectGoogle/useConnectGoogle"; -import { useGoogleAuth } from "@web/auth/hooks/google/useGoogleAuth/useGoogleAuth"; -import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; -import { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; -import { AuthApi } from "@web/common/apis/auth.api"; -import { SyncApi } from "@web/common/apis/sync.api"; -import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; -import { - selectGoogleConnectionState, - selectUserMetadataStatus, -} from "@web/ducks/auth/selectors/user-metadata.selectors"; -import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; -import { - importGCalSlice, - triggerFetch, -} from "@web/ducks/events/slices/sync.slice"; -import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; -import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; - -jest.mock("@web/auth/hooks/google/useGoogleAuth/useGoogleAuth"); -jest.mock("@web/auth/google/google.auth.util", () => ({ - ...jest.requireActual( - "@web/auth/google/google.auth.util", - ), - syncPendingLocalEvents: jest.fn(), -})); -jest.mock("@web/auth/session/user-metadata.util"); -jest.mock("@web/auth/state/auth.state.util"); -jest.mock("@web/common/apis/auth.api"); -jest.mock("@web/common/apis/sync.api"); -jest.mock("@web/common/utils/toast/error-toast.util"); -jest.mock("@web/store/store.hooks"); -jest.mock("react-toastify", () => ({ - toast: { - error: jest.fn(), - }, -})); - -const mockUseGoogleAuth = useGoogleAuth as jest.MockedFunction< - typeof useGoogleAuth ->; -const mockSyncPendingLocalEvents = - syncPendingLocalEvents as jest.MockedFunction; -const mockShowErrorToast = showErrorToast as jest.MockedFunction< - typeof showErrorToast ->; -const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< - typeof useAppDispatch ->; -const mockUseAppSelector = useAppSelector as jest.MockedFunction< - typeof useAppSelector ->; -const mockHasUserEverAuthenticated = - hasUserEverAuthenticated as jest.MockedFunction< - typeof hasUserEverAuthenticated - >; -const mockAuthApi = AuthApi as jest.Mocked; -const mockRefreshUserMetadata = refreshUserMetadata as jest.MockedFunction< - typeof refreshUserMetadata ->; -const mockSyncApi = SyncApi as jest.Mocked; -const mockToastError = toast.error as jest.MockedFunction; - -const getUseGoogleAuthArg = (): NonNullable< - Parameters[0] -> => { - const firstCall = mockUseGoogleAuth.mock.calls.at(0); - - if (!firstCall) { - throw new Error("Expected useGoogleAuth to be called"); - } - - return firstCall[0] ?? {}; -}; - -describe("useConnectGoogle", () => { - const mockDispatch = jest.fn(); - const mockLogin = jest.fn(); - - const expectGoogleAuthConfig = () => { - const arg = getUseGoogleAuthArg(); - - expect(arg.prompt).toBe("consent"); - expect(typeof arg.onSuccess).toBe("function"); - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(console, "error").mockImplementation(() => {}); - mockUseAppDispatch.mockReturnValue(mockDispatch); - mockUseGoogleAuth.mockReturnValue({ - login: mockLogin, - data: null, - loading: false, - }); - mockAuthApi.connectGoogle.mockResolvedValue({ status: "OK" }); - mockHasUserEverAuthenticated.mockReturnValue(true); - mockRefreshUserMetadata.mockResolvedValue(); - mockSyncApi.importGCal.mockResolvedValue(undefined as never); - mockSyncPendingLocalEvents.mockResolvedValue(true); - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "NOT_CONNECTED"; - } - - if (selector === selectUserMetadataStatus) { - return "loading"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - }); - - it("returns checking state when metadata is still loading", () => { - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe( - "Checking Google Calendar…", - ); - expect(result.current.commandAction.isDisabled).toBe(true); - expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); - expect(result.current.sidebarStatus.tooltip).toBe( - "Checking Google Calendar status…", - ); - }); - - it("returns checking state when metadata is idle before refresh", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "NOT_CONNECTED"; - } - - if (selector === selectUserMetadataStatus) { - return "idle"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expect(result.current.commandAction.label).toBe( - "Checking Google Calendar…", - ); - expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); - }); - - it("returns connect state when metadata is loaded and Google is not connected", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "NOT_CONNECTED"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); - expect(result.current.commandAction.isDisabled).toBe(false); - expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); - expect(result.current.sidebarStatus.tooltip).toBe( - "Google Calendar not connected. Click to connect.", - ); - }); - - it("returns connected state when metadata is healthy", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "HEALTHY"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe( - "Google Calendar Connected", - ); - expect(result.current.commandAction.isDisabled).toBe(true); - expect(result.current.commandAction.onSelect).toBeUndefined(); - expect(result.current.sidebarStatus.icon).toBe("LinkIcon"); - expect(result.current.sidebarStatus.isDisabled).toBe(true); - }); - - it("returns reconnect state when refresh token is missing", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "RECONNECT_REQUIRED"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe( - "Reconnect Google Calendar", - ); - expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); - - result.current.commandAction.onSelect?.(); - - expect(mockLogin).toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith( - settingsSlice.actions.closeCmdPalette(), - ); - }); - - it("returns syncing state while import is running", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "IMPORTING"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); - expect(result.current.commandAction.isDisabled).toBe(true); - expect(result.current.commandAction.onSelect).toBeUndefined(); - expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); - expect(result.current.sidebarStatus.isDisabled).toBe(true); - }); - - it("returns repair state when sync needs attention", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "ATTENTION"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe("Repair Google Calendar"); - expect(result.current.commandAction.isDisabled).toBe(false); - expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); - expect(result.current.sidebarStatus.tooltip).toBe( - "Google Calendar needs repair. Click to repair.", - ); - - result.current.sidebarStatus.onSelect?.(); - - expect(mockSyncApi.importGCal).toHaveBeenCalledWith({ force: true }); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.clearImportResults(undefined), - ); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.startRepair(), - ); - expect(mockDispatch).toHaveBeenCalledWith( - settingsSlice.actions.closeCmdPalette(), - ); - - jest.clearAllMocks(); - - result.current.commandAction.onSelect?.(); - - expect(mockSyncApi.importGCal).toHaveBeenCalledWith({ force: true }); - expect(mockDispatch).toHaveBeenCalledWith( - settingsSlice.actions.closeCmdPalette(), - ); - }); - - it("returns repairing state while a repair is active", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "ATTENTION"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: true }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expect(result.current.commandAction.label).toBe( - "Repairing Google Calendar…", - ); - expect(result.current.commandAction.isDisabled).toBe(true); - expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); - expect(result.current.sidebarStatus.tone).toBe("warning"); - expect(result.current.sidebarStatus.tooltip).toBe( - "Repairing Google Calendar in the background.", - ); - }); - - it("shows a toast and clears repair state when the repair request fails to start", async () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "ATTENTION"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - mockSyncApi.importGCal.mockRejectedValueOnce(new Error("boom")); - - const { result } = renderHook(() => useConnectGoogle()); - - result.current.commandAction.onSelect?.(); - - await waitFor(() => - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setImportError( - "Google Calendar repair failed. Please try again.", - ), - ), - ); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.stopRepair(), - ); - expect(mockShowErrorToast).toHaveBeenCalledWith( - "Google Calendar repair failed. Please try again.", - { toastId: "google-repair-failed" }, - ); - }); - - it("shows connect state when server says not connected", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "NOT_CONNECTED"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); - expect(result.current.commandAction.isDisabled).toBe(false); - expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); - expect(result.current.sidebarStatus.isDisabled).toBe(false); - }); - - it("shows reconnect_required state from the server", () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "RECONNECT_REQUIRED"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe( - "Reconnect Google Calendar", - ); - expect(result.current.sidebarStatus.icon).toBe("LinkBreakIcon"); - expect(result.current.commandAction.isDisabled).toBe(false); - }); - - it("returns connect state when metadata is missing for a never-authenticated user", () => { - mockHasUserEverAuthenticated.mockReturnValue(false); - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "NOT_CONNECTED"; - } - - if (selector === selectUserMetadataStatus) { - return "idle"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - - const { result } = renderHook(() => useConnectGoogle()); - - expectGoogleAuthConfig(); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); - expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); - }); - - it("connects Google through the backend endpoint and refreshes metadata", async () => { - renderHook(() => useConnectGoogle()); - - const useGoogleAuthArg = getUseGoogleAuthArg(); - if (!useGoogleAuthArg?.onSuccess) { - throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); - } - - const payload = { - clientType: "web" as const, - thirdPartyId: "google" as const, - redirectURIInfo: { - redirectURIOnProviderDashboard: window.location.origin, - redirectURIQueryParams: { - code: "auth-code", - scope: "scope", - state: "state", - }, - }, - }; - - await useGoogleAuthArg.onSuccess(payload); - - expect(mockSyncPendingLocalEvents).toHaveBeenCalledTimes(1); - expect(mockAuthApi.connectGoogle).toHaveBeenCalledWith(payload); - expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith(triggerFetch()); - }); - - it("shows the server message when Google connect fails and keeps the connect action visible", async () => { - mockUseAppSelector.mockImplementation((selector) => { - if (selector === selectGoogleConnectionState) { - return "NOT_CONNECTED"; - } - - if (selector === selectUserMetadataStatus) { - return "loaded"; - } - - if (selector === selectImportGCalState) { - return { isRepairing: false }; - } - - return undefined; - }); - mockAuthApi.connectGoogle.mockRejectedValueOnce({ - isAxiosError: true, - response: { - data: { - code: "GOOGLE_ACCOUNT_ALREADY_CONNECTED", - message: - "Google account is already connected to another Compass user", - }, - }, - } as never); - - const { result } = renderHook(() => useConnectGoogle()); - const useGoogleAuthArg = getUseGoogleAuthArg(); - if (!useGoogleAuthArg?.onSuccess) { - throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); - } - - const payload = { - clientType: "web" as const, - thirdPartyId: "google" as const, - redirectURIInfo: { - redirectURIOnProviderDashboard: window.location.origin, - redirectURIQueryParams: { - code: "auth-code", - scope: "scope", - state: "state", - }, - }, - }; - - await expect(useGoogleAuthArg.onSuccess(payload)).resolves.toBe(false); - - expect(mockShowErrorToast).toHaveBeenCalledWith( - "Google account is already connected to another Compass user", - ); - expect(mockRefreshUserMetadata).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalledWith(triggerFetch()); - expect(result.current.commandAction.label).toBe("Connect Google Calendar"); - }); - - it("records synced local events before refreshing Google data", async () => { - mockSyncPendingLocalEvents.mockImplementation((dispatch) => { - dispatch(importGCalSlice.actions.setLocalEventsSynced(2)); - return Promise.resolve(true); - }); - - renderHook(() => useConnectGoogle()); - - const useGoogleAuthArg = getUseGoogleAuthArg(); - if (!useGoogleAuthArg?.onSuccess) { - throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); - } - - const payload = { - clientType: "web" as const, - thirdPartyId: "google" as const, - redirectURIInfo: { - redirectURIOnProviderDashboard: window.location.origin, - redirectURIQueryParams: { - code: "auth-code", - scope: "scope", - state: "state", - }, - }, - }; - - await useGoogleAuthArg.onSuccess(payload); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setLocalEventsSynced(2), - ); - expect(mockAuthApi.connectGoogle).toHaveBeenCalledWith(payload); - }); - - it("does not connect Google when local event sync fails", async () => { - const { showLocalEventsSyncFailure } = jest.requireActual< - typeof GoogleAuthUtil - >("@web/auth/google/google.auth.util"); - mockSyncPendingLocalEvents.mockImplementation(() => { - showLocalEventsSyncFailure(new Error("sync failed")); - return Promise.resolve(false); - }); - - renderHook(() => useConnectGoogle()); - - const useGoogleAuthArg = getUseGoogleAuthArg(); - if (!useGoogleAuthArg?.onSuccess) { - throw new Error("Expected useGoogleAuth to receive an onSuccess handler"); - } - - const payload = { - clientType: "web" as const, - thirdPartyId: "google" as const, - redirectURIInfo: { - redirectURIOnProviderDashboard: window.location.origin, - redirectURIQueryParams: { - code: "auth-code", - scope: "scope", - state: "state", - }, - }, - }; - - await useGoogleAuthArg.onSuccess(payload); - - expect(mockToastError).toHaveBeenCalled(); - expect(mockAuthApi.connectGoogle).not.toHaveBeenCalled(); - expect(mockRefreshUserMetadata).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalledWith(triggerFetch()); - }); -}); diff --git a/packages/web/src/auth/posthog/posthog.util.ts b/packages/web/src/auth/posthog/posthog.util.ts new file mode 100644 index 000000000..9bb9c41ef --- /dev/null +++ b/packages/web/src/auth/posthog/posthog.util.ts @@ -0,0 +1,5 @@ +import { ENV_WEB } from "@web/common/constants/env.constants"; + +export function isPosthogEnabled() { + return !!ENV_WEB.POSTHOG_HOST && !!ENV_WEB.POSTHOG_KEY; +} diff --git a/packages/web/src/auth/posthog/useIdentifyUser.test.ts b/packages/web/src/auth/posthog/useIdentifyUser.test.ts new file mode 100644 index 000000000..37314039f --- /dev/null +++ b/packages/web/src/auth/posthog/useIdentifyUser.test.ts @@ -0,0 +1,89 @@ +import { type PostHog } from "posthog-js"; +import { usePostHog } from "posthog-js/react"; +import "@testing-library/jest-dom"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useIdentifyUser } from "./useIdentifyUser"; + +jest.mock("posthog-js/react"); +const mockUsePostHog = jest.mocked(usePostHog); + +const mockIdentify = jest.fn(); + +function mockPostHogEnabled(overrides?: Partial): void { + mockUsePostHog.mockReturnValue({ + identify: mockIdentify, + ...overrides, + } as unknown as PostHog); +} + +function mockPostHogDisabled(): void { + mockUsePostHog.mockReturnValue(undefined as unknown as PostHog); +} + +describe("useIdentifyUser", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPostHogEnabled(); + }); + + it("calls posthog.identify when PostHog is enabled and user data is available", async () => { + const testUserId = "test-user-123"; + const testEmail = "test@example.com"; + + renderHook(() => useIdentifyUser(testEmail, testUserId)); + + await waitFor(() => { + expect(mockIdentify).toHaveBeenCalledWith(testEmail, { + email: testEmail, + userId: testUserId, + }); + }); + expect(mockIdentify).toHaveBeenCalledTimes(1); + }); + + it("does not call posthog.identify when PostHog is disabled", async () => { + mockPostHogDisabled(); + + renderHook(() => useIdentifyUser("test@example.com", "user-1")); + + await waitFor(() => { + expect(mockUsePostHog).toHaveBeenCalled(); + }); + + expect(mockIdentify).not.toHaveBeenCalled(); + }); + + it("does not call posthog.identify when email is null", async () => { + renderHook(() => useIdentifyUser(null, "test-user-123")); + + await waitFor(() => { + expect(mockUsePostHog).toHaveBeenCalled(); + }); + + expect(mockIdentify).not.toHaveBeenCalled(); + }); + + it("does not call posthog.identify when userId is null", async () => { + renderHook(() => useIdentifyUser("test@example.com", null)); + + await waitFor(() => { + expect(mockUsePostHog).toHaveBeenCalled(); + }); + + expect(mockIdentify).not.toHaveBeenCalled(); + }); + + it("handles posthog.identify not being a function gracefully", async () => { + mockPostHogEnabled({ + identify: null as unknown as PostHog["identify"], + }); + + expect(() => { + renderHook(() => useIdentifyUser("test@example.com", "user-1")); + }).not.toThrow(); + + await waitFor(() => { + expect(mockIdentify).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/web/src/auth/posthog/useIdentifyUser.ts b/packages/web/src/auth/posthog/useIdentifyUser.ts new file mode 100644 index 000000000..48b097069 --- /dev/null +++ b/packages/web/src/auth/posthog/useIdentifyUser.ts @@ -0,0 +1,22 @@ +import { usePostHog } from "posthog-js/react"; +import { useEffect } from "react"; + +/** + * Identifies the user in PostHog when `userId` and profile email are available. + */ +export function useIdentifyUser( + profileEmail: string | null, + userId: string | null, +): void { + const posthog = usePostHog(); + useEffect(() => { + if ( + userId && + profileEmail && + posthog && + typeof posthog.identify === "function" + ) { + posthog.identify(profileEmail, { email: profileEmail, userId }); + } + }, [profileEmail, posthog, userId]); +} diff --git a/packages/web/src/common/apis/auth.api.ts b/packages/web/src/common/apis/auth.api.ts index f44948ceb..83bc27926 100644 --- a/packages/web/src/common/apis/auth.api.ts +++ b/packages/web/src/common/apis/auth.api.ts @@ -3,7 +3,7 @@ import { type GoogleConnectResponse, type Result_Auth_Compass, } from "@core/types/auth.types"; -import { type GoogleAuthConfig } from "@web/auth/hooks/google/googe.auth.types"; +import { type GoogleAuthConfig } from "@web/auth/google/hooks/googe.auth.types"; import { CompassApi } from "@web/common/apis/compass.api"; const AuthApi = { diff --git a/packages/web/src/common/apis/compass.api.ts b/packages/web/src/common/apis/compass.api.ts index 2e0a37921..7654a2fc8 100644 --- a/packages/web/src/common/apis/compass.api.ts +++ b/packages/web/src/common/apis/compass.api.ts @@ -6,7 +6,7 @@ import { session } from "@web/common/classes/Session"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { showSessionExpiredToast } from "@web/common/utils/toast/error-toast.util"; -import { handleGoogleRevoked } from "../../auth/google/google.auth.util"; +import { handleGoogleRevoked } from "../../auth/google/util/google.auth.util"; export const CompassApi = axios.create({ baseURL: ENV_WEB.API_BASEURL, diff --git a/packages/web/src/common/constants/auth.constants.ts b/packages/web/src/common/constants/auth.constants.ts index d7888a89c..5c845962a 100644 --- a/packages/web/src/common/constants/auth.constants.ts +++ b/packages/web/src/common/constants/auth.constants.ts @@ -3,12 +3,14 @@ import { z } from "zod"; export const AuthStateSchema = z.object({ hasAuthenticated: z.boolean().default(false), lastKnownEmail: z.string().email().optional(), + shouldPromptSignUpAfterAnonymousCalendarChange: z.boolean().default(false), }); export type AuthState = z.infer; export const DEFAULT_AUTH_STATE: AuthState = { hasAuthenticated: false, + shouldPromptSignUpAfterAnonymousCalendarChange: false, }; export const UNAUTHENTICATED_USER = "UNAUTHENTICATED_USER"; diff --git a/packages/web/src/common/hooks/useAuthCmdItems.test.ts b/packages/web/src/common/hooks/useAuthCmdItems.test.ts index 5d755c8a9..d10f9b9ec 100644 --- a/packages/web/src/common/hooks/useAuthCmdItems.test.ts +++ b/packages/web/src/common/hooks/useAuthCmdItems.test.ts @@ -1,11 +1,11 @@ import type { MouseEvent } from "react"; import { act } from "react"; import { renderHook } from "@testing-library/react"; -import { useSession } from "@web/auth/hooks/session/useSession"; +import { useSession } from "@web/auth/compass/session/useSession"; import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; import { useAuthCmdItems } from "./useAuthCmdItems"; -jest.mock("@web/auth/hooks/session/useSession", () => ({ +jest.mock("@web/auth/compass/session/useSession", () => ({ useSession: jest.fn(), })); diff --git a/packages/web/src/common/hooks/useAuthCmdItems.ts b/packages/web/src/common/hooks/useAuthCmdItems.ts index 28c982d6c..98a1b4c52 100644 --- a/packages/web/src/common/hooks/useAuthCmdItems.ts +++ b/packages/web/src/common/hooks/useAuthCmdItems.ts @@ -1,5 +1,5 @@ import { type JsonStructureItem } from "react-cmdk"; -import { useSession } from "@web/auth/hooks/session/useSession"; +import { useSession } from "@web/auth/compass/session/useSession"; import { useAuthFeatureFlag } from "@web/components/AuthModal/hooks/useAuthFeatureFlag"; import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; diff --git a/packages/web/src/common/hooks/useMainGridSelectionActions.ts b/packages/web/src/common/hooks/useMainGridSelectionActions.ts index 1ceed47e5..01ce5bc55 100644 --- a/packages/web/src/common/hooks/useMainGridSelectionActions.ts +++ b/packages/web/src/common/hooks/useMainGridSelectionActions.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { Priorities } from "@core/constants/core.constants"; -import { getUserId } from "@web/auth/session/session.util"; +import { getUserId } from "@web/auth/compass/session/session.util"; import { CursorItem, openFloatingAtCursor, diff --git a/packages/web/src/common/repositories/event/event.repository.util.test.ts b/packages/web/src/common/repositories/event/event.repository.util.test.ts index a1059d3a2..7b74c0794 100644 --- a/packages/web/src/common/repositories/event/event.repository.util.test.ts +++ b/packages/web/src/common/repositories/event/event.repository.util.test.ts @@ -1,12 +1,12 @@ -import * as googleAuthState from "@web/auth/google/google.auth.state"; -import * as authStateUtil from "@web/auth/state/auth.state.util"; +import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; +import * as googleAuthState from "@web/auth/google/state/google.auth.state"; import { getEventRepository } from "./event.repository.util"; import { LocalEventRepository } from "./local.event.repository"; import { RemoteEventRepository } from "./remote.event.repository"; jest.mock("@web/common/classes/Session"); -jest.mock("@web/auth/google/google.auth.state"); -jest.mock("@web/auth/state/auth.state.util"); +jest.mock("@web/auth/google/state/google.auth.state"); +jest.mock("@web/auth/compass/state/auth.state.util"); describe("getEventRepository", () => { beforeEach(() => { diff --git a/packages/web/src/common/repositories/event/event.repository.util.ts b/packages/web/src/common/repositories/event/event.repository.util.ts index f4b650db0..25ac9a5c2 100644 --- a/packages/web/src/common/repositories/event/event.repository.util.ts +++ b/packages/web/src/common/repositories/event/event.repository.util.ts @@ -1,5 +1,5 @@ -import { isGoogleRevoked } from "@web/auth/google/google.auth.state"; -import { hasUserEverAuthenticated } from "@web/auth/state/auth.state.util"; +import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; +import { isGoogleRevoked } from "@web/auth/google/state/google.auth.state"; import { type EventRepository } from "./event.repository.interface"; import { LocalEventRepository } from "./local.event.repository"; import { RemoteEventRepository } from "./remote.event.repository"; diff --git a/packages/web/src/common/types/icon.types.ts b/packages/web/src/common/types/icon.types.ts deleted file mode 100644 index 1b47c8f8e..000000000 --- a/packages/web/src/common/types/icon.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type ConnectionStatusIcon = - | "CloudArrowUpIcon" - | "LinkBreakIcon" - | "LinkIcon" - | "SpinnerIcon" - | "CloudWarningIcon"; diff --git a/packages/web/src/common/utils/draft/draft.util.test.ts b/packages/web/src/common/utils/draft/draft.util.test.ts index 41c3fe23e..414e68b9f 100644 --- a/packages/web/src/common/utils/draft/draft.util.test.ts +++ b/packages/web/src/common/utils/draft/draft.util.test.ts @@ -1,7 +1,7 @@ import { Categories_Event } from "@core/types/event.types"; import { assembleDefaultEvent } from "../event/event.util"; -jest.mock("@web/auth/session/session.util", () => ({ +jest.mock("@web/auth/compass/session/session.util", () => ({ getUserId: jest.fn().mockResolvedValue("mock-user-id"), })); describe("assembleDefaultEvent", () => { diff --git a/packages/web/src/common/utils/event/event.util.ts b/packages/web/src/common/utils/event/event.util.ts index 4c33e8d5b..eec52070f 100644 --- a/packages/web/src/common/utils/event/event.util.ts +++ b/packages/web/src/common/utils/event/event.util.ts @@ -8,7 +8,7 @@ import { type Schema_Event_Recur_Base, } from "@core/types/event.types"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; -import { getUserId } from "@web/auth/session/session.util"; +import { getUserId } from "@web/auth/compass/session/session.util"; import { CLASS_TIMED_CALENDAR_EVENT, DATA_EVENT_ELEMENT_ID, diff --git a/packages/web/src/common/utils/overlap/overlap.test.ts b/packages/web/src/common/utils/overlap/overlap.test.ts index 873f49304..a9e76dd6d 100644 --- a/packages/web/src/common/utils/overlap/overlap.test.ts +++ b/packages/web/src/common/utils/overlap/overlap.test.ts @@ -1,5 +1,5 @@ import { Categories_Event } from "@core/types/event.types"; -import { getUserId } from "@web/auth/session/session.util"; +import { getUserId } from "@web/auth/compass/session/session.util"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { assembleDefaultEvent, @@ -7,7 +7,7 @@ import { } from "../event/event.util"; import { adjustOverlappingEvents, getOverlappingStyles } from "./overlap"; -jest.mock("@web/auth/session/session.util", () => ({ +jest.mock("@web/auth/compass/session/session.util", () => ({ getUserId: jest.fn(), })); diff --git a/packages/web/src/components/AuthModal/AccountIcon.tsx b/packages/web/src/components/AuthModal/AccountIcon.tsx deleted file mode 100644 index f7ac7cccf..000000000 --- a/packages/web/src/components/AuthModal/AccountIcon.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { type FC } from "react"; -import { UserCircleDashedIcon, UserCircleIcon } from "@phosphor-icons/react"; -import { useSession } from "@web/auth/hooks/session/useSession"; -import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag"; -import { useAuthModal } from "./hooks/useAuthModal"; - -/** - * Account icon button for triggering the auth modal - * - */ -export const AccountIcon: FC = () => { - const { authenticated } = useSession(); - const isEnabled = useAuthFeatureFlag(); - const { openModal } = useAuthModal(); - - // Don't show if user is already authenticated or feature is disabled - if (authenticated || !isEnabled) { - return null; - } - - const handleClick = () => { - openModal("login"); - }; - - const tipDescription = authenticated ? "You're logged in" : "Log in"; - - return ( - - {authenticated ? ( - - ) : ( - - )} - - ); -}; diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index e7fdc254a..23223c781 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -22,20 +22,20 @@ const mockUseSession = jest.fn(() => ({ setAuthenticated: jest.fn(), })); -jest.mock("@web/auth/hooks/session/useSession", () => ({ +jest.mock("@web/auth/compass/session/useSession", () => ({ useSession: () => mockUseSession(), })); // Mock useGoogleAuth const mockGoogleLogin = jest.fn(); -jest.mock("@web/auth/hooks/google/useGoogleAuth/useGoogleAuth", () => ({ +jest.mock("@web/auth/google/hooks/useGoogleAuth/useGoogleAuth", () => ({ useGoogleAuth: () => ({ login: mockGoogleLogin, }), })); const mockCompleteAuthentication = jest.fn(); -jest.mock("@web/auth/hooks/compass/useCompleteAuthentication", () => ({ +jest.mock("@web/auth/compass/hooks/useCompleteAuthentication", () => ({ useCompleteAuthentication: () => mockCompleteAuthentication, })); @@ -934,18 +934,6 @@ describe("URL Parameter Support", () => { ).not.toBeInTheDocument(); }); - it("implicitly enables auth feature when ?auth param is present", async () => { - mockWindowLocation("/?auth=signup"); - renderWithProviders(, "/?auth=signup"); - - await waitFor(() => { - // The auth modal should open - expect( - screen.getByRole("heading", { name: /nice to meet you/i }), - ).toBeInTheDocument(); - }); - }); - it("works on different routes", async () => { mockWindowLocation("/week?auth=signup"); renderWithProviders(
, "/week?auth=signup"); @@ -1047,66 +1035,3 @@ describe("URL Parameter Support", () => { }); }); }); - -describe("AccountIcon", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("renders when user is not authenticated and feature flag is enabled", async () => { - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - - renderWithProviders(, "/day?auth=signup"); - - await waitFor(() => { - expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); - }); - }); - - it("shows 'Log in' when user is not authenticated", () => { - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - - renderWithProviders(, "/day?auth=signup"); - - expect(screen.getAllByText("Log in").length).toBeGreaterThan(0); - }); - - it("does not render when feature flag is disabled", () => { - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - - renderWithProviders(, "/day"); - - expect(screen.queryByLabelText(/log in/i)).not.toBeInTheDocument(); - }); - - it("opens modal when clicked", async () => { - const user = userEvent.setup(); - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - - renderWithProviders(, "/day?auth=signup"); - - await waitFor(() => { - expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); - }); - - await user.click(screen.getByLabelText(/log in/i)); - - await waitFor(() => { - expect( - screen.getByRole("heading", { name: /hey, welcome back/i }), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index d2e286d7e..9e00ffc86 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -1,6 +1,6 @@ import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { DotIcon } from "@phosphor-icons/react"; -import { useGoogleAuth } from "@web/auth/hooks/google/useGoogleAuth/useGoogleAuth"; +import { useGoogleAuth } from "@web/auth/google/hooks/useGoogleAuth/useGoogleAuth"; import { GoogleButton } from "@web/components/AuthModal/components/GoogleButton"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; import { AuthButton } from "./components/AuthButton"; diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx index 36996028f..d0dbb2ec4 100644 --- a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, type FC, useState } from "react"; import { type ForgotPasswordFormData, ForgotPasswordSchema, -} from "@web/auth/schemas/auth.schemas"; +} from "@web/auth/compass/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; import { useZodForm } from "../hooks/useZodForm"; diff --git a/packages/web/src/components/AuthModal/forms/LogInForm.tsx b/packages/web/src/components/AuthModal/forms/LogInForm.tsx index 9da4323bf..58e9fa415 100644 --- a/packages/web/src/components/AuthModal/forms/LogInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/LogInForm.tsx @@ -2,7 +2,7 @@ import { type FC } from "react"; import { type LogInFormData, LogInSchema, -} from "@web/auth/schemas/auth.schemas"; +} from "@web/auth/compass/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; import { useZodForm } from "../hooks/useZodForm"; diff --git a/packages/web/src/components/AuthModal/forms/ResetPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ResetPasswordForm.tsx index 10d857f00..074535246 100644 --- a/packages/web/src/components/AuthModal/forms/ResetPasswordForm.tsx +++ b/packages/web/src/components/AuthModal/forms/ResetPasswordForm.tsx @@ -2,7 +2,7 @@ import { type FC } from "react"; import { type ResetPasswordFormData, ResetPasswordSchema, -} from "@web/auth/schemas/auth.schemas"; +} from "@web/auth/compass/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; import { useZodForm } from "../hooks/useZodForm"; diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx index c0843ac4f..bf69ba336 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, type FC, useCallback } from "react"; import { type SignUpFormData, SignUpSchema, -} from "@web/auth/schemas/auth.schemas"; +} from "@web/auth/compass/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; import { useZodForm } from "../hooks/useZodForm"; diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.test.tsx b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.test.tsx index e6f6391f6..57ec1e900 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.test.tsx +++ b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from "@testing-library/react"; -import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; +import { markUserAsAuthenticated } from "@web/auth/compass/state/auth.state.util"; import { useAuthFeatureFlag } from "./useAuthFeatureFlag"; const setWindowLocation = (url: string) => { diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts index ea704a278..db268ada2 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts @@ -1,4 +1,4 @@ -import { getLastKnownEmail } from "@web/auth/state/auth.state.util"; +import { getLastKnownEmail } from "@web/auth/compass/state/auth.state.util"; /** * Feature flag hook for email/password authentication UI diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts index ec039258c..5c0fbc6ba 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts @@ -1,13 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import EmailPassword from "supertokens-web-js/recipe/emailpassword"; import { z } from "zod"; -import { useCompleteAuthentication } from "@web/auth/hooks/compass/useCompleteAuthentication"; +import { useCompleteAuthentication } from "@web/auth/compass/hooks/useCompleteAuthentication"; import { type ForgotPasswordFormData, type LogInFormData, type ResetPasswordFormData, type SignUpFormData, -} from "@web/auth/schemas/auth.schemas"; +} from "@web/auth/compass/schemas/auth.schemas"; import { type AuthView } from "./useAuthModal"; const AUTH_TOKEN_QUERY_SCHEMA = z.object({ diff --git a/packages/web/src/components/CompassProvider/CompassProvider.tsx b/packages/web/src/components/CompassProvider/CompassProvider.tsx index a0c541405..ef2a9feae 100644 --- a/packages/web/src/components/CompassProvider/CompassProvider.tsx +++ b/packages/web/src/components/CompassProvider/CompassProvider.tsx @@ -5,7 +5,8 @@ import { ToastContainer } from "react-toastify"; import { ThemeProvider } from "styled-components"; import { GoogleOAuthProvider } from "@react-oauth/google"; import { HotkeysProvider } from "@tanstack/react-hotkeys"; -import { SessionProvider } from "@web/auth/session/SessionProvider"; +import { SessionProvider } from "@web/auth/compass/session/SessionProvider"; +import { isPosthogEnabled } from "@web/auth/posthog/posthog.util"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { CompassRefsProvider } from "@web/common/context/compass-refs"; import { PointerPositionProvider } from "@web/common/context/pointer-position"; @@ -18,10 +19,6 @@ import { IconProvider } from "@web/components/IconProvider/IconProvider"; import { store } from "@web/store"; import { useGlobalShortcuts } from "@web/views/Calendar/hooks/shortcuts/useGlobalShortcuts"; -function isPosthogEnabled() { - return !!ENV_WEB.POSTHOG_HOST && !!ENV_WEB.POSTHOG_KEY; -} - /** * Mount once under {@link HotkeysProvider} and inside React Router so * {@link useGlobalShortcuts} can register app hotkeys (via useAppHotkey). diff --git a/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx b/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx new file mode 100644 index 000000000..bb885991d --- /dev/null +++ b/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.test.tsx @@ -0,0 +1,223 @@ +import type React from "react"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { type CompassSession } from "@web/auth/compass/session/session.types"; +import { + type GoogleUiConfig, + type GoogleUiState, +} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type AuthView } from "@web/components/AuthModal/hooks/useAuthModal"; +import { HeaderInfoIcon } from "./HeaderInfoIcon"; + +interface MockConnectGoogleResult { + commandAction: GoogleUiConfig["commandAction"]; + isRepairing: boolean; + sidebarStatus: GoogleUiConfig["sidebarStatus"]; + state: GoogleUiState; +} + +const mockOpenModal = jest.fn(); +const mockGoogleOnSelect = jest.fn(); +const mockUseSession = jest.fn(); +const mockUseConnectGoogle = jest.fn(); +const mockShouldShowAnonymousCalendarChangeSignUpPrompt = jest.fn< + boolean, + [] +>(); +const mockSubscribeToAuthState = jest.fn<() => void, [() => void]>( + () => () => {}, +); + +jest.mock("@web/auth/compass/session/useSession", () => ({ + useSession: (): CompassSession => mockUseSession(), +})); + +jest.mock("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ + useConnectGoogle: (): MockConnectGoogleResult => mockUseConnectGoogle(), +})); + +jest.mock("@web/auth/compass/state/auth.state.util", () => ({ + shouldShowAnonymousCalendarChangeSignUpPrompt: (): boolean => + mockShouldShowAnonymousCalendarChangeSignUpPrompt(), + subscribeToAuthState: (listener: () => void): (() => void) => + mockSubscribeToAuthState(listener), +})); + +jest.mock("@web/components/AuthModal/hooks/useAuthModal", () => ({ + useAuthModal: () => ({ + openModal: mockOpenModal, + }), +})); + +jest.mock("@phosphor-icons/react", () => ({ + InfoIcon: ({ className }: { className?: string }) => ( + {className ?? ""} + ), + SpinnerGapIcon: () => , +})); + +jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ + TooltipWrapper: ({ + children, + description, + disabled, + onClick, + }: { + children: React.ReactNode; + description?: string; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), +})); + +describe("HeaderInfoIcon", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + mockUseConnectGoogle.mockReturnValue({ + commandAction: { + icon: "CloudArrowUpIcon", + isDisabled: false, + label: "Reconnect Google Calendar", + onSelect: mockGoogleOnSelect, + }, + isRepairing: false, + sidebarStatus: { + iconColor: "error", + isDisabled: false, + onSelect: mockGoogleOnSelect, + tooltip: "Google Calendar needs reconnecting. Click to reconnect.", + }, + state: "RECONNECT_REQUIRED", + }); + mockShouldShowAnonymousCalendarChangeSignUpPrompt.mockReturnValue(false); + }); + + it("renders the anonymous sign-up warning dot and opens sign up when clicked", async () => { + const user = userEvent.setup(); + mockShouldShowAnonymousCalendarChangeSignUpPrompt.mockReturnValue(true); + + render(); + + expect( + screen.getByRole("status", { + name: /sign up to save your changes/i, + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { + name: /sign up to save your changes/i, + }), + ); + + expect(screen.getByLabelText("header-info-icon")).toHaveTextContent( + "motion-safe:animate-sync-dot-pulse", + ); + expect(screen.getByLabelText("header-info-icon")).toHaveTextContent( + "motion-safe:group-hover:animate-none", + ); + expect(mockOpenModal).toHaveBeenCalledWith("signUp"); + expect(mockGoogleOnSelect).not.toHaveBeenCalled(); + }); + + it("falls back to the existing Google status when the prompt is inactive", async () => { + const user = userEvent.setup(); + + render(); + + expect( + screen.getByRole("status", { + name: /google calendar needs reconnecting/i, + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { + name: /google calendar needs reconnecting/i, + }), + ); + + expect(screen.getByLabelText("header-info-icon")).not.toHaveTextContent( + "motion-safe:animate-sync-dot-pulse", + ); + expect(mockGoogleOnSelect).toHaveBeenCalled(); + expect(mockOpenModal).not.toHaveBeenCalled(); + }); + + it("renders the background import spinner instead of the info icon while importing", () => { + mockUseConnectGoogle.mockReturnValue({ + commandAction: { + icon: "CloudArrowUpIcon", + isDisabled: true, + label: "Syncing Google Calendar…", + }, + isRepairing: false, + sidebarStatus: { + isDisabled: true, + tooltip: "Google Calendar is syncing in the background.", + }, + state: "IMPORTING", + }); + + render(); + + expect( + screen.getByRole("status", { + name: /syncing google calendar in the background/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /syncing google calendar in the background/i, + }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("spinner-gap")).toBeInTheDocument(); + expect(screen.queryByLabelText("header-info-icon")).not.toBeInTheDocument(); + }); + + it("renders the repairing spinner instead of the warning icon while repairing", () => { + mockUseConnectGoogle.mockReturnValue({ + commandAction: { + icon: "CloudArrowUpIcon", + isDisabled: true, + label: "Repairing Google Calendar…", + }, + isRepairing: true, + sidebarStatus: { + iconColor: "warning", + isDisabled: true, + tooltip: "Repairing Google Calendar in the background.", + }, + state: "repairing", + }); + + render(); + + expect( + screen.getByRole("status", { + name: /repairing google calendar in the background/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /repairing google calendar in the background/i, + }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("spinner-gap")).toBeInTheDocument(); + expect(screen.queryByLabelText("header-info-icon")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.tsx b/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.tsx new file mode 100644 index 000000000..ad1fc0300 --- /dev/null +++ b/packages/web/src/components/HeaderInfoIcon/HeaderInfoIcon.tsx @@ -0,0 +1,86 @@ +import { InfoIcon } from "@phosphor-icons/react"; +import { type IconColor } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { theme } from "@web/common/styles/theme"; +import { SpinnerIcon } from "@web/components/Icons/Spinner"; +import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; +import { StatusDotPopover } from "./HeaderInfoIconPopover"; +import { useHeaderInfo } from "./useHeaderInfo"; + +const ICON_COLOR_MAP: Record = { + muted: theme.color.text.darkPlaceholder, + warning: theme.color.status.warning, + error: theme.color.status.error, +}; + +const ANONYMOUS_PROMPT_ICON_CLASSNAME = + "origin-center transition-all duration-200 ease-out motion-safe:animate-sync-dot-pulse motion-safe:group-hover:animate-none"; +const DOT_BUTTON_CLASSNAME = "inline-flex h-6 w-6 items-center justify-center"; +const ANONYMOUS_PROMPT_WRAPPER_CLASSNAME = `${DOT_BUTTON_CLASSNAME} group rounded-full transition-colors duration-200 ease-out hover:bg-white/20 hover:ring-1 hover:ring-white/20`; + +export const HeaderInfoIcon = () => { + const { isAnonymousSignUpPrompt, sidebarStatus, isRepairing, syncTooltip } = + useHeaderInfo(); + + if (syncTooltip) { + return ( +
+ + + + +
+ ); + } + + // Only render when user attention is needed (warning or error states) + if ( + sidebarStatus.iconColor !== "warning" && + sidebarStatus.iconColor !== "error" + ) { + return null; + } + + const iconColor = ICON_COLOR_MAP[sidebarStatus.iconColor]; + const iconClassName = isAnonymousSignUpPrompt + ? ANONYMOUS_PROMPT_ICON_CLASSNAME + : undefined; + const icon = ( +
); }; diff --git a/packages/web/src/views/Calendar/components/Header/Header.tsx b/packages/web/src/views/Calendar/components/Header/Header.tsx index de2886b9d..66d45e034 100644 --- a/packages/web/src/views/Calendar/components/Header/Header.tsx +++ b/packages/web/src/views/Calendar/components/Header/Header.tsx @@ -2,8 +2,7 @@ import { type FC } from "react"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { theme } from "@web/common/styles/theme"; import { getCalendarHeadingLabel } from "@web/common/utils/datetime/web.date.util"; -import { AccountIcon } from "@web/components/AuthModal/AccountIcon"; -import { AlignItems } from "@web/components/Flex/styled"; +import { HeaderInfoIcon } from "@web/components/HeaderInfoIcon/HeaderInfoIcon"; import { SidebarIcon } from "@web/components/Icons/Sidebar"; import { SelectView } from "@web/components/SelectView/SelectView"; import { Text } from "@web/components/Text"; @@ -16,15 +15,6 @@ import { type Util_Scroll } from "../../hooks/grid/useScroll"; import { type WeekProps } from "../../hooks/useWeek"; import { TodayButton } from "../TodayButton/TodayButton"; import { DayLabels } from "./DayLabels"; -import { - ArrowNavigationButton, - StyledHeaderLabel, - StyledHeaderRow, - StyledLeftGroup, - StyledNavigationArrows, - StyledNavigationGroup, - StyledRightGroup, -} from "./styled"; interface Props { rootProps: RootProps; @@ -52,7 +42,7 @@ export const Header: FC = ({ scrollUtil, today, weekProps }) => { return ( <> - +
dispatch(viewSlice.actions.toggleSidebar())} @@ -66,53 +56,55 @@ export const Header: FC = ({ scrollUtil, today, weekProps }) => { } /> - - +
+
{headerLabel} - - - - +
+
+
+
- +
- +
weekProps.util.decrementWeek()} shortcut="J" > - {"<"} - + weekProps.util.incrementWeek()} shortcut="K" > - {">"} - + - - +
+
- - +
+
` - color: ${({ color }) => color}; - flex-basis: 100%; - min-width: ${EVENT_WIDTH_MINIMUM}px; -`; - -export const StyledMonthLabel = styled(Text)` - font-size: 24px; - color: ${c.gray100}; - margin-left: 20px; -`; - -// Today button -export const StyledTodayButton = styled.button` - background: transparent; - color: ${c.gray100}; - border-radius: 4px; - padding: 4px 8px; - cursor: pointer; - font-size: 14px; - - &:hover { - background: rgba(255, 255, 255, 0.1); - } -`; diff --git a/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.test.tsx new file mode 100644 index 000000000..65f5acbfe --- /dev/null +++ b/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.test.tsx @@ -0,0 +1,94 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useUser } from "@web/auth/compass/user/hooks/useUser"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; +import { SubCalendarList } from "./SubCalendarList"; + +jest.mock("@web/auth/compass/user/hooks/useUser", () => ({ + useUser: jest.fn(), +})); + +jest.mock("@web/components/AuthModal/hooks/useAuthModal", () => ({ + useAuthModal: jest.fn(), +})); + +const mockUseUser = useUser as jest.MockedFunction; +const mockUseAuthModal = useAuthModal as jest.MockedFunction< + typeof useAuthModal +>; + +describe("SubCalendarList", () => { + const openModal = jest.fn(); + + beforeEach(() => { + openModal.mockReset(); + mockUseAuthModal.mockReturnValue({ + closeModal: jest.fn(), + currentView: "login", + isOpen: false, + openModal, + setView: jest.fn(), + }); + }); + + it("renders the signed in email without the temporary account tooltip", () => { + mockUseUser.mockReturnValue({ + email: "signed-in@example.com", + }); + + render(); + + expect(screen.getByText("signed-in@example.com")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /temporary account info/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Sign up to save your changes"), + ).not.toBeInTheDocument(); + expect(screen.getByText("primary")).toBeInTheDocument(); + }); + + it("renders temporary account with a hover tooltip when there is no email", async () => { + const user = userEvent.setup(); + + mockUseUser.mockReturnValue({ + email: undefined, + }); + + render(); + + const infoButton = screen.getByRole("button", { + name: /temporary account info/i, + }); + + expect(screen.getByText("Temporary account")).toBeInTheDocument(); + expect(screen.queryByText("primary")).not.toBeInTheDocument(); + + await user.hover(infoButton); + + await waitFor(() => { + expect( + screen.getByText("Sign up to save your changes"), + ).toBeInTheDocument(); + }); + }); + + it("opens the sign up auth modal when the temporary account icon is clicked", async () => { + const user = userEvent.setup(); + + mockUseUser.mockReturnValue({ + email: undefined, + }); + + render(); + + await user.click( + screen.getByRole("button", { + name: /temporary account info/i, + }), + ); + + expect(openModal).toHaveBeenCalledWith("signUp"); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.tsx b/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.tsx index 9195a97b8..9ec61403d 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.tsx @@ -1,39 +1,59 @@ -import React, { type FC } from "react"; -import { theme } from "@web/common/styles/theme"; -import { Divider } from "@web/components/Divider"; -import { Text } from "@web/components/Text"; -import { - CalendarLabel, - CalendarList, - CalendarListContainer, -} from "../../styled"; +import { type FC, useCallback } from "react"; +import { InfoIcon } from "@phosphor-icons/react"; +import { useUser } from "@web/auth/compass/user/hooks/useUser"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; +import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; + +const TEMPORARY_ACCOUNT_MESSAGE = "Sign up to save your changes"; export const SubCalendarList: FC = () => { + const { email } = useUser(); + const { openModal } = useAuthModal(); + const isTemporaryAccount = !email; + const headingText = email ?? "Temporary account"; + const handleOpenSignUp = useCallback(() => { + openModal("signUp"); + }, [openModal]); + return ( <> - - - - Calendars - - - - - - primary - - - - +
+
+ {headingText} + {isTemporaryAccount ? ( + + + + ) : null} +
+ {!isTemporaryAccount ? ( +
    + +
+ ) : null} +
); }; diff --git a/packages/web/src/views/Calendar/components/Sidebar/Sidebar.interactions.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/Sidebar.interactions.test.tsx index af16e35a5..ef2ac69db 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/Sidebar.interactions.test.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/Sidebar.interactions.test.tsx @@ -15,7 +15,7 @@ import { ENV_WEB } from "@web/common/constants/env.constants"; import { CalendarView } from "@web/views/Calendar"; import { freshenEventStartEndDate } from "@web/views/Calendar/calendar.render.test.utils"; -jest.mock("@web/auth/session/session.util", () => ({ +jest.mock("@web/auth/compass/session/session.util", () => ({ getUserId: async () => "test-user-id", })); diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx index a22eb5e4b..67f00fb98 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.test.tsx @@ -1,23 +1,8 @@ import type { ReactNode } from "react"; -import { fireEvent, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { render } from "@web/__tests__/utils/render.test.util"; -import { SyncApi } from "@web/common/apis/sync.api"; import { SidebarIconRow } from "@web/views/Calendar/components/Sidebar/SidebarIconRow"; -const mockLogin = jest.fn(); - -jest.mock("@web/auth/hooks/google/useGoogleAuth/useGoogleAuth", () => ({ - useGoogleAuth: () => ({ - login: mockLogin, - }), -})); - -jest.mock("@web/common/apis/sync.api", () => ({ - SyncApi: { - importGCal: jest.fn().mockResolvedValue(undefined), - }, -})); - jest.mock("@web/common/hooks/useVersionCheck", () => ({ useVersionCheck: () => ({ isUpdateAvailable: false, @@ -48,177 +33,13 @@ jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ })); describe("SidebarIconRow", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("shows the connect icon action when Google Calendar is not connected", () => { - render(, { - state: { - userMetadata: { - current: { - google: { - connectionState: "NOT_CONNECTED", - }, - }, - }, - }, - }); - - expect( - screen.getByRole("button", { - name: "Google Calendar not connected. Click to connect.", - }), - ).toBeEnabled(); - expect( - screen.getByRole("status", { - name: "Google Calendar not connected. Click to connect.", - }), - ).toBeInTheDocument(); - }); - - it("shows the reconnect icon action when reconnect is required", () => { - render(, { - state: { - userMetadata: { - current: { - google: { - connectionState: "RECONNECT_REQUIRED", - }, - }, - }, - }, - }); - - expect( - screen.getByRole("button", { - name: "Google Calendar needs reconnecting. Click to reconnect.", - }), - ).toBeEnabled(); - expect( - screen.getByRole("status", { - name: "Google Calendar needs reconnecting. Click to reconnect.", - }), - ).toBeInTheDocument(); - }); - - it("disables the sidebar action when Google Calendar is healthy", () => { - render(, { - state: { - userMetadata: { - current: { - google: { - connectionState: "HEALTHY", - }, - }, - }, - }, - }); - - expect( - screen.getByRole("button", { - name: "Google Calendar connected.", - }), - ).toBeDisabled(); - expect( - screen.getByRole("status", { - name: "Google Calendar connected.", - }), - ).toBeInTheDocument(); - }); - - it("disables the sidebar action while Google Calendar is importing", () => { - render(, { - state: { - userMetadata: { - current: { - google: { - connectionState: "IMPORTING", - }, - }, - }, - }, - }); - - expect( - screen.getByRole("button", { - name: "Google Calendar is syncing in the background.", - }), - ).toBeDisabled(); - expect( - screen.getByRole("status", { - name: "Google Calendar is syncing in the background.", - }), - ).toBeInTheDocument(); - }); - - it("shows the background import spinner while Google Calendar is importing", () => { - render(, { - state: { - sync: { - importGCal: { - isProcessing: true, - }, - }, - }, - }); + it("does not render the background import spinner in the sidebar", () => { + render(); expect( - screen.getByRole("button", { - name: "Importing your calendar events in the background", - }), - ).toBeInTheDocument(); - }); - - it("clicks through to repair when Google Calendar needs attention", () => { - render(, { - state: { - userMetadata: { - current: { - google: { - connectionState: "ATTENTION", - }, - }, - }, - }, - }); - - fireEvent.click( - screen.getByRole("button", { - name: "Google Calendar needs repair. Click to repair.", - }), - ); - - expect(SyncApi.importGCal).toHaveBeenCalledWith({ force: true }); - }); - - it("shows a disabled warning spinner while a repair is active", () => { - render(, { - state: { - userMetadata: { - current: { - google: { - connectionState: "ATTENTION", - }, - }, - }, - sync: { - importGCal: { - isRepairing: true, - }, - }, - }, - }); - - expect( - screen.getByRole("button", { - name: "Repairing Google Calendar in the background.", - }), - ).toBeDisabled(); - expect( - screen.getByRole("status", { - name: "Repairing Google Calendar in the background.", + screen.queryByRole("button", { + name: "Syncing Google Calendar in the background.", }), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx index bcf1f1f44..c3fc49de9 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx @@ -1,22 +1,12 @@ -import { - CloudArrowUpIcon, - CloudWarningIcon, - LinkBreakIcon, - LinkIcon, -} from "@phosphor-icons/react"; -import { useConnectGoogle } from "@web/auth/hooks/google/useConnectGoogle/useConnectGoogle"; import { useVersionCheck } from "@web/common/hooks/useVersionCheck"; import { theme } from "@web/common/styles/theme"; -import { type ConnectionStatusIcon } from "@web/common/types/icon.types"; import { getModifierKeyIcon } from "@web/common/utils/shortcut/shortcut.util"; import { CalendarIcon } from "@web/components/Icons/Calendar"; import { CommandIcon } from "@web/components/Icons/Command"; import { RefreshIcon } from "@web/components/Icons/Refresh"; -import { SpinnerIcon } from "@web/components/Icons/Spinner"; import { TodoIcon } from "@web/components/Icons/Todo"; import { Text } from "@web/components/Text"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { selectImportGCalState } from "@web/ducks/events/selectors/sync.selector"; import { selectSidebarTab } from "@web/ducks/events/selectors/view.selectors"; import { viewSlice } from "@web/ducks/events/slices/view.slice"; import { selectIsCmdPaletteOpen } from "@web/ducks/settings/selectors/settings.selectors"; @@ -28,75 +18,11 @@ import { RightIconGroup, } from "@web/views/Calendar/components/Sidebar/styled"; -/** - * Returns the icon for the current Google connection state. - * Icons are decorative (aria-hidden) since the parent status container - * provides the accessible name via aria-label. - */ -const getGoogleStatusIcon = ({ - icon, - tone = "default", -}: { - icon: ConnectionStatusIcon; - - tone?: "default" | "warning"; -}) => { - switch (icon) { - case "LinkBreakIcon": - return ( -