diff --git a/.agents/handoffs/3233.md b/.agents/handoffs/3233.md new file mode 100644 index 0000000000..afe023f946 --- /dev/null +++ b/.agents/handoffs/3233.md @@ -0,0 +1,41 @@ +--- +schema_version: 1 +task_id: "3233" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/web/src/sse/hooks/useSyncSSE.factory.ts + - path: packages/web/src/auth/providers/connection-revoked.util.ts + - path: packages/web/src/auth/providers/provider-copy.util.ts + - path: packages/web/src/booking/BookingConnectGooglePrompt.tsx + - path: packages/web/src/calendars/calendar.util.ts + - path: packages/web/src/auth/posthog/track.ts +evidence: + - command: bun run verify --strict + result: "VERDICT: PASS (test:web, type-check, lint, knip, test:a11y, test:e2e)" + - command: wc -l packages/web/src/auth/google/**/*.ts* + result: "762 (below 1500); remaining files are sign-in only" +assumptions: + - "Main's WP-08b (#3382) kept ConnectProviderChooser; this WP extends it with a prompt variant instead of adding ProviderConnectChooser." + - "PR #3384 is the superseded conflicting 08c attempt based on a different 08b." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: human +waiting_on: null +escalation: null +--- + +P0 WP-08c: migrate SSE, booking, calendars and analytics off the Google connect +layer, then delete that layer. + +Remaining `packages/web/src/auth/google/` files (sign-in only, keep until +milestone I): + +- `google-auth-config.ts` + test +- `authorization/complete-google-authorization.ts` +- `authorization/google-authorization.{constants,storage,util}` + tests +- `authorization/useStartGoogleAuthorization.ts` + `.impl.ts` +- `authorization/google-authorization.test.ts` diff --git a/packages/web/src/__tests__/helpers/web-test-seams.ts b/packages/web/src/__tests__/helpers/web-test-seams.ts index fab3dc96d4..357e1bfa7c 100644 --- a/packages/web/src/__tests__/helpers/web-test-seams.ts +++ b/packages/web/src/__tests__/helpers/web-test-seams.ts @@ -11,10 +11,8 @@ import { } from "@web/auth/google/authorization/useStartGoogleAuthorization"; import { resetGoogleAvailabilityForTests, - setGoogleAvailabilityForTests, -} from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; -import { resetProviderAvailabilityForTests, + setGoogleAvailabilityForTests, setProviderAvailabilityForTests, } from "@web/auth/providers/useIsProviderAvailable"; import { resetEmbeddedCheckoutForTests } from "@web/billing/embedded-checkout/embedded-checkout.seam"; diff --git a/packages/web/src/app.bootstrap.tsx b/packages/web/src/app.bootstrap.tsx index fe454af839..a83ed11175 100644 --- a/packages/web/src/app.bootstrap.tsx +++ b/packages/web/src/app.bootstrap.tsx @@ -3,12 +3,12 @@ import { createRoot } from "react-dom/client"; import "react-toastify/dist/ReactToastify.css"; import "./common/styles/toastify-theme.css"; import { sessionInit } from "@web/auth/compass/session/SessionProvider"; -import { configureGoogleRevocationApiHandler } from "@web/auth/google/util/google-revocation-api.config"; import { readConnectStatus, refreshUserMetadataAfterConnect, showConnectStatusToast, } from "@web/auth/providers/connect-status.util"; +import { configureGoogleRevocationApiHandler } from "@web/auth/providers/revocation-api.config"; import { initializeDatabaseWithErrorHandling, showDbInitErrorToast, diff --git a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.impl.ts b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.impl.ts index 0ffa53fd71..49f33adfc0 100644 --- a/packages/web/src/auth/compass/hooks/useCompleteAuthentication.impl.ts +++ b/packages/web/src/auth/compass/hooks/useCompleteAuthentication.impl.ts @@ -5,7 +5,7 @@ import { 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 { syncPendingLocalEvents } from "@web/auth/providers/connection-revoked.util"; import { eventQueryKeys } from "@web/events/queries/event.query.keys"; import { refreshEventRepositorySource } from "@web/events/repositories/event.repository.source.store"; import { createUseCompleteAuthentication } from "./useCompleteAuthentication.factory"; diff --git a/packages/web/src/auth/compass/session/SessionProvider.tsx b/packages/web/src/auth/compass/session/SessionProvider.tsx index c3fb4bf7bd..68238c029a 100644 --- a/packages/web/src/auth/compass/session/SessionProvider.tsx +++ b/packages/web/src/auth/compass/session/SessionProvider.tsx @@ -9,13 +9,13 @@ import { getLastKnownEmail, markUserAsAuthenticated, } from "@web/auth/compass/state/auth.state.util"; +import { clearGoogleSyncIndicatorOverride } from "@web/auth/providers/sync.indicator.state"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { createExternalStore } from "@web/common/utils/external-store.util"; import { refreshEventRepositorySource } from "@web/events/repositories/event.repository.source.store"; import * as sse from "@web/sse/provider/SSEProvider"; -import { clearGoogleSyncIndicatorOverride } from "../../google/state/google.sync.state"; import { refreshUserMetadata } from "../user/util/user-metadata.util"; import { SessionContext } from "./session.context"; diff --git a/packages/web/src/auth/compass/session/logout.teardown.test.ts b/packages/web/src/auth/compass/session/logout.teardown.test.ts index 9c1b6f9d47..d12a8543c1 100644 --- a/packages/web/src/auth/compass/session/logout.teardown.test.ts +++ b/packages/web/src/auth/compass/session/logout.teardown.test.ts @@ -22,7 +22,7 @@ const mockDraftActionsDiscard = mock(); // and only intercept what this file asserts on while it runs - the flag flips // back to the real implementations in afterAll, because many later suites // exercise these functions for real (draftActions.discard closes event forms, -// clearGoogleSyncIndicatorOverride drives useGcalSSE, and the source store's +// clearGoogleSyncIndicatorOverride drives useSyncSSE, and the source store's // own tests call refreshEventRepositorySource). const actualRepositorySource = { ...(await import("@web/events/repositories/event.repository.source.store")), @@ -32,7 +32,7 @@ const actualUserMetadata = { }; const actualSseClient = { ...(await import("@web/sse/client/sse.client")) }; const actualSyncState = { - ...(await import("@web/auth/google/state/google.sync.state")), + ...(await import("@web/auth/providers/sync.indicator.state")), }; const actualDraftStore = { ...(await import("@web/events/stores/draft.store")), @@ -62,7 +62,7 @@ mock.module("@web/auth/state/user-metadata.store", () => ({ }, })); -mock.module("@web/auth/google/state/google.sync.state", () => ({ +mock.module("@web/auth/providers/sync.indicator.state", () => ({ ...actualSyncState, clearGoogleSyncIndicatorOverride: (...args: unknown[]) => isTeardownMocked diff --git a/packages/web/src/auth/compass/session/logout.teardown.ts b/packages/web/src/auth/compass/session/logout.teardown.ts index 2e23e5e226..a07f8dc010 100644 --- a/packages/web/src/auth/compass/session/logout.teardown.ts +++ b/packages/web/src/auth/compass/session/logout.teardown.ts @@ -8,7 +8,7 @@ */ import { queryClient } from "@web/api/query-client"; -import { clearGoogleSyncIndicatorOverride } from "@web/auth/google/state/google.sync.state"; +import { clearGoogleSyncIndicatorOverride } from "@web/auth/providers/sync.indicator.state"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { eventQueryKeys } from "@web/events/queries/event.query.keys"; diff --git a/packages/web/src/auth/compass/state/auth.state.util.ts b/packages/web/src/auth/compass/state/auth.state.util.ts index 99f4c81935..cecba0974a 100644 --- a/packages/web/src/auth/compass/state/auth.state.util.ts +++ b/packages/web/src/auth/compass/state/auth.state.util.ts @@ -1,8 +1,8 @@ import { z } from "zod/v4"; +import { clearAllGoogleReconnectRequired } from "@web/auth/providers/reconnect.state"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; import { subscribeToStorageKey } from "@web/common/utils/external-store.util"; -import { clearAllGoogleReconnectRequired } from "../../google/state/google.reconnect.state"; export const AuthStateSchema = z.object({ hasAuthenticated: z.boolean().default(false), diff --git a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts index 8fb32d8c0e..2ac5c039e3 100644 --- a/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.test.ts @@ -1,7 +1,7 @@ import { type UserMetadata } from "@core/types/user.types"; import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; import { BaseApi } from "@web/api/base/base.api"; -import { resetGoogleReconnectRequiredForTests } from "@web/auth/google/state/google.reconnect.state"; +import { resetGoogleReconnectRequiredForTests } from "@web/auth/providers/reconnect.state"; import { GOOGLE_DELAYED_TOAST_ID } from "@web/common/constants/toast.constants"; import { registerToastPort } from "@web/common/utils/toast/toast.port"; import { diff --git a/packages/web/src/auth/compass/user/util/user-metadata.util.ts b/packages/web/src/auth/compass/user/util/user-metadata.util.ts index d7c63966c1..503afc3552 100644 --- a/packages/web/src/auth/compass/user/util/user-metadata.util.ts +++ b/packages/web/src/auth/compass/user/util/user-metadata.util.ts @@ -5,7 +5,7 @@ import { getGoogleReconnectRequiredAccountEmails, hasGoogleReconnectRequired, syncReconnectRequiredFromConnections, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { findPrimaryGoogleSyncConnectionFromMetadata, userMetadataActions, diff --git a/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts b/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts deleted file mode 100644 index b5785e87f5..0000000000 --- a/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; -import * as userMetadataUtil from "@web/auth/compass/user/util/user-metadata.util"; -import { registerToastPort } from "@web/common/utils/toast/toast.port"; -import { - readGoogleConnectStatus, - refreshUserMetadataAfterGoogleConnect, - showGoogleConnectStatusToast, -} from "./google-connect-status.util"; -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; - -describe("google-connect-status.util", () => { - const { port, mocks } = createTestToastPort(); - - let rafCallbacks: FrameRequestCallback[]; - let rafSpy: ReturnType; - - beforeEach(() => { - mocks.toast.mockClear(); - mocks.info.mockClear(); - mocks.success.mockClear(); - mocks.error.mockClear(); - registerToastPort(port); - rafCallbacks = []; - rafSpy = spyOn(globalThis, "requestAnimationFrame").mockImplementation((( - callback: FrameRequestCallback, - ) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - }) as typeof requestAnimationFrame); - }); - - afterEach(() => { - rafSpy.mockRestore(); - }); - - /** Flush both rAF frames used by `showGoogleConnectStatusToast`. */ - const runToastAfterPaint = () => { - const first = [...rafCallbacks]; - rafCallbacks = []; - for (const callback of first) callback(0); - const second = [...rafCallbacks]; - rafCallbacks = []; - for (const callback of second) callback(0); - }; - - describe("readGoogleConnectStatus", () => { - it("reads connected/declined/error off a provider=google redirect", () => { - expect(readGoogleConnectStatus("?provider=google&status=connected")).toBe( - "connected", - ); - expect(readGoogleConnectStatus("?provider=google&status=declined")).toBe( - "declined", - ); - expect(readGoogleConnectStatus("?provider=google&status=error")).toBe( - "error", - ); - }); - - it("reads missingScopes off a partial-grant redirect", () => { - expect( - readGoogleConnectStatus("?provider=google&status=missingScopes"), - ).toBe("missingScopes"); - }); - - it("returns null when provider is missing or not google", () => { - expect(readGoogleConnectStatus("?status=connected")).toBeNull(); - expect( - readGoogleConnectStatus("?provider=outlook&status=connected"), - ).toBeNull(); - }); - - it("returns null for an unrecognized status value", () => { - expect( - readGoogleConnectStatus("?provider=google&status=pending"), - ).toBeNull(); - }); - - it("returns null with no query string at all", () => { - expect(readGoogleConnectStatus("")).toBeNull(); - }); - }); - - describe("showGoogleConnectStatusToast", () => { - it("does nothing until the deferred paint frame runs", () => { - showGoogleConnectStatusToast("connected"); - expect(mocks.toast).not.toHaveBeenCalled(); - }); - - it("shows a success toast for connected", () => { - showGoogleConnectStatusToast("connected"); - runToastAfterPaint(); - - expect(mocks.success).toHaveBeenCalledWith( - "Google Calendar connected.", - expect.objectContaining({ toastId: "google-connect-success" }), - ); - }); - - it("shows a neutral, non-blaming toast for declined", () => { - showGoogleConnectStatusToast("declined"); - runToastAfterPaint(); - - expect(mocks.info).toHaveBeenCalledWith( - "No problem - nothing was connected. You can add the account anytime from Settings.", - expect.objectContaining({ toastId: "google-connect-declined" }), - ); - }); - - it("shows a scope-specific toast for missingScopes, not the generic connect-failed one", () => { - showGoogleConnectStatusToast("missingScopes"); - runToastAfterPaint(); - - expect(mocks.error).toHaveBeenCalledWith( - "Compass needs calendar permission to sync. Reconnect from Settings and leave the calendar box checked.", - expect.objectContaining({ - toastId: "google-connect-missing-scopes", - autoClose: false, - }), - ); - }); - - it("shows a retryable error toast for error", () => { - showGoogleConnectStatusToast("error"); - runToastAfterPaint(); - - expect(mocks.error).toHaveBeenCalledWith( - "We couldn't connect your Google account. Please try again from Settings.", - expect.objectContaining({ - toastId: "google-connect-failed", - autoClose: false, - }), - ); - }); - }); - - describe("refreshUserMetadataAfterGoogleConnect", () => { - it("force-refreshes metadata after a completed connect, so new capabilities (e.g. contacts) go live without a reload", () => { - const refreshSpy = spyOn( - userMetadataUtil, - "refreshUserMetadata", - ).mockResolvedValue(undefined); - - refreshUserMetadataAfterGoogleConnect("connected"); - expect(refreshSpy).toHaveBeenCalledWith({ force: true }); - - refreshSpy.mockRestore(); - }); - - it("does nothing for non-connected outcomes", () => { - const refreshSpy = spyOn( - userMetadataUtil, - "refreshUserMetadata", - ).mockResolvedValue(undefined); - - refreshUserMetadataAfterGoogleConnect("declined"); - refreshUserMetadataAfterGoogleConnect("missingScopes"); - refreshUserMetadataAfterGoogleConnect("error"); - expect(refreshSpy).not.toHaveBeenCalled(); - - refreshSpy.mockRestore(); - }); - }); -}); diff --git a/packages/web/src/auth/google/authorization/google-connect-status.util.ts b/packages/web/src/auth/google/authorization/google-connect-status.util.ts deleted file mode 100644 index 11df26e3f1..0000000000 --- a/packages/web/src/auth/google/authorization/google-connect-status.util.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - type ConnectStatus, - readConnectStatus, - refreshUserMetadataAfterConnect, - showConnectStatusToast, -} from "@web/auth/providers/connect-status.util"; - -export type GoogleConnectStatus = ConnectStatus; - -export function readGoogleConnectStatus( - search = window.location.search, -): GoogleConnectStatus | null { - const redirect = readConnectStatus(search); - return redirect?.provider === "google" ? redirect.status : null; -} - -export function showGoogleConnectStatusToast( - status: GoogleConnectStatus, -): void { - showConnectStatusToast({ provider: "google", status }); -} - -export function refreshUserMetadataAfterGoogleConnect( - status: GoogleConnectStatus, -): void { - refreshUserMetadataAfterConnect(status); -} diff --git a/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts b/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts index f056d9959c..d6d094a528 100644 --- a/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts +++ b/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts @@ -70,7 +70,7 @@ export const useStartGoogleAuthorizationImpl = ({ // PostHog loses the session at the redirect; this marks "left for // Google" so abandoned round trips are distinguishable from silent // failures on return. - track("google_oauth_redirect_started", { intent }); + track("oauth_redirect_started", { provider: "google", intent }); return startGoogleAuthorization(); }, [intent, onStart, startGoogleAuthorization, state]), }; diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts deleted file mode 100644 index 6df5968c4c..0000000000 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; -import { - type UseConnectGoogleOptions, - type UseConnectGoogleResult, -} from "./useConnectGoogle.types"; - -export type { UseConnectGoogleOptions, UseConnectGoogleResult }; - -export const useConnectGoogle = ( - options?: UseConnectGoogleOptions, -): UseConnectGoogleResult => useConnectProvider("google", options); diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts deleted file mode 100644 index c012b4ae06..0000000000 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createProviderAvailability } from "@web/auth/providers/provider-availability.factory"; - -type AppConfigResponse = { - google?: { isConfigured?: boolean }; -}; - -type GoogleAvailabilityDependencies = { - getConfig: () => Promise; - isGoogleAuthConfigured: boolean; -}; - -export function createGoogleAvailability({ - getConfig, - isGoogleAuthConfigured, -}: GoogleAvailabilityDependencies) { - const availability = createProviderAvailability({ - getConfig, - isGoogleAuthConfigured, - }); - - return { - resetGoogleAvailabilityForTests: - availability.resetGoogleAvailabilityForTests, - setGoogleAvailabilityForTests: availability.setGoogleAvailabilityForTests, - useIsGoogleAvailable: availability.useIsGoogleAvailable, - useIsConnectGoogleAvailable: availability.useIsConnectGoogleAvailable, - }; -} diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx deleted file mode 100644 index 8be204af05..0000000000 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { createGoogleAvailability } from "./useIsGoogleAvailable.factory"; -import { describe, expect, it, mock } from "bun:test"; - -const getConfig = mock(); - -const createHook = () => { - const { resetGoogleAvailabilityForTests, useIsGoogleAvailable } = - createGoogleAvailability({ - getConfig, - isGoogleAuthConfigured: true, - }); - - resetGoogleAvailabilityForTests(); - - return useIsGoogleAvailable; -}; - -describe("useIsGoogleAvailable", () => { - it("uses the backend config response before exposing Google UI", async () => { - getConfig.mockClear(); - getConfig.mockResolvedValue({ - google: { - isConfigured: true, - }, - }); - const useIsGoogleAvailable = createHook(); - - const { result } = renderHook(() => useIsGoogleAvailable()); - - expect(result.current).toBe(false); - - await waitFor(() => { - expect(result.current).toBe(true); - }); - expect(getConfig).toHaveBeenCalledTimes(1); - }); - - it("retries backend config after a failed request on the next mount", async () => { - getConfig.mockClear(); - getConfig - .mockRejectedValueOnce(new Error("temporary config failure")) - .mockResolvedValueOnce({ - google: { - isConfigured: true, - }, - }); - const useIsGoogleAvailable = createHook(); - - const firstRender = renderHook(() => useIsGoogleAvailable()); - - await waitFor(() => { - expect(getConfig).toHaveBeenCalledTimes(1); - }); - expect(firstRender.result.current).toBe(false); - firstRender.unmount(); - - const secondRender = renderHook(() => useIsGoogleAvailable()); - - await waitFor(() => { - expect(secondRender.result.current).toBe(true); - }); - expect(getConfig).toHaveBeenCalledTimes(2); - }); -}); - -describe("useIsConnectGoogleAvailable", () => { - it("is available with no baked GOOGLE_CLIENT_ID (self-host, unrebuilt web image)", async () => { - getConfig.mockClear(); - getConfig.mockResolvedValue({ - google: { isConfigured: true }, - }); - const { resetGoogleAvailabilityForTests, useIsConnectGoogleAvailable } = - createGoogleAvailability({ getConfig, isGoogleAuthConfigured: false }); - resetGoogleAvailabilityForTests(); - - const { result } = renderHook(() => useIsConnectGoogleAvailable()); - - expect(result.current).toBe(false); - await waitFor(() => { - expect(result.current).toBe(true); - }); - // The old behavior bailed out before ever calling getConfig() when no - // client id was baked in — this is the regression this test guards. - expect(getConfig).toHaveBeenCalledTimes(1); - }); - - it("stays unavailable when the backend has no Google configured at all", async () => { - getConfig.mockClear(); - getConfig.mockResolvedValue({ - google: { isConfigured: false }, - }); - const { resetGoogleAvailabilityForTests, useIsConnectGoogleAvailable } = - createGoogleAvailability({ getConfig, isGoogleAuthConfigured: false }); - resetGoogleAvailabilityForTests(); - - const { result } = renderHook(() => useIsConnectGoogleAvailable()); - - await waitFor(() => { - expect(getConfig).toHaveBeenCalledTimes(1); - }); - expect(result.current).toBe(false); - }); -}); - -describe("useIsGoogleAvailable (sign-in) stays gated on the baked client id", () => { - it("stays unavailable without a baked GOOGLE_CLIENT_ID even when the backend is configured", async () => { - getConfig.mockClear(); - getConfig.mockResolvedValue({ - google: { isConfigured: true }, - }); - const { resetGoogleAvailabilityForTests, useIsGoogleAvailable } = - createGoogleAvailability({ getConfig, isGoogleAuthConfigured: false }); - resetGoogleAvailabilityForTests(); - - const { result } = renderHook(() => useIsGoogleAvailable()); - - await waitFor(() => { - expect(getConfig).toHaveBeenCalledTimes(1); - }); - // Sign-in always needs the baked client id — only connect relaxes this. - expect(result.current).toBe(false); - }); -}); diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts deleted file mode 100644 index 85f4c34a98..0000000000 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { providerAvailability } from "@web/auth/providers/provider-availability.instance"; - -export const { - resetGoogleAvailabilityForTests, - setGoogleAvailabilityForTests, - useIsGoogleAvailable, - useIsConnectGoogleAvailable, -} = providerAvailability; diff --git a/packages/web/src/auth/posthog/track.ts b/packages/web/src/auth/posthog/track.ts index 644250abe2..9a01ef757b 100644 --- a/packages/web/src/auth/posthog/track.ts +++ b/packages/web/src/auth/posthog/track.ts @@ -6,7 +6,7 @@ export type ProductEvent = | "welcome_step_viewed" | "signup_started" | "signup_completed" - | "google_oauth_redirect_started" + | "oauth_redirect_started" | "login_completed" | "event_created" | "calendar_connected" diff --git a/packages/web/src/auth/providers/ConnectProviderChooser.tsx b/packages/web/src/auth/providers/ConnectProviderChooser.tsx index ca9d605aba..21934a5e76 100644 --- a/packages/web/src/auth/providers/ConnectProviderChooser.tsx +++ b/packages/web/src/auth/providers/ConnectProviderChooser.tsx @@ -6,10 +6,14 @@ import { providerDisplayName, } from "@core/types/sync/identity.contracts"; import { openingProviderLabel } from "@web/auth/providers/connection-provider.util"; +import { BOOKING_CONNECT_BUTTON_LABEL } from "@web/auth/providers/provider-copy.util"; import { useAvailableConnectProviders } from "@web/auth/providers/useAvailableConnectProviders"; import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { focusOnPointerEnter } from "@web/common/utils/focus-on-pointer-enter"; -import { OverlayPanelActionButton } from "@web/components/OverlayPanel/OverlayPanel"; +import { + OverlayPanelActionButton, + OverlayPanelActions, +} from "@web/components/OverlayPanel/OverlayPanel"; const SIDEBAR_PRIMARY_CLASSNAME = "c-button-compact c-button-primary mb-2 w-full rounded-xs px-2 py-1.5 text-left text-xs"; @@ -17,7 +21,7 @@ const SIDEBAR_PRIMARY_CLASSNAME = type ConnectProviderChooserProps = { idleLabel: string; newAccount?: boolean; - variant?: "overlay-primary" | "sidebar-primary"; + variant?: "overlay-primary" | "sidebar-primary" | "prompt"; showShortcut?: boolean; shortcut?: string; shortcutAttrs?: Record; @@ -64,15 +68,35 @@ export const ConnectProviderChooser: FC = ({ if (available.length === 0) return null; - const buttonLabel = isConnecting - ? openingProviderLabel(connectingKind ?? "google") - : idleLabel; - const runConnect = (kind: ProviderKind) => { setMenuOpen(false); byKind[kind].connect(); }; + if (variant === "prompt") { + return ( + + {available.map((kind) => ( + runConnect(kind)} + variant="primary" + > + {byKind[kind].isConnecting + ? openingProviderLabel(kind) + : BOOKING_CONNECT_BUTTON_LABEL[kind]} + + ))} + + ); + } + + const buttonLabel = isConnecting + ? openingProviderLabel(connectingKind ?? "google") + : idleLabel; + if (available.length === 1) { const kind = available[0]; const singleLabel = isConnecting diff --git a/packages/web/src/auth/providers/connect-status.util.ts b/packages/web/src/auth/providers/connect-status.util.ts index 03c0b14945..3432a6d103 100644 --- a/packages/web/src/auth/providers/connect-status.util.ts +++ b/packages/web/src/auth/providers/connect-status.util.ts @@ -91,7 +91,7 @@ function fireConnectStatusToast({ provider, status }: ConnectRedirect): void { const toast = getToast(); switch (status) { case "connected": - track("calendar_connected", { source: "connect_redirect" }); + track("calendar_connected", { source: "connect_redirect", provider }); toast.success(connectedCopy(provider), { ...getToastDefaultOptions(), toastId: SUCCESS_TOAST_ID[provider], diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts b/packages/web/src/auth/providers/connect.types.ts similarity index 100% rename from packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts rename to packages/web/src/auth/providers/connect.types.ts diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts b/packages/web/src/auth/providers/connect.util.test.ts similarity index 99% rename from packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts rename to packages/web/src/auth/providers/connect.util.test.ts index a6da670fac..5335177186 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts +++ b/packages/web/src/auth/providers/connect.util.test.ts @@ -8,7 +8,7 @@ import { getSidebarSyncStatus, isFirstImportFailed, isFirstImportInProgress, -} from "./useConnectGoogle.util"; +} from "./connect.util"; import { beforeEach, describe, expect, it, mock } from "bun:test"; describe("formatLastSyncedLabel", () => { diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts b/packages/web/src/auth/providers/connect.util.ts similarity index 99% rename from packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts rename to packages/web/src/auth/providers/connect.util.ts index a5af2f78a9..20e79101f8 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts +++ b/packages/web/src/auth/providers/connect.util.ts @@ -7,13 +7,13 @@ import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; import { isAccountReconnectRequired, isConnectionReconnectRequired, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { type SyncStatus } from "@web/calendars/sync-status.types"; import { type CommandActionIcon, type GoogleUiConfig, type GoogleUiState, -} from "./useConnectGoogle.types"; +} from "./connect.types"; const RECONNECT_STATUS: SyncStatus = { variant: "error", diff --git a/packages/web/src/auth/google/util/google.auth.util.factory.ts b/packages/web/src/auth/providers/connection-revoked.util.factory.ts similarity index 95% rename from packages/web/src/auth/google/util/google.auth.util.factory.ts rename to packages/web/src/auth/providers/connection-revoked.util.factory.ts index 6ea8874cbe..e31cbf67fd 100644 --- a/packages/web/src/auth/google/util/google.auth.util.factory.ts +++ b/packages/web/src/auth/providers/connection-revoked.util.factory.ts @@ -1,7 +1,7 @@ import { type toast } from "react-toastify"; import { Status } from "@core/errors/status.codes"; import { type ApiError } from "@web/api/api.types"; -import { type GoogleReconnectTarget } from "@web/auth/google/state/google.reconnect.state"; +import { type GoogleReconnectTarget } from "@web/auth/providers/reconnect.state"; import { getToastDefaultOptions } from "@web/common/constants/toast.constants"; export interface SyncLocalEventsResult { @@ -20,6 +20,7 @@ export type GoogleRevokedContext = { connectionId?: string | null; accountEmail?: string | null; }; +export type ConnectionRevokedContext = GoogleRevokedContext; type GoogleAuthUtilDependencies = { closeStream: () => void; diff --git a/packages/web/src/auth/google/util/google.auth.util.test.ts b/packages/web/src/auth/providers/connection-revoked.util.test.ts similarity index 98% rename from packages/web/src/auth/google/util/google.auth.util.test.ts rename to packages/web/src/auth/providers/connection-revoked.util.test.ts index c1be39c45c..c3d1b3d3ef 100644 --- a/packages/web/src/auth/google/util/google.auth.util.test.ts +++ b/packages/web/src/auth/providers/connection-revoked.util.test.ts @@ -4,12 +4,12 @@ import { isAccountReconnectRequired, markAccountReconnectRequired, resetGoogleReconnectRequiredForTests, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { createGoogleAuthUtil, LOCAL_EVENTS_SYNC_ERROR_MESSAGE, LOCAL_EVENTS_SYNC_SESSION_EXPIRED_MESSAGE, -} from "./google.auth.util.factory"; +} from "./connection-revoked.util.factory"; import { afterEach, beforeEach, diff --git a/packages/web/src/auth/google/util/google.auth.util.ts b/packages/web/src/auth/providers/connection-revoked.util.ts similarity index 79% rename from packages/web/src/auth/google/util/google.auth.util.ts rename to packages/web/src/auth/providers/connection-revoked.util.ts index 7a477dfbf9..2b3d178bec 100644 --- a/packages/web/src/auth/google/util/google.auth.util.ts +++ b/packages/web/src/auth/providers/connection-revoked.util.ts @@ -4,9 +4,9 @@ import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.u import { type GoogleReconnectTarget, markAccountReconnectRequired, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { - selectGoogleSyncConnections, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; @@ -17,7 +17,7 @@ import { closeStream, openStream } from "@web/sse/client/sse.client"; import { createGoogleAuthUtil, type GoogleRevokedContext, -} from "./google.auth.util.factory"; +} from "./connection-revoked.util.factory"; /** * Resolve which account a revoke signal belongs to. Returns null when the @@ -27,16 +27,27 @@ import { const resolveRevokedAccount = ( context?: GoogleRevokedContext, ): GoogleReconnectTarget | null => { - if (context?.connectionId || context?.accountEmail) { + const connections = selectSyncConnections(useUserMetadataStore.getState()); + + if (context?.connectionId) { + const connection = connections.find( + (entry) => entry.id === context.connectionId, + ); return { connectionId: context.connectionId, - accountEmail: context.accountEmail, + accountEmail: context.accountEmail ?? connection?.accountEmail ?? null, }; } - const connections = selectGoogleSyncConnections( - useUserMetadataStore.getState(), - ); + if (context?.accountEmail) { + const connection = connections.find( + (entry) => entry.accountEmail === context.accountEmail, + ); + return { + connectionId: connection?.id ?? null, + accountEmail: context.accountEmail, + }; + } if (context?.calendarId) { const calendars = @@ -96,8 +107,12 @@ const { syncPendingLocalEvents, } = googleAuthUtil; -export type { GoogleRevokedContext }; +export type { + ConnectionRevokedContext, + GoogleRevokedContext, +} from "./connection-revoked.util.factory"; export { + handleGoogleRevoked as handleConnectionRevoked, handleGoogleRevoked, showLocalEventsSyncFailure, syncLocalEvents, diff --git a/packages/web/src/auth/providers/provider-copy.util.test.ts b/packages/web/src/auth/providers/provider-copy.util.test.ts new file mode 100644 index 0000000000..cb62440036 --- /dev/null +++ b/packages/web/src/auth/providers/provider-copy.util.test.ts @@ -0,0 +1,120 @@ +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { + bookingConnectPromptCopy, + CONNECT_CALENDAR_LABEL, + calendarProductName, + connectionProvider, + defaultCalendarGroupLabel, + emptyCalendarsCopy, + openingProviderCopy, + RECONNECT_BANNER_MESSAGE, + RECONNECT_CALENDAR_LABEL, + reconnectPointerHint, + reconnectToastBody, + reconnectToastTitle, + relabelConnectCommand, +} from "./provider-copy.util"; +import { describe, expect, it } from "bun:test"; + +describe("provider copy", () => { + it("defaults a missing connection provider to google", () => { + expect(connectionProvider(undefined)).toBe("google"); + expect(connectionProvider({ provider: "microsoft" })).toBe("microsoft"); + }); + + it("keeps Google strings byte-identical", () => { + expect(calendarProductName("google")).toBe("Google Calendar"); + expect(CONNECT_CALENDAR_LABEL.google).toBe("Connect Google Calendar"); + expect(RECONNECT_CALENDAR_LABEL.google).toBe("Reconnect Google Calendar"); + expect(RECONNECT_BANNER_MESSAGE.google).toBe( + "Google Calendar needs reconnecting.", + ); + expect(openingProviderCopy("google")).toBe("Opening Google…"); + expect(emptyCalendarsCopy(["google"])).toBe( + "Connect Google to see your calendars.", + ); + expect(defaultCalendarGroupLabel("ahab@pequod.com", "google")).toBe( + "ahab@pequod.com (Google)", + ); + expect(reconnectToastTitle("google", "lance@example.com")).toBe( + "Google Calendar disconnected (lance@example.com)", + ); + expect(reconnectToastTitle("google")).toBe("Google Calendar disconnected"); + expect(reconnectToastBody("google", "lance@example.com")).toBe( + "Access for lance@example.com expired or was revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.", + ); + expect(reconnectToastBody("google")).toBe( + "This happens when access expires or is revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.", + ); + expect(reconnectPointerHint("google")).toBe( + "Press G to reconnect Google Calendar.", + ); + }); + + it("names Microsoft as Outlook in reconnect copy", () => { + expect(RECONNECT_BANNER_MESSAGE.microsoft).toBe( + "Outlook needs reconnecting.", + ); + expect(reconnectToastTitle("microsoft", "ada@outlook.com")).toBe( + "Outlook disconnected (ada@outlook.com)", + ); + expect(reconnectToastBody("microsoft", "ada@outlook.com")).toBe( + "Access for ada@outlook.com expired or was revoked. Your events are still safe in Outlook. Reconnect and Compass will re-import them.", + ); + expect(RECONNECT_CALENDAR_LABEL.microsoft).toBe("Reconnect Outlook"); + expect(CONNECT_CALENDAR_LABEL.microsoft).toBe("Connect Outlook"); + expect(openingProviderCopy("microsoft")).toBe("Opening Microsoft…"); + expect(emptyCalendarsCopy(["microsoft"])).toBe( + "Connect Microsoft to see your calendars.", + ); + expect(defaultCalendarGroupLabel("ada@outlook.com", "microsoft")).toBe( + "ada@outlook.com (Microsoft)", + ); + expect(reconnectPointerHint("microsoft")).toBe( + "Press G to reconnect Outlook.", + ); + }); + + it("uses provider-neutral empty copy when more than one provider can connect", () => { + expect(emptyCalendarsCopy(["google", "microsoft"])).toBe( + "Connect a calendar to see your calendars.", + ); + }); + + it("keeps Google booking-connect copy byte-identical", () => { + expect(bookingConnectPromptCopy(["google"])).toBe( + "Connect a Google account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", + ); + expect(bookingConnectPromptCopy(["microsoft"])).toBe( + "Connect a Microsoft account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", + ); + expect(bookingConnectPromptCopy(["google", "microsoft"])).toBe( + "Connect a calendar account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", + ); + expect(bookingConnectPromptCopy([])).toBe( + "Connect a Google account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", + ); + }); + + it("relabels Google connect commands for another provider", () => { + const connect = relabelConnectCommand( + { + label: "Connect Google Calendar", + icon: ArrowsClockwiseIcon, + onSelect: () => {}, + }, + "microsoft", + ); + expect(connect?.label).toBe("Connect Outlook"); + + const reconnect = relabelConnectCommand( + { + label: "Reconnect Google Calendar", + icon: ArrowsClockwiseIcon, + onSelect: () => {}, + }, + "microsoft", + ); + expect(reconnect?.label).toBe("Reconnect Outlook"); + }); +}); diff --git a/packages/web/src/auth/providers/provider-copy.util.ts b/packages/web/src/auth/providers/provider-copy.util.ts new file mode 100644 index 0000000000..c8e1ba3ddb --- /dev/null +++ b/packages/web/src/auth/providers/provider-copy.util.ts @@ -0,0 +1,137 @@ +import { + type ProviderKind, + providerDisplayName, +} from "@core/types/sync/identity.contracts"; +import { type SyncConnectionSummary } from "@core/types/user.types"; +import { type GoogleUiConfig } from "@web/auth/providers/connect.types"; + +export function connectionProvider( + connection: Pick | null | undefined, +): ProviderKind { + return connection?.provider ?? "google"; +} + +export const CALENDAR_PRODUCT_NAME: Record = { + google: "Google Calendar", + microsoft: "Outlook", + apple: "Apple Calendar", +}; + +export function calendarProductName(kind: ProviderKind): string { + return CALENDAR_PRODUCT_NAME[kind]; +} + +export const CONNECT_CALENDAR_LABEL: Record = { + google: "Connect Google Calendar", + microsoft: "Connect Outlook", + apple: "Connect Apple Calendar", +}; + +export const RECONNECT_CALENDAR_LABEL: Record = { + google: "Reconnect Google Calendar", + microsoft: "Reconnect Outlook", + apple: "Reconnect Apple Calendar", +}; + +export const RECONNECT_BANNER_MESSAGE: Record = { + google: "Google Calendar needs reconnecting.", + microsoft: "Outlook needs reconnecting.", + apple: "Apple Calendar needs reconnecting.", +}; + +const EMPTY_CALENDARS_COPY: Record = { + google: "Connect Google to see your calendars.", + microsoft: "Connect Microsoft to see your calendars.", + apple: "Connect Apple to see your calendars.", +}; + +const EVENTS_SAFE_PLACE: Record = { + google: "Google", + microsoft: "Outlook", + apple: "Apple", +}; + +export function openingProviderCopy(kind: ProviderKind): string { + return `Opening ${providerDisplayName(kind)}…`; +} + +export function emptyCalendarsCopy( + connectable: readonly ProviderKind[], +): string { + if (connectable.length === 1) { + return EMPTY_CALENDARS_COPY[connectable[0]!]; + } + return "Connect a calendar to see your calendars."; +} + +const BOOKING_CONNECT_PROMPT: Record = { + google: + "Connect a Google account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", + microsoft: + "Connect a Microsoft account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", + apple: + "Connect an Apple account to enable your booking page. Guests book through a public link and Compass creates events on your calendar.", +}; + +export function bookingConnectPromptCopy( + connectable: readonly ProviderKind[], +): string { + if (connectable.length <= 1) { + return BOOKING_CONNECT_PROMPT[connectable[0] ?? "google"]; + } + return "Connect a calendar account to enable your booking page. Guests book through a public link and Compass creates events on your calendar."; +} + +export const BOOKING_CONNECT_BUTTON_LABEL: Record = { + google: "Connect Google", + microsoft: CONNECT_CALENDAR_LABEL.microsoft, + apple: CONNECT_CALENDAR_LABEL.apple, +}; + +export function defaultCalendarGroupLabel( + accountEmail: string, + kind: ProviderKind, +): string { + return `${accountEmail} (${providerDisplayName(kind)})`; +} + +export function reconnectToastTitle( + kind: ProviderKind, + accountEmail?: string | null, +): string { + const product = calendarProductName(kind); + const named = accountEmail?.trim(); + return named + ? `${product} disconnected (${named})` + : `${product} disconnected`; +} + +export function reconnectToastBody( + kind: ProviderKind, + accountEmail?: string | null, +): string { + const named = accountEmail?.trim(); + const safePlace = EVENTS_SAFE_PLACE[kind]; + if (named) { + return `Access for ${named} expired or was revoked. Your events are still safe in ${safePlace}. Reconnect and Compass will re-import them.`; + } + return `This happens when access expires or is revoked. Your events are still safe in ${safePlace}. Reconnect and Compass will re-import them.`; +} + +export function reconnectPointerHint(kind: ProviderKind): string { + return `Press G to reconnect ${calendarProductName(kind)}.`; +} + +export function relabelConnectCommand( + commandAction: GoogleUiConfig["commandAction"], + kind: ProviderKind, +): GoogleUiConfig["commandAction"] { + if (!commandAction) return null; + if (commandAction.label === CONNECT_CALENDAR_LABEL.google) { + return { ...commandAction, label: CONNECT_CALENDAR_LABEL[kind] }; + } + if (commandAction.label === RECONNECT_CALENDAR_LABEL.google) { + return { ...commandAction, label: RECONNECT_CALENDAR_LABEL[kind] }; + } + return commandAction; +} diff --git a/packages/web/src/auth/google/state/google.reconnect.calendar.ts b/packages/web/src/auth/providers/reconnect.calendar.ts similarity index 93% rename from packages/web/src/auth/google/state/google.reconnect.calendar.ts rename to packages/web/src/auth/providers/reconnect.calendar.ts index e93975e024..e3a2d30101 100644 --- a/packages/web/src/auth/google/state/google.reconnect.calendar.ts +++ b/packages/web/src/auth/providers/reconnect.calendar.ts @@ -2,7 +2,7 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { isAccountReconnectRequired, isConnectionReconnectRequired, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { selectGoogleSyncConnections, useUserMetadataStore, diff --git a/packages/web/src/auth/google/state/google.reconnect.state.test.ts b/packages/web/src/auth/providers/reconnect.state.test.ts similarity index 98% rename from packages/web/src/auth/google/state/google.reconnect.state.test.ts rename to packages/web/src/auth/providers/reconnect.state.test.ts index ddb586847f..11b67320ff 100644 --- a/packages/web/src/auth/google/state/google.reconnect.state.test.ts +++ b/packages/web/src/auth/providers/reconnect.state.test.ts @@ -8,7 +8,7 @@ import { markAccountReconnectRequired, resetGoogleReconnectRequiredForTests, syncReconnectRequiredFromConnections, -} from "./google.reconnect.state"; +} from "./reconnect.state"; import { afterEach, describe, expect, it } from "bun:test"; afterEach(() => { diff --git a/packages/web/src/auth/google/state/google.reconnect.state.ts b/packages/web/src/auth/providers/reconnect.state.ts similarity index 100% rename from packages/web/src/auth/google/state/google.reconnect.state.ts rename to packages/web/src/auth/providers/reconnect.state.ts diff --git a/packages/web/src/auth/google/util/google-revocation-api.config.ts b/packages/web/src/auth/providers/revocation-api.config.ts similarity index 67% rename from packages/web/src/auth/google/util/google-revocation-api.config.ts rename to packages/web/src/auth/providers/revocation-api.config.ts index 4b3ca7a6a6..f16383d8c9 100644 --- a/packages/web/src/auth/google/util/google-revocation-api.config.ts +++ b/packages/web/src/auth/providers/revocation-api.config.ts @@ -1,5 +1,5 @@ import { BaseApi } from "@web/api/base/base.api"; -import { handleGoogleRevoked } from "@web/auth/google/util/google.auth.util"; +import { handleGoogleRevoked } from "@web/auth/providers/connection-revoked.util"; export function configureGoogleRevocationApiHandler(): void { BaseApi.defaults.onGoogleRevoked = handleGoogleRevoked; diff --git a/packages/web/src/auth/google/state/google.sync.state.ts b/packages/web/src/auth/providers/sync.indicator.state.ts similarity index 100% rename from packages/web/src/auth/google/state/google.sync.state.ts rename to packages/web/src/auth/providers/sync.indicator.state.ts diff --git a/packages/web/src/auth/google/state/google.sync.refresh.test.ts b/packages/web/src/auth/providers/sync.refresh.test.ts similarity index 96% rename from packages/web/src/auth/google/state/google.sync.refresh.test.ts rename to packages/web/src/auth/providers/sync.refresh.test.ts index d6fe13072e..d4cf8186af 100644 --- a/packages/web/src/auth/google/state/google.sync.refresh.test.ts +++ b/packages/web/src/auth/providers/sync.refresh.test.ts @@ -1,4 +1,4 @@ -import { createGoogleSyncRefreshCoordinator } from "@web/auth/google/state/google.sync.refresh"; +import { createGoogleSyncRefreshCoordinator } from "@web/auth/providers/sync.refresh"; import { describe, expect, it, mock } from "bun:test"; describe("createGoogleSyncRefreshCoordinator", () => { diff --git a/packages/web/src/auth/google/state/google.sync.refresh.ts b/packages/web/src/auth/providers/sync.refresh.ts similarity index 100% rename from packages/web/src/auth/google/state/google.sync.refresh.ts rename to packages/web/src/auth/providers/sync.refresh.ts diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx b/packages/web/src/auth/providers/useConnectProvider.scope.test.tsx similarity index 93% rename from packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx rename to packages/web/src/auth/providers/useConnectProvider.scope.test.tsx index 95594b2180..03cd2e3e8f 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx +++ b/packages/web/src/auth/providers/useConnectProvider.scope.test.tsx @@ -3,7 +3,7 @@ import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; import { createStoreWrapper } from "@web/__tests__/render-with-store"; import { AuthApi } from "@web/api/auth.api"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; -import { useConnectGoogle } from "./useConnectGoogle"; +import { useConnectProvider } from "./useConnectProvider"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; // Reconnecting one of several accounts must rebind consent to THAT account's @@ -27,12 +27,13 @@ const connection = ( const renderScoped = (scoped?: GoogleSyncConnectionSummary) => { const { wrapper } = createStoreWrapper(); return renderHook( - () => useConnectGoogle(scoped ? { connection: scoped } : undefined), + () => + useConnectProvider("google", scoped ? { connection: scoped } : undefined), { wrapper }, ); }; -describe("useConnectGoogle account scoping", () => { +describe("useConnectProvider account scoping", () => { beforeEach(() => { userMetadataActions.set({ google: { @@ -125,7 +126,7 @@ describe("useConnectGoogle account scoping", () => { const { wrapper } = createStoreWrapper(); const { result } = renderHook( - () => useConnectGoogle({ newAccount: true }), + () => useConnectProvider("google", { newAccount: true }), { wrapper }, ); act(() => result.current.connect()); @@ -144,7 +145,7 @@ describe("useConnectGoogle account scoping", () => { const { wrapper } = createStoreWrapper(); const { result } = renderHook( - () => useConnectGoogle({ features: ["contacts"] }), + () => useConnectProvider("google", { features: ["contacts"] }), { wrapper }, ); act(() => result.current.connect()); @@ -168,7 +169,7 @@ describe("useConnectGoogle account scoping", () => { const { wrapper } = createStoreWrapper(); const { result } = renderHook( - () => useConnectGoogle({ newAccount: true }), + () => useConnectProvider("google", { newAccount: true }), { wrapper, }, diff --git a/packages/web/src/auth/providers/useConnectProvider.ts b/packages/web/src/auth/providers/useConnectProvider.ts index 95deb413c1..941fca94ce 100644 --- a/packages/web/src/auth/providers/useConnectProvider.ts +++ b/packages/web/src/auth/providers/useConnectProvider.ts @@ -9,20 +9,24 @@ import { AuthApi } from "@web/api/auth.api"; import { type UseConnectGoogleOptions, type UseConnectGoogleResult, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +} from "@web/auth/providers/connect.types"; import { connectionHasReconnectRequired, getGoogleConnectionConfig, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; -import { useGoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useGoogleUiState"; +} from "@web/auth/providers/connect.util"; +import { + connectionProvider, + relabelConnectCommand, +} from "@web/auth/providers/provider-copy.util"; import { noteGoogleSyncRefreshImproved, refreshGoogleSync, useGoogleSyncRefreshSnapshot, -} from "@web/auth/google/state/google.sync.refresh"; +} from "@web/auth/providers/sync.refresh"; import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; +import { useGoogleUiState } from "@web/auth/providers/useProviderUiState"; import { - selectPrimaryGoogleSyncConnection, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { @@ -43,11 +47,17 @@ export const useConnectProvider = ( ): UseConnectGoogleResult => { const isAvailable = useIsProviderAvailable(kind, "connect"); const aggregateState = useGoogleUiState(); - const primaryConnection = useUserMetadataStore( - selectPrimaryGoogleSyncConnection, - ); + const connections = useUserMetadataStore(selectSyncConnections); + const kindPrimary = + connections.find( + (connection) => + connectionProvider(connection) === kind && + connection.connectionState === aggregateState, + ) ?? + connections.find((connection) => connectionProvider(connection) === kind) ?? + null; const scopedConnection = options?.connection; - const syncConnection = scopedConnection ?? primaryConnection; + const syncConnection = scopedConnection ?? kindPrimary; const state = scopedConnection != null && connectionHasReconnectRequired(scopedConnection) ? "RECONNECT_REQUIRED" @@ -173,18 +183,20 @@ export const useConnectProvider = ( [queryClient, refreshSnapshot.isRefreshing], ); + const googleConfig = getGoogleConnectionConfig( + state, + { + onConnectGoogle: onOpenAuth, + onRefreshGoogle: onRefresh, + }, + { + refreshGaveUp: refreshSnapshot.gaveUp, + }, + ); + return { - ...getGoogleConnectionConfig( - state, - { - onConnectGoogle: onOpenAuth, - onRefreshGoogle: onRefresh, - }, - { - refreshGaveUp: refreshSnapshot.gaveUp, - provider: kind, - }, - ), + ...googleConfig, + commandAction: relabelConnectCommand(googleConfig.commandAction, kind), connect: onOpenAuth, connection: syncConnection, refresh: onRefresh, diff --git a/packages/web/src/auth/google/hooks/useDisconnectGoogleAccount.ts b/packages/web/src/auth/providers/useDisconnectAccount.ts similarity index 97% rename from packages/web/src/auth/google/hooks/useDisconnectGoogleAccount.ts rename to packages/web/src/auth/providers/useDisconnectAccount.ts index 3b497ae6d6..9bd216eb59 100644 --- a/packages/web/src/auth/google/hooks/useDisconnectGoogleAccount.ts +++ b/packages/web/src/auth/providers/useDisconnectAccount.ts @@ -3,7 +3,7 @@ import { useCallback, useState } from "react"; import { type Calendar } from "@core/types/calendar.contracts"; import { AuthApi } from "@web/api/auth.api"; import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; -import { clearAccountReconnectRequired } from "@web/auth/google/state/google.reconnect.state"; +import { clearAccountReconnectRequired } from "@web/auth/providers/reconnect.state"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx index 39df8c1199..28f147cab9 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx +++ b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx @@ -32,4 +32,48 @@ describe("useIsProviderAvailable", () => { }); expect(getConfig).toHaveBeenCalledTimes(1); }); + + it("gates google sign-in on the baked client id even when the backend is configured", async () => { + getConfig.mockClear(); + getConfig.mockResolvedValue({ + google: { isConfigured: true }, + }); + const { resetProviderAvailabilityForTests, useIsProviderAvailable } = + createProviderAvailability({ + getConfig, + isGoogleAuthConfigured: false, + }); + resetProviderAvailabilityForTests(); + + const { result } = renderHook(() => + useIsProviderAvailable("google", "signIn"), + ); + + await waitFor(() => { + expect(getConfig).toHaveBeenCalledTimes(1); + }); + expect(result.current).toBe(false); + }); + + it("lets google connect succeed without a baked client id", async () => { + getConfig.mockClear(); + getConfig.mockResolvedValue({ + google: { isConfigured: true }, + }); + const { resetProviderAvailabilityForTests, useIsProviderAvailable } = + createProviderAvailability({ + getConfig, + isGoogleAuthConfigured: false, + }); + resetProviderAvailabilityForTests(); + + const { result } = renderHook(() => + useIsProviderAvailable("google", "connect"), + ); + + expect(result.current).toBe(false); + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); }); diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.ts b/packages/web/src/auth/providers/useIsProviderAvailable.ts index 01a66d1d5b..e7c745c79b 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.ts +++ b/packages/web/src/auth/providers/useIsProviderAvailable.ts @@ -2,6 +2,9 @@ import { providerAvailability } from "@web/auth/providers/provider-availability. export const { useIsProviderAvailable, + useIsGoogleAvailable, setProviderAvailabilityForTests, resetProviderAvailabilityForTests, + setGoogleAvailabilityForTests, + resetGoogleAvailabilityForTests, } = providerAvailability; diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useGoogleUiState.test.ts b/packages/web/src/auth/providers/useProviderUiState.test.ts similarity index 93% rename from packages/web/src/auth/google/hooks/useConnectGoogle/useGoogleUiState.test.ts rename to packages/web/src/auth/providers/useProviderUiState.test.ts index ba6e2e3592..5c50535b08 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useGoogleUiState.test.ts +++ b/packages/web/src/auth/providers/useProviderUiState.test.ts @@ -2,9 +2,9 @@ import { act, cleanup, renderHook } from "@testing-library/react"; import { resetGoogleSyncUIStateForTests, setSyncingSyncIndicatorOverride, -} from "@web/auth/google/state/google.sync.state"; +} from "@web/auth/providers/sync.indicator.state"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; -import { resolveGoogleUiState, useGoogleUiState } from "./useGoogleUiState"; +import { resolveGoogleUiState, useGoogleUiState } from "./useProviderUiState"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; beforeEach(() => { diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useGoogleUiState.ts b/packages/web/src/auth/providers/useProviderUiState.ts similarity index 92% rename from packages/web/src/auth/google/hooks/useConnectGoogle/useGoogleUiState.ts rename to packages/web/src/auth/providers/useProviderUiState.ts index 1c71fc8e9e..749428d176 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useGoogleUiState.ts +++ b/packages/web/src/auth/providers/useProviderUiState.ts @@ -4,18 +4,18 @@ import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.uti import { hasGoogleReconnectRequired, useGoogleReconnectRequiredVersion, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { getGoogleSyncIndicatorOverride, subscribeToGoogleSyncUIState, -} from "@web/auth/google/state/google.sync.state"; +} from "@web/auth/providers/sync.indicator.state"; import { selectGoogleConnectionState, selectUserMetadataStatus, type UserMetadataStatus, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; -import { type GoogleUiState } from "./useConnectGoogle.types"; +import { type GoogleUiState } from "./connect.types"; type SyncIndicator = ReturnType; diff --git a/packages/web/src/auth/state/user-metadata.store.test.ts b/packages/web/src/auth/state/user-metadata.store.test.ts index 094282b5a3..8c7dcb9149 100644 --- a/packages/web/src/auth/state/user-metadata.store.test.ts +++ b/packages/web/src/auth/state/user-metadata.store.test.ts @@ -1,6 +1,7 @@ import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; import { findPrimaryGoogleSyncConnectionFromMetadata, + findSyncConnectionFromMetadata, selectPrimaryGoogleSyncConnection, userMetadataActions, useUserMetadataStore, @@ -91,7 +92,7 @@ describe("userMetadataActions.removeConnection", () => { describe("findPrimaryGoogleSyncConnectionFromMetadata", () => { it("applies the same precedence to a raw payload, not just the store", () => { - // useGcalSSE.factory.ts calls this directly on an SSE message's metadata, + // useSyncSSE.factory.ts calls this directly on an SSE message's metadata, // before it reaches the store. const healthy = connection({ id: "healthy", connectionState: "HEALTHY" }); const broken = connection({ @@ -114,3 +115,30 @@ describe("findPrimaryGoogleSyncConnectionFromMetadata", () => { expect(findPrimaryGoogleSyncConnectionFromMetadata({})).toBeNull(); }); }); + +describe("findSyncConnectionFromMetadata", () => { + it("finds a non-primary connection by id on connections[]", () => { + const google = connection({ + id: "google-primary", + connectionState: "HEALTHY", + }); + const microsoft = connection({ + id: "ms-secondary", + accountEmail: "ada@outlook.com", + connectionState: "HEALTHY", + }); + + expect( + findSyncConnectionFromMetadata( + { + google: { connectionState: "HEALTHY", connections: [google] }, + connections: [ + { ...google, provider: "google" }, + { ...microsoft, provider: "microsoft" }, + ], + }, + "ms-secondary", + )?.accountEmail, + ).toBe("ada@outlook.com"); + }); +}); diff --git a/packages/web/src/auth/state/user-metadata.store.ts b/packages/web/src/auth/state/user-metadata.store.ts index 2e6a5d50bf..8211f9718d 100644 --- a/packages/web/src/auth/state/user-metadata.store.ts +++ b/packages/web/src/auth/state/user-metadata.store.ts @@ -181,10 +181,36 @@ export const selectPrimaryGoogleSyncConnection = ( ): GoogleSyncConnectionSummary | null => findPrimaryGoogleSyncConnection(state.current?.google); +/** + * Every connection on a raw metadata payload (SSE `userMetadataChanged`), + * preferring the WP-07 `connections[]` field. + */ +export const findSyncConnectionsFromMetadata = ( + metadata: UserMetadata, +): SyncConnectionSummary[] => + metadata.connections ?? metadata.google?.connections ?? NO_CONNECTIONS; + +/** + * Look up one connection by id on a raw payload. Without an id, falls back + * to the aggregate primary — the same precedence as the store selector. + */ +export const findSyncConnectionFromMetadata = ( + metadata: UserMetadata, + connectionId?: string | null, +): SyncConnectionSummary | null => { + const connections = findSyncConnectionsFromMetadata(metadata); + if (connectionId) { + return ( + connections.find((connection) => connection.id === connectionId) ?? null + ); + } + return findPrimaryGoogleSyncConnection(metadata.google); +}; + /** * Same selection, for a raw `UserMetadata` payload that hasn't gone through * the store yet (an SSE `userMetadataChanged` message) - see - * useGcalSSE.factory.ts. + * useSyncSSE.factory.ts. */ export const findPrimaryGoogleSyncConnectionFromMetadata = ( metadata: UserMetadata, diff --git a/packages/web/src/booking/BookingConnectGooglePrompt.tsx b/packages/web/src/booking/BookingConnectGooglePrompt.tsx index d882e76d7b..9e28630775 100644 --- a/packages/web/src/booking/BookingConnectGooglePrompt.tsx +++ b/packages/web/src/booking/BookingConnectGooglePrompt.tsx @@ -1,31 +1,17 @@ -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; -import { - OverlayPanelActionButton, - OverlayPanelActions, -} from "@web/components/OverlayPanel/OverlayPanel"; +import { ConnectProviderChooser } from "@web/auth/providers/ConnectProviderChooser"; +import { bookingConnectPromptCopy } from "@web/auth/providers/provider-copy.util"; +import { useAvailableConnectProviders } from "@web/auth/providers/useAvailableConnectProviders"; export function BookingConnectGooglePrompt() { - const { connect, isAvailable, isConnecting } = useConnectGoogle({ - newAccount: true, - }); + const connectable = useAvailableConnectProviders(); return (

- Connect a Google account to enable your booking page. Guests book - through a public link and Compass creates events on your calendar. + {bookingConnectPromptCopy(connectable)}

- {isAvailable ? ( - - - {isConnecting ? "Opening Google…" : "Connect Google"} - - + {connectable.length > 0 ? ( + ) : (

Google sign-in is not configured in this environment. diff --git a/packages/web/src/booking/BookingSettingsSection.test.tsx b/packages/web/src/booking/BookingSettingsSection.test.tsx index 3da91e5bdb..9133c02e1e 100644 --- a/packages/web/src/booking/BookingSettingsSection.test.tsx +++ b/packages/web/src/booking/BookingSettingsSection.test.tsx @@ -162,6 +162,32 @@ describe("BookingSettingsSection", () => { expect(screen.queryByLabelText("Duration")).not.toBeInTheDocument(); }); + it("treats a healthy non-google connection as connected for booking", () => { + userMetadataActions.set({ + google: { + connectionState: "NOT_CONNECTED", + connections: [], + }, + connections: [ + createMockConnection("ada@outlook.com", { provider: "microsoft" }), + ], + }); + + const { wrapper } = createStoreWrapper(); + render( + + + , + { wrapper }, + ); + + expect( + screen.queryByText( + /Connect a Google account to enable your booking page/, + ), + ).not.toBeInTheDocument(); + }); + it("saves 30-minute duration and shows the copyable booking link", async () => { const user = userEvent.setup({ delay: null }); const slug = "hostuser"; diff --git a/packages/web/src/booking/BookingSettingsSection.tsx b/packages/web/src/booking/BookingSettingsSection.tsx index a465cbac82..2cf6039e7e 100644 --- a/packages/web/src/booking/BookingSettingsSection.tsx +++ b/packages/web/src/booking/BookingSettingsSection.tsx @@ -17,7 +17,7 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { type CalendarId, TimeZoneSchema } from "@core/types/domain-primitives"; import { selectGoogleConnectionState, - selectGoogleSyncConnections, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { useAppAccess } from "@web/billing/useAppAccess"; @@ -207,8 +207,11 @@ export function BookingSettingsSection({ const googleConnectionState = useUserMetadataStore( selectGoogleConnectionState, ); - const connections = useUserMetadataStore(selectGoogleSyncConnections); - const isGoogleHealthy = googleConnectionState === "HEALTHY"; + const connections = useUserMetadataStore(selectSyncConnections); + const hasHealthyConnection = + connections.some( + (connection) => connection.connectionState === "HEALTHY", + ) || googleConnectionState === "HEALTHY"; const access = useAppAccess(); const isReadOnly = access.kind === "server" && access.isReadOnly; const effectiveTimeZone = useEffectiveTimeZone(); @@ -228,7 +231,8 @@ export function BookingSettingsSection({ ), [accountEmailOrder, calendars], ); - const { data: serverPage, isPending } = useBookingPageQuery(isGoogleHealthy); + const { data: serverPage, isPending } = + useBookingPageQuery(hasHealthyConnection); const saveMutation = useSaveBookingPageMutation(); const [form, setForm] = useState(() => buildInitialForm( @@ -323,7 +327,7 @@ export function BookingSettingsSection({ }; }, [dismissGuardRef]); - if (!isGoogleHealthy) { + if (!hasHealthyConnection) { return ; } diff --git a/packages/web/src/calendars/calendar.util.test.ts b/packages/web/src/calendars/calendar.util.test.ts index 82637eab2f..493fbdb128 100644 --- a/packages/web/src/calendars/calendar.util.test.ts +++ b/packages/web/src/calendars/calendar.util.test.ts @@ -111,6 +111,18 @@ describe("getDefaultTargetCalendar", () => { ); }); + it("prefers a writable microsoft primary the same way", () => { + const local = makeCalendar({ provider: "local" }); + const primaryMicrosoft = makeCalendar({ + provider: "microsoft", + isPrimary: true, + id: "507f1f77bcf86cd799439014" as Calendar["id"], + }); + expect(getDefaultTargetCalendar([local, primaryMicrosoft])).toBe( + primaryMicrosoft, + ); + }); + it("skips a reconnect-required account when choosing the default target", () => { const brokenPrimary = makeCalendar({ provider: "google", diff --git a/packages/web/src/calendars/calendar.util.ts b/packages/web/src/calendars/calendar.util.ts index 7e0113f4ee..af34901cad 100644 --- a/packages/web/src/calendars/calendar.util.ts +++ b/packages/web/src/calendars/calendar.util.ts @@ -1,6 +1,6 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { type SyncConnectionSummary } from "@core/types/user.types"; -import { isCalendarReconnectRequired } from "@web/auth/google/state/google.reconnect.calendar"; +import { isCalendarReconnectRequired } from "@web/auth/providers/reconnect.calendar"; export function getLocalCalendar(calendars: Calendar[]): Calendar | undefined { return calendars.find((calendar) => calendar.provider === "local"); @@ -217,11 +217,11 @@ export interface DefaultTargetCalendarOptions { reconnectRequiredEmails?: ReadonlySet | readonly string[]; } -const isWritableGoogleCalendar = ( +const isWritableProviderCalendar = ( calendar: Calendar, reconnectRequiredEmails: ReadonlySet | null, ): boolean => - calendar.provider === "google" && + calendar.provider !== "local" && calendar.capabilities.canWrite && !calendarNeedsReconnect(calendar, reconnectRequiredEmails); @@ -248,7 +248,7 @@ export function getDefaultTargetCalendar( ? calendars.find((calendar) => calendar.id === preferredCalendarId) : undefined; // The local calendar is a valid explicit choice while disconnected, even - // though it is not a writable *Google* calendar. + // though it is not a writable *provider* calendar. if ( preferred?.capabilities.canWrite && !calendarNeedsReconnect(preferred, reconnectRequiredEmails) && @@ -260,7 +260,7 @@ export function getDefaultTargetCalendar( const primaries = calendars.filter( (calendar) => calendar.isPrimary && - isWritableGoogleCalendar(calendar, reconnectRequiredEmails), + isWritableProviderCalendar(calendar, reconnectRequiredEmails), ); const byConnectionOrder = accountEmailOrder .map((email) => diff --git a/packages/web/src/calendars/isEventReadOnly.test.ts b/packages/web/src/calendars/isEventReadOnly.test.ts index bb0a16fbc8..a2bcdb244b 100644 --- a/packages/web/src/calendars/isEventReadOnly.test.ts +++ b/packages/web/src/calendars/isEventReadOnly.test.ts @@ -6,7 +6,7 @@ import { CalendarIdSchema } from "@core/types/domain-primitives"; import { markAccountReconnectRequired, resetGoogleReconnectRequiredForTests, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { buildCalendarLookup, isEventReadOnly, diff --git a/packages/web/src/calendars/useCalendarLookup.ts b/packages/web/src/calendars/useCalendarLookup.ts index d408cc22ed..9c4a0e7976 100644 --- a/packages/web/src/calendars/useCalendarLookup.ts +++ b/packages/web/src/calendars/useCalendarLookup.ts @@ -1,7 +1,7 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { type CalendarId } from "@core/types/domain-primitives"; -import { isCalendarReconnectRequired } from "@web/auth/google/state/google.reconnect.calendar"; -import { useGoogleReconnectRequiredVersion } from "@web/auth/google/state/google.reconnect.state"; +import { isCalendarReconnectRequired } from "@web/auth/providers/reconnect.calendar"; +import { useGoogleReconnectRequiredVersion } from "@web/auth/providers/reconnect.state"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { type CrossAccountDuplicate } from "@web/common/types/web.event.types"; diff --git a/packages/web/src/calendars/useDefaultTargetCalendar.ts b/packages/web/src/calendars/useDefaultTargetCalendar.ts index ccc196c100..f43fd00166 100644 --- a/packages/web/src/calendars/useDefaultTargetCalendar.ts +++ b/packages/web/src/calendars/useDefaultTargetCalendar.ts @@ -3,7 +3,7 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { getGoogleReconnectRequiredAccountEmails, useGoogleReconnectRequiredVersion, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { selectGoogleSyncConnections, useUserMetadataStore, diff --git a/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx b/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx index ddc29bd23c..0712cdbecf 100644 --- a/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx +++ b/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx @@ -3,7 +3,7 @@ import { render, screen, within } from "@testing-library/react"; import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; import { pressKey } from "@web/__tests__/utils/keyboard.test.util"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; -import * as realConnectGoogle from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import * as realConnectProvider from "@web/auth/providers/useConnectProvider"; import { resetBillingGateAttentionForTests, setBillingGateOwnsScreen, @@ -24,9 +24,9 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const mockRefresh = mock(); mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: () => ({ refresh: mockRefresh }) }, + "@web/auth/providers/useConnectProvider", + realConnectProvider, + { useConnectProvider: () => ({ refresh: mockRefresh }) }, ); describe("GoogleDelayedToast", () => { diff --git a/packages/web/src/common/utils/toast/google-delayed.toast.tsx b/packages/web/src/common/utils/toast/google-delayed.toast.tsx index 8a0afcb09e..3e15808b40 100644 --- a/packages/web/src/common/utils/toast/google-delayed.toast.tsx +++ b/packages/web/src/common/utils/toast/google-delayed.toast.tsx @@ -1,6 +1,6 @@ import { createElement } from "react"; import { type Id } from "react-toastify"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { rememberPendingDelayed, shouldDeferAttentionToasts, @@ -22,9 +22,9 @@ interface GoogleDelayedToastProps { // Shown when Sync reports delayed / soft ATTENTION so returning users get an // actionable Refresh rather than a dead-end warning. Mirrors the reconnect -// toast layout and delegates to useConnectGoogle().refresh(). +// toast layout and delegates to useConnectProvider().refresh(). export const GoogleDelayedToast = ({ toastId }: GoogleDelayedToastProps) => { - const { refresh } = useConnectGoogle(); + const { refresh } = useConnectProvider("google"); const handleRefresh = () => { getToast().dismiss(toastId); diff --git a/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx b/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx index 96fc2d6f30..1ad07e583b 100644 --- a/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx +++ b/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx @@ -119,10 +119,10 @@ describe("GoogleReconnectToast", () => { ); expect( - screen.getByText("Microsoft Calendar disconnected (user@outlook.com)"), + screen.getByText("Outlook disconnected (user@outlook.com)"), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Reconnect Microsoft Calendar" }), + screen.getByRole("button", { name: "Reconnect Outlook" }), ).toBeInTheDocument(); }); diff --git a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx index ea1d03a7c5..7e79ec3502 100644 --- a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx +++ b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx @@ -1,12 +1,14 @@ import { createElement } from "react"; import { type Id } from "react-toastify"; -import { - type ProviderKind, - providerDisplayName, -} from "@core/types/sync/identity.contracts"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; import { type SyncConnectionSummary } from "@core/types/user.types"; -import { type GoogleReconnectTarget } from "@web/auth/google/state/google.reconnect.state"; -import { connectionProviderKind } from "@web/auth/providers/connection-provider.util"; +import { + connectionProvider, + RECONNECT_CALENDAR_LABEL, + reconnectToastBody, + reconnectToastTitle, +} from "@web/auth/providers/provider-copy.util"; +import { type GoogleReconnectTarget } from "@web/auth/providers/reconnect.state"; import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { selectSyncConnections, @@ -47,12 +49,13 @@ interface GoogleReconnectToastProps { toastId: Id; accountEmail?: string | null; connectionId?: string | null; + provider?: ProviderKind; } const toastScopedConnection = ( connectionId: string | null | undefined, accountEmail: string | null | undefined, - provider: ProviderKind = "google", + provider: ProviderKind, ): SyncConnectionSummary => ({ id: connectionId?.trim() || "reconnect-target", provider, @@ -65,46 +68,27 @@ const toastScopedConnection = ( canSuggestContacts: false, }); -const reconnectToastTitle = ( - provider: ProviderKind, - namedAccount?: string, -): string => { - const calendarName = `${providerDisplayName(provider)} Calendar`; - return namedAccount - ? `${calendarName} disconnected (${namedAccount})` - : `${calendarName} disconnected`; -}; - -const reconnectToastBody = ( - provider: ProviderKind, - namedAccount?: string, -): string => { - const host = providerDisplayName(provider); - if (namedAccount) { - return `Access for ${namedAccount} expired or was revoked. Your events are still safe in ${host}. Reconnect and Compass will re-import them.`; - } - return `This happens when access expires or is revoked. Your events are still safe in ${host}. Reconnect and Compass will re-import them.`; -}; - -const reconnectActionLabel = (provider: ProviderKind): string => - `Reconnect ${providerDisplayName(provider)} Calendar`; - export const GoogleReconnectToast = ({ toastId, accountEmail, connectionId, + provider: providerProp, }: GoogleReconnectToastProps) => { const connections = useUserMetadataStore(selectSyncConnections); const connectionFromStore = connections.find((entry) => entry.id === connectionId) ?? connections.find((entry) => entry.accountEmail === accountEmail) ?? null; + const kind = connectionProvider( + connectionFromStore ?? (providerProp ? { provider: providerProp } : null), + ); const connection = connectionFromStore ?? - (connectionId ? toastScopedConnection(connectionId, accountEmail) : null); - const provider = connectionProviderKind(connection); + (connectionId + ? toastScopedConnection(connectionId, accountEmail, kind) + : null); const { connect } = useConnectProvider( - provider, + kind, connection ? { connection } : undefined, ); @@ -118,16 +102,16 @@ export const GoogleReconnectToast = ({ return (

- {reconnectToastTitle(provider, namedAccount || undefined)} + {reconnectToastTitle(kind, namedAccount)}

- {reconnectToastBody(provider, namedAccount || undefined)} + {reconnectToastBody(kind, namedAccount)}

- {reconnectActionLabel(provider)} + {RECONNECT_CALENDAR_LABEL[kind]} ); diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index b9edb886cc..c19edceaf6 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -23,7 +23,7 @@ import { registerUseStartGoogleAuthorizationForTests } from "@web/auth/google/au import { resetGoogleAvailabilityForTests, setGoogleAvailabilityForTests, -} from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; +} from "@web/auth/providers/useIsProviderAvailable"; import { AuthModal } from "./AuthModal"; import { AuthModalProvider } from "./AuthModalProvider"; import { useAuthModal, validateAuthSearch } from "./hooks/useAuthModal"; diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index 1022e7b21e..b2c9178a3a 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { consumeGoogleAuthNeedsConsentRetry } from "@web/auth/google/authorization/google-authorization.storage"; import { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization"; -import { useIsGoogleAvailable } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; +import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; import { dismissErrorToast, @@ -68,7 +68,7 @@ export const AuthModal: FC = () => { loading: isGoogleAuthLoading, startGoogleAuthorization: startGoogleSignIn, } = googleAuth; - const isGoogleAvailable = useIsGoogleAvailable(); + const isGoogleAvailable = useIsProviderAvailable("google", "signIn"); const isLoginView = currentView === "login" || currentView === "loginAfterReset"; const search = useSearch({ from: "__root__" }); diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx index 2751ed800a..2ca8c80400 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx @@ -3,7 +3,7 @@ import { type ProviderKind } from "@core/types/sync/identity.contracts"; import { type CalendarConnectionBannerKind, calendarReconnectBannerMessage, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +} from "@web/auth/providers/connect.util"; import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys"; import { POINTER_ACTION_ATTRIBUTE, diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx index 48775754f2..dd951925bf 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx @@ -1,5 +1,5 @@ import { type FC } from "react"; -import { getCalendarConnectionBannerKind } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { getCalendarConnectionBannerKind } from "@web/auth/providers/connect.util"; import { connectionProviderKind } from "@web/auth/providers/connection-provider.util"; import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { diff --git a/packages/web/src/components/CommandPalette/hooks/useShowAccountsCmdItems.ts b/packages/web/src/components/CommandPalette/hooks/useShowAccountsCmdItems.ts index 6e082d0019..b73f543bc7 100644 --- a/packages/web/src/components/CommandPalette/hooks/useShowAccountsCmdItems.ts +++ b/packages/web/src/components/CommandPalette/hooks/useShowAccountsCmdItems.ts @@ -31,6 +31,9 @@ export const useShowAccountsCmdItems = (): CommandItem[] => { "options", "add account", "connect google", + "connect microsoft", + "connect apple", + "connect calendar", "multiple google", "google accounts", "disconnect account", diff --git a/packages/web/src/components/Settings/SettingsModal.test.tsx b/packages/web/src/components/Settings/SettingsModal.test.tsx index 8a266224db..c6e4ae7dfd 100644 --- a/packages/web/src/components/Settings/SettingsModal.test.tsx +++ b/packages/web/src/components/Settings/SettingsModal.test.tsx @@ -12,7 +12,7 @@ import { AuthApi } from "@web/api/auth.api"; import { markAccountReconnectRequired, resetGoogleReconnectRequiredForTests, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { UpgradeConfirmationProvider } from "@web/billing/UpgradeConfirmation/UpgradeConfirmationProvider"; import { type AppAccess } from "@web/billing/useAppAccess"; @@ -226,7 +226,7 @@ describe("SettingsModal", () => { // A live 410 marks the session-local override ahead of the next metadata // refetch; the connection's own connectionState is still "HEALTHY" here. - // AccountsSection's own useConnectGoogle() call subscribes to the + // AccountsSection's own useConnectProvider() call subscribes to the // reconnect-required version and re-renders the tree beneath it, so // AccountRow picks up the override on its next render even without // subscribing itself - this guards that behavior against a refactor that diff --git a/packages/web/src/components/Settings/SettingsModal.tsx b/packages/web/src/components/Settings/SettingsModal.tsx index 62194fd79e..4f76f6c1d0 100644 --- a/packages/web/src/components/Settings/SettingsModal.tsx +++ b/packages/web/src/components/Settings/SettingsModal.tsx @@ -4,16 +4,16 @@ import { type CalendarId } from "@core/types/domain-primitives"; import { providerDisplayName } from "@core/types/sync/identity.contracts"; import { type SyncConnectionSummary } from "@core/types/user.types"; import { useSession } from "@web/auth/compass/session/useSession"; +import { ConnectProviderChooser } from "@web/auth/providers/ConnectProviderChooser"; import { formatLastSyncedLabel, getGoogleSyncStatus, googleSyncSupportMailto, SSE_DEGRADED_STATUS, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; -import { useDisconnectGoogleAccount } from "@web/auth/google/hooks/useDisconnectGoogleAccount"; -import { useGoogleSyncRefreshSnapshot } from "@web/auth/google/state/google.sync.refresh"; -import { ConnectProviderChooser } from "@web/auth/providers/ConnectProviderChooser"; +} from "@web/auth/providers/connect.util"; import { connectionProviderKind } from "@web/auth/providers/connection-provider.util"; +import { useGoogleSyncRefreshSnapshot } from "@web/auth/providers/sync.refresh"; +import { useDisconnectGoogleAccount } from "@web/auth/providers/useDisconnectAccount"; import { selectSyncConnections, useUserMetadataStore, diff --git a/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx b/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx index 8e3a7a3c90..2830512f78 100644 --- a/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { type SyncConnectionSummary } from "@core/types/user.types"; import { createStoreWrapper } from "@web/__tests__/render-with-store"; import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; -import { type GoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type GoogleUiState } from "@web/auth/providers/connect.types"; import { toggleAccountCollapsed } from "@web/calendars/collapsed-accounts.store"; import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; @@ -19,7 +19,12 @@ const onSelect = mock(); const commandActionFor = (state: GoogleUiState, provider = "google") => state === "RECONNECT_REQUIRED" ? { - label: `Reconnect ${provider === "google" ? "Google" : "Microsoft"} Calendar`, + label: + provider === "google" + ? "Reconnect Google Calendar" + : provider === "microsoft" + ? "Reconnect Outlook" + : "Reconnect Apple Calendar", onSelect, } : null; @@ -144,7 +149,7 @@ describe("AccountSectionHeader", () => { await user.click( screen.getByRole("button", { - name: `Reconnect Microsoft Calendar for ${EMAIL}`, + name: `Reconnect Outlook for ${EMAIL}`, }), ); expect(onSelect).toHaveBeenCalledTimes(1); diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx index d00d17324c..45c6bd0604 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx @@ -51,16 +51,17 @@ mock.module("@web/auth/compass/session/useSession", () => ({ isSessionMocked ? mockUseSession(...args) : actualUseSession(...args), })); -const actualUseConnectGoogle = ( - await import("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle") -).useConnectGoogle; +const actualUseConnectProvider = ( + await import("@web/auth/providers/useConnectProvider") +).useConnectProvider; let isConnectGoogleMocked = true; // Mirrors the real hook's one behavior these tests depend on: scoping to a // connection reports that account's own state. (The real scoping is covered -// directly in useConnectGoogle.scope.test.tsx.) Restored in beforeEach, since +// directly in useConnectProvider.scope.test.tsx.) Restored in beforeEach, since // a test that swaps in mockReturnValue would otherwise poison later ones. const defaultUseConnectGoogle = ( - options?: Parameters[0], + _kind?: unknown, + options?: Parameters[1], ) => ({ commandAction: null, connect: mock(), @@ -69,11 +70,11 @@ const defaultUseConnectGoogle = ( state: options?.connection?.connectionState ?? ("NOT_CONNECTED" as const), }); const mockUseConnectGoogle = mock(defaultUseConnectGoogle); -mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ - useConnectGoogle: (...args: Parameters) => +mock.module("@web/auth/providers/useConnectProvider", () => ({ + useConnectProvider: (...args: Parameters) => isConnectGoogleMocked ? mockUseConnectGoogle(...args) - : actualUseConnectGoogle(...args), + : actualUseConnectProvider(...args), })); // The no-accounts-yet header (covered in CalendarListHeader.test.tsx) reads diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx index 6fd70c7ae0..439e069992 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; import * as realAuthStateUtil from "@web/auth/compass/state/auth.state.util"; import * as realUserHook from "@web/auth/compass/user/hooks/useUser"; -import { type GoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type GoogleUiState } from "@web/auth/providers/connect.types"; import * as realAvailableProviders from "@web/auth/providers/useAvailableConnectProviders"; import * as realConnectProvider from "@web/auth/providers/useConnectProvider"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; diff --git a/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts b/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts index e7a5fd16ce..dc03a90641 100644 --- a/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts +++ b/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts @@ -1,5 +1,5 @@ import { type SyncConnectionSummary } from "@core/types/user.types"; -import { getSidebarSyncStatus } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { getSidebarSyncStatus } from "@web/auth/providers/connect.util"; import { connectionProviderKind } from "@web/auth/providers/connection-provider.util"; import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; diff --git a/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.test.tsx b/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.test.tsx index d2fe3b7c98..211de061b0 100644 --- a/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.test.tsx +++ b/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.test.tsx @@ -4,7 +4,7 @@ import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; import { resetGoogleSyncUIStateForTests, setSyncingSyncIndicatorOverride, -} from "@web/auth/google/state/google.sync.state"; +} from "@web/auth/providers/sync.indicator.state"; import * as realUseversioncheck from "@web/components/Sidebar/SidebarActions/useVersionCheck"; import { viewActions } from "@web/events/stores/view.store"; import { afterAll, afterEach, describe, expect, it } from "bun:test"; diff --git a/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.tsx b/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.tsx index 56046fccfd..1b3762feec 100644 --- a/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.tsx +++ b/packages/web/src/components/Sidebar/SidebarActions/SidebarActions.tsx @@ -1,5 +1,5 @@ import { CommandIcon, GearIcon, KeyboardIcon } from "@phosphor-icons/react"; -import { useGoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useGoogleUiState"; +import { useGoogleUiState } from "@web/auth/providers/useProviderUiState"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; import { selectIsShortcutsOpen, diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx index 360fa44e0b..6eefb90e75 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx @@ -5,7 +5,7 @@ import { createStoreWrapper } from "@web/__tests__/render-with-store"; import { seedPendingEventMutations } from "@web/__tests__/utils/event-query-test-data"; import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; -import { type GoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type GoogleUiState } from "@web/auth/providers/connect.types"; import { initialFirstEventPromptState, useFirstEventPromptStore, @@ -56,15 +56,15 @@ import { // mock.module is process-wide and not reliably restorable, so - as in // AccountSectionHeader.test.tsx - the real hook is captured up front and a // flag (flipped in afterAll) decides which one runs. -const actualUseConnectGoogle = ( - await import("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle") -).useConnectGoogle; +const actualUseConnectProvider = ( + await import("@web/auth/providers/useConnectProvider") +).useConnectProvider; let isConnectGoogleMocked = true; let googleState: GoogleUiState = "HEALTHY"; let isConnecting = false; let connection: GoogleSyncConnectionSummary | null = null; -mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ - useConnectGoogle: (...args: Parameters) => +mock.module("@web/auth/providers/useConnectProvider", () => ({ + useConnectProvider: (...args: Parameters) => isConnectGoogleMocked ? { commandAction: null, @@ -76,7 +76,7 @@ mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ isRefreshing: false, state: googleState, } - : actualUseConnectGoogle(...args), + : actualUseConnectProvider(...args), })); // Do not mock.module google.sync.refresh here: process-wide mocks poison diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx index 44bc8da163..708eea240e 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx @@ -1,10 +1,10 @@ import { type FC } from "react"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { getSidebarSyncStatus, SSE_DEGRADED_STATUS, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; -import { useGoogleSyncRefreshSnapshot } from "@web/auth/google/state/google.sync.refresh"; +} from "@web/auth/providers/connect.util"; +import { useGoogleSyncRefreshSnapshot } from "@web/auth/providers/sync.refresh"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { useShortcutWriteLocked } from "@web/billing/useBillingWriteLock"; import { SYNC_STATUS_VARIANT_CLASSNAME } from "@web/calendars/sync-status.types"; import { useHasPendingEventMutations } from "@web/events/mutations/useEventPending"; @@ -68,7 +68,7 @@ export const SidebarStatusBar: FC = () => { // collapse to the aggregate "IMPORTING" state, and only the connection's // own lastHealthyAt tells getSidebarSyncStatus the account was already // established and should stay quiet. - const { connection, isConnecting, state } = useConnectGoogle(); + const { connection, isConnecting, state } = useConnectProvider("google"); const refreshSnapshot = useGoogleSyncRefreshSnapshot(); const sseDegraded = useSseDegraded(); const syncStatus = getSidebarSyncStatus({ diff --git a/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx b/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx index 796d3374b6..2c49cdf09e 100644 --- a/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx +++ b/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx @@ -15,7 +15,7 @@ import { import { resetGoogleAvailabilityForTests, setGoogleAvailabilityForTests, -} from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; +} from "@web/auth/providers/useIsProviderAvailable"; import { afterAll, afterEach, diff --git a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx index a9e6d9d04c..652999df3e 100644 --- a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx +++ b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx @@ -2,8 +2,8 @@ import classNames from "classnames"; import { useContext, useEffect, useId, useRef, useState } from "react"; import { SessionContext } from "@web/auth/compass/session/session.context"; import { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization"; -import { useIsGoogleAvailable } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; import { track } from "@web/auth/posthog/track"; +import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; import { MODAL_DISMISS_MS } from "@web/common/constants/motion.constants"; import { useDismissTransition } from "@web/common/hooks/useDismissTransition"; import { GoogleButton } from "@web/components/AuthModal/components/GoogleButton"; @@ -54,7 +54,7 @@ function WelcomeSteps({ step }: { step: WelcomeStep }) { export function WelcomeModal() { const { authenticated } = useContext(SessionContext); const { openModal, isOpen: isAuthModalOpen } = useAuthModal(); - const isGoogleAvailable = useIsGoogleAvailable(); + const isGoogleAvailable = useIsProviderAvailable("google", "signIn"); const { loading: isGoogleAuthLoading, startGoogleAuthorization } = useStartGoogleAuthorization({ intent: "signIn" }); // A ?play= deep link goes straight to the practice game. This initializer diff --git a/packages/web/src/events/mutations/useEventMutations.ts b/packages/web/src/events/mutations/useEventMutations.ts index 41179ea281..7437693a5e 100644 --- a/packages/web/src/events/mutations/useEventMutations.ts +++ b/packages/web/src/events/mutations/useEventMutations.ts @@ -29,8 +29,8 @@ import { import { shiftSeriesScheduleByOccurrenceEdit } from "@core/util/event/shift-series-schedule-by-occurrence-edit"; import { decodeOccurrenceId } from "@core/util/occurrence-id"; import { getApiErrorCode, isApiError } from "@web/api/util/api.util"; -import { isCalendarReconnectRequired } from "@web/auth/google/state/google.reconnect.calendar"; import { track } from "@web/auth/posthog/track"; +import { isCalendarReconnectRequired } from "@web/auth/providers/reconnect.calendar"; import { selectGoogleSyncConnections, useUserMetadataStore, diff --git a/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts b/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts index 6d57d50061..fe2072547e 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts @@ -2,7 +2,7 @@ import { renderHook } from "@testing-library/react"; import { act } from "react"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; import * as realUserMetadata from "@web/auth/compass/user/util/user-metadata.util"; -import { type UseConnectGoogleResult } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type UseConnectGoogleResult } from "@web/auth/providers/connect.types"; import { afterEach, beforeEach, diff --git a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts index 6e7345d577..20f6a04ca5 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; -import { type UseConnectGoogleResult } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type UseConnectGoogleResult } from "@web/auth/providers/connect.types"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { useVisibleAfterHidden } from "@web/common/hooks/useVisibleAfterHidden"; // A tab hidden for less than this is treated as a quick alt-tab, not a @@ -10,13 +10,13 @@ const MIN_HIDDEN_DURATION_MS = 30_000; /** * Triggers the same Google Calendar sync refresh as the sidebar's "Refresh - * calendar" CTA (`useConnectGoogle().refresh`), automatically: on mount and + * calendar" CTA (`useConnectProvider().refresh`), automatically: on mount and * whenever the tab regains focus after being hidden for 30+ seconds. Without * this, a user only sees a caught-up calendar if they remember to click * Refresh themselves. Passes `silent: true` — the user didn't ask for this * one, so a transient failure shouldn't surface an error toast the way a * manual click's would. The sync service still coalesces redundant enqueues - * regardless. Every `useConnectGoogle()` instance delegates to the same + * regardless. Every `useConnectProvider()` instance delegates to the same * browser-wide refresh coordinator, so a focus refresh and a manual click * share work and status instead of racing each other. * @@ -25,12 +25,14 @@ const MIN_HIDDEN_DURATION_MS = 30_000; * import). Metadata always reconciles on mount and on visible-after-hidden — * including during IMPORTING, which is exactly when the UI used to get stuck. * - * `useConnectGoogleImpl` is a test seam (default: the real hook) so tests can + * `useConnectGoogleImpl` is a test seam (default: Google connect) so tests can * pass a fake implementation instead of mock.module-ing a hook other files * also mock. */ +const useDefaultConnectProvider = () => useConnectProvider("google"); + export const useSyncFocusRefresh = ( - useConnectGoogleImpl: () => UseConnectGoogleResult = useConnectGoogle, + useConnectGoogleImpl: () => UseConnectGoogleResult = useDefaultConnectProvider, ) => { const { isAvailable, refresh, state } = useConnectGoogleImpl(); const didReconcileMetadataOnMount = useRef(false); diff --git a/packages/web/src/sse/hooks/useGcalSSE.factory.ts b/packages/web/src/sse/hooks/useSyncSSE.factory.ts similarity index 76% rename from packages/web/src/sse/hooks/useGcalSSE.factory.ts rename to packages/web/src/sse/hooks/useSyncSSE.factory.ts index cf60a401ad..73882924d1 100644 --- a/packages/web/src/sse/hooks/useGcalSSE.factory.ts +++ b/packages/web/src/sse/hooks/useSyncSSE.factory.ts @@ -6,18 +6,25 @@ import { type UserMetadataMessage, } from "@core/types/server-message.contracts"; import { type UserMetadata } from "@core/types/user.types"; +import { type ConnectionRevokedContext } from "@web/auth/providers/connection-revoked.util.factory"; import { clearGoogleSyncIndicatorOverride, clearSyncingSyncIndicatorOverride, getGoogleSyncIndicatorOverride, setSyncingSyncIndicatorOverride, -} from "@web/auth/google/state/google.sync.state"; -import { findPrimaryGoogleSyncConnectionFromMetadata } from "@web/auth/state/user-metadata.store"; +} from "@web/auth/providers/sync.indicator.state"; +import { findSyncConnectionsFromMetadata } from "@web/auth/state/user-metadata.store"; import { GOOGLE_REPAIR_FAILED_TOAST_ID } from "@web/common/constants/toast.constants"; import { type OnServerMessage } from "@web/sse/client/sse.client"; -export type GcalSSEDependencies = { - handleGoogleRevoked: () => void; +const IN_PROGRESS_SYNC_STATES = new Set([ + "connecting", + "importing", + "catchingUp", +]); + +export type SyncSSEDependencies = { + handleConnectionRevoked: (context?: ConnectionRevokedContext) => void; invalidateEventQueries: () => void; onServerMessage: OnServerMessage; refreshUserMetadata: (options?: { @@ -30,8 +37,11 @@ export type GcalSSEDependencies = { ) => void; }; -export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { - return function useGcalSSEWithDependencies() { +const revokedConnectionId = (message: SyncStatusMessage): string | undefined => + message.sync.status === "attention" ? message.sync.connectionId : undefined; + +export const createUseSyncSSE = (dependencies: SyncSSEDependencies) => { + return function useSyncSSEWithDependencies() { // B10 folds import start/progress/end into syncStatusChanged // (syncing/healthy/attention) plus a separate importCompleted summary. // Do not clear the syncing override from a healthy/importCompleted SSE @@ -52,8 +62,13 @@ export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { // attention clearGoogleSyncIndicatorOverride(); - if (message.sync.code === "GOOGLE_REVOKED") { - dependencies.handleGoogleRevoked(); + if ( + message.sync.code === "CONNECTION_REVOKED" || + message.sync.code === "GOOGLE_REVOKED" + ) { + dependencies.handleConnectionRevoked({ + connectionId: revokedConnectionId(message), + }); return; } @@ -61,7 +76,7 @@ export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { if (message.sync.code === "WATCH_REPAIR_FAILED") { dependencies.showErrorToast( - "We couldn't keep your Google Calendar connection healthy. Try Refresh, or reconnect if this lasts.", + "We couldn't keep your calendar connection healthy. Try Refresh, or reconnect if this lasts.", { toastId: GOOGLE_REPAIR_FAILED_TOAST_ID, }, @@ -90,12 +105,10 @@ export const createUseGcalSSE = (dependencies: GcalSSEDependencies) => { // Prefer Sync's in-progress states when present; otherwise the collapsed // product enum. Never clear syncing from local optimism alone (S41). - const syncState = - findPrimaryGoogleSyncConnectionFromMetadata(metadata)?.state; - const syncInProgress = - syncState === "connecting" || - syncState === "importing" || - syncState === "catchingUp"; + const connections = findSyncConnectionsFromMetadata(metadata); + const syncInProgress = connections.some((connection) => + IN_PROGRESS_SYNC_STATES.has(connection.state), + ); const enumImporting = metadata.google?.connectionState === "IMPORTING"; if (!syncInProgress && !enumImporting) { clearSyncingSyncIndicatorOverride(); diff --git a/packages/web/src/sse/hooks/useGcalSSE.ts b/packages/web/src/sse/hooks/useSyncSSE.ts similarity index 79% rename from packages/web/src/sse/hooks/useGcalSSE.ts rename to packages/web/src/sse/hooks/useSyncSSE.ts index 842781b5b2..d756aed13a 100644 --- a/packages/web/src/sse/hooks/useGcalSSE.ts +++ b/packages/web/src/sse/hooks/useSyncSSE.ts @@ -3,16 +3,16 @@ import { applyUserMetadataSideEffects, refreshUserMetadata, } from "@web/auth/compass/user/util/user-metadata.util"; -import { handleGoogleRevoked } from "@web/auth/google/util/google.auth.util"; +import { handleConnectionRevoked } from "@web/auth/providers/connection-revoked.util"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; import { invalidateEventQueriesUnlessMutating } from "@web/events/queries/event.query.invalidation"; import { eventQueryKeys } from "@web/events/queries/event.query.keys"; import { onServerMessage } from "../client/sse.client"; -import { createUseGcalSSE } from "./useGcalSSE.factory"; +import { createUseSyncSSE } from "./useSyncSSE.factory"; -export const useGcalSSE = createUseGcalSSE({ - handleGoogleRevoked, +export const useSyncSSE = createUseSyncSSE({ + handleConnectionRevoked, invalidateEventQueries: () => invalidateEventQueriesUnlessMutating(queryClient, eventQueryKeys.all), onServerMessage, diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.ts index 10b2606155..6147cacfab 100644 --- a/packages/web/src/sse/hooks/useTransientSyncPolling.ts +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; import { - selectGoogleSyncConnections, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; @@ -19,7 +19,7 @@ const TRANSIENT_POLL_MS = 20_000; * state. Stops when nothing is transient. */ export const useTransientSyncPolling = () => { - const connections = useUserMetadataStore(selectGoogleSyncConnections); + const connections = useUserMetadataStore(selectSyncConnections); const anyTransient = connections.some((connection) => TRANSIENT_CONNECTION_STATES.has(connection.state), ); diff --git a/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx b/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx index 0cd49afa39..b8feeaef30 100644 --- a/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx +++ b/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx @@ -1,20 +1,25 @@ -import { render, waitFor } from "@testing-library/react"; +import { HotkeysProvider } from "@tanstack/react-hotkeys"; +import { render, screen, waitFor } from "@testing-library/react"; import { act } from "react"; +import { ConnectionIdSchema } from "@core/types/sync/identity.contracts"; import { type UserMetadata } from "@core/types/user.types"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; import { createFakeServerMessageBus } from "@web/__tests__/utils/sse-message-bus.test.util"; import { getGoogleSyncIndicatorOverride, resetGoogleSyncUIStateForTests, setSyncingSyncIndicatorOverride, -} from "@web/auth/google/state/google.sync.state"; +} from "@web/auth/providers/sync.indicator.state"; import { userMetadataActions, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; -import { createUseGcalSSE } from "../hooks/useGcalSSE.factory"; +import { GoogleReconnectToast } from "@web/common/utils/toast/google-reconnect.toast"; +import { createUseSyncSSE } from "../hooks/useSyncSSE.factory"; import { beforeEach, describe, expect, it, mock } from "bun:test"; -const mockHandleGoogleRevoked = mock(); +const mockHandleConnectionRevoked = mock(); const mockInvalidateEventQueries = mock(); const mockShowErrorToast = mock(); const refreshUserMetadata = mock().mockResolvedValue(undefined); @@ -25,8 +30,8 @@ const { clear, } = createFakeServerMessageBus(); -const useGcalSSE = createUseGcalSSE({ - handleGoogleRevoked: mockHandleGoogleRevoked, +const useSyncSSE = createUseSyncSSE({ + handleConnectionRevoked: mockHandleConnectionRevoked, invalidateEventQueries: mockInvalidateEventQueries, onServerMessage, refreshUserMetadata, @@ -35,7 +40,7 @@ const useGcalSSE = createUseGcalSSE({ }); const HookHost = () => { - useGcalSSE(); + useSyncSSE(); return null; }; @@ -46,10 +51,10 @@ const fireUserMetadata = (metadata: UserMetadata) => { }); }; -describe("useGcalSSE", () => { +describe("useSyncSSE", () => { beforeEach(() => { clear(); - mockHandleGoogleRevoked.mockClear(); + mockHandleConnectionRevoked.mockClear(); mockInvalidateEventQueries.mockClear(); mockShowErrorToast.mockClear(); refreshUserMetadata.mockClear(); @@ -207,7 +212,7 @@ describe("useGcalSSE", () => { await waitFor(() => { expect(getGoogleSyncIndicatorOverride()).toBe(null); expect(mockShowErrorToast).toHaveBeenCalledWith( - "We couldn't keep your Google Calendar connection healthy. Try Refresh, or reconnect if this lasts.", + "We couldn't keep your calendar connection healthy. Try Refresh, or reconnect if this lasts.", expect.anything(), ); }); @@ -227,7 +232,64 @@ describe("useGcalSSE", () => { await waitFor(() => { expect(getGoogleSyncIndicatorOverride()).toBe(null); - expect(mockHandleGoogleRevoked).toHaveBeenCalledTimes(1); + expect(mockHandleConnectionRevoked).toHaveBeenCalledTimes(1); }); }); + + it("passes CONNECTION_REVOKED connectionId for a non-primary account", async () => { + const google = createMockConnection("ahab@pequod.com", { + id: "64b7f9c2e1a2b3c4d5e6f7a8", + provider: "google", + }); + const microsoft = createMockConnection("ada@outlook.com", { + id: "64b7f9c2e1a2b3c4d5e6f7a9", + provider: "microsoft", + }); + const microsoftId = ConnectionIdSchema.parse(microsoft.id); + userMetadataActions.set({ + google: { connectionState: "HEALTHY", connections: [google] }, + connections: [google, microsoft], + }); + setSyncingSyncIndicatorOverride(); + + render(); + + act(() => { + fireMessage({ + type: "syncStatusChanged", + sync: { + status: "attention", + code: "CONNECTION_REVOKED", + connectionId: microsoftId, + retryable: false, + }, + }); + }); + + await waitFor(() => { + expect(getGoogleSyncIndicatorOverride()).toBe(null); + expect(mockHandleConnectionRevoked).toHaveBeenCalledWith({ + connectionId: microsoftId, + }); + }); + + const { wrapper } = createStoreWrapper(); + render( + + + , + { wrapper }, + ); + + expect( + screen.getByText("Outlook disconnected (ada@outlook.com)"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reconnect Outlook" }), + ).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/sse/provider/SSEProvider.test.tsx b/packages/web/src/sse/provider/SSEProvider.test.tsx index 01e2d57994..ee055a1a78 100644 --- a/packages/web/src/sse/provider/SSEProvider.test.tsx +++ b/packages/web/src/sse/provider/SSEProvider.test.tsx @@ -6,7 +6,7 @@ import { createCompassQueryClient } from "@web/api/query-client"; import * as realSessionHook from "@web/auth/compass/session/useSession"; import * as realUserHook from "@web/auth/compass/user/hooks/useUser"; import * as realUserMetadata from "@web/auth/compass/user/util/user-metadata.util"; -import * as realConnectGoogle from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import * as realConnectProvider from "@web/auth/providers/useConnectProvider"; import * as realSseClient from "@web/sse/client/sse.client"; import { beforeEach, describe, expect, it, mock } from "bun:test"; @@ -15,10 +15,10 @@ const mockUseUser = mock(); const openStream = mock(); const closeStream = mock(); const getStream = mock(() => null); -// SSEProvider mounts useSyncFocusRefresh, which calls useConnectGoogle(). +// SSEProvider mounts useSyncFocusRefresh, which calls useConnectProvider(). // This test isn't about Google/sync behavior, so give it a stable, complete // fake of its own rather than inheriting whatever another file installed. -const mockUseConnectGoogle = mock(() => ({ +const mockUseConnectProvider = mock(() => ({ commandAction: null, isAvailable: false, isConnecting: false, @@ -40,9 +40,11 @@ mockModuleForFile( { refreshUserMetadata: mock().mockResolvedValue(undefined) }, ); mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: mockUseConnectGoogle }, + "@web/auth/providers/useConnectProvider", + realConnectProvider, + { + useConnectProvider: mockUseConnectProvider, + }, ); mockModuleForFile("@web/sse/client/sse.client", realSseClient, { openStream, diff --git a/packages/web/src/sse/provider/SSEProvider.tsx b/packages/web/src/sse/provider/SSEProvider.tsx index f46e7ebcb2..85da12b6b3 100644 --- a/packages/web/src/sse/provider/SSEProvider.tsx +++ b/packages/web/src/sse/provider/SSEProvider.tsx @@ -1,8 +1,8 @@ import { type ReactNode } from "react"; import { useEventSSE } from "../hooks/useEventSSE"; -import { useGcalSSE } from "../hooks/useGcalSSE"; import { useSSEConnection } from "../hooks/useSSEConnection"; import { useSyncFocusRefresh } from "../hooks/useSyncFocusRefresh"; +import { useSyncSSE } from "../hooks/useSyncSSE"; import { useTransientSyncPolling } from "../hooks/useTransientSyncPolling"; export * from "../client/sse.client"; @@ -10,7 +10,7 @@ export * from "../client/sse.client"; const SSEProvider = ({ children }: { children: ReactNode }) => { useSSEConnection(); useEventSSE(); - useGcalSSE(); + useSyncSSE(); useSyncFocusRefresh(); useTransientSyncPolling(); diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx index 77fc330ea8..bec5aaa11f 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx @@ -5,11 +5,11 @@ import { } from "@core/types/domain-primitives"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { shouldShowContextualLoadError } from "@web/api/util/api.util"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { isFirstImportFailed, isFirstImportInProgress, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +} from "@web/auth/providers/connect.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { getWritableCalendars } from "@web/calendars/calendar.util"; import { @@ -103,7 +103,11 @@ export function DayCalendarGrid() { showEventsLoadError, isFetching, ); - const { connection, refresh, state: googleState } = useConnectGoogle(); + const { + connection, + refresh, + state: googleState, + } = useConnectProvider("google"); // See Grid.tsx's Week-view equivalent: googleState alone can't tell a // first-ever import apart from routine catch-up on an established account. const isImportingEmpty = diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx index 038344e7fa..a14183ab88 100644 --- a/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx @@ -20,20 +20,20 @@ import { // shows it, the next stays empty, and dismissal survives a new session. // // The web suite runs in ONE process and earlier files (Sidebar/CalendarList) -// register process-wide mock.module stubs for useConnectGoogle — some without +// register process-wide mock.module stubs for useConnectProvider — some without // a `connect` at all — so this file cannot reach the real hook -> AuthApi // path reliably. It follows the repo's delegating-mock pattern instead and // asserts the nudge's contract AT THE HOOK BOUNDARY: it asks for the // contacts feature and starts the flow on click. The features -> begin-body -// wire threading is covered by useConnectGoogle.scope.test.tsx, which runs +// wire threading is covered by useConnectProvider.scope.test.tsx, which runs // before any module mock exists. -const actualUseConnectGoogle = ( - await import("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle") -).useConnectGoogle; -let isConnectGoogleMocked = true; +const actualUseConnectProvider = ( + await import("@web/auth/providers/useConnectProvider") +).useConnectProvider; +let isConnectProviderMocked = true; const connectMock = mock(); -const mockUseConnectGoogle = mock( - (_options?: Parameters[0]) => ({ +const mockUseConnectProvider = mock( + (..._args: Parameters) => ({ commandAction: null, connect: connectMock, connection: null, @@ -44,20 +44,20 @@ const mockUseConnectGoogle = mock( state: "HEALTHY" as const, }), ); -mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ - useConnectGoogle: ( - ...args: Parameters - ): ReturnType => - isConnectGoogleMocked - ? (mockUseConnectGoogle(...args) as unknown as ReturnType< - typeof actualUseConnectGoogle +mock.module("@web/auth/providers/useConnectProvider", () => ({ + useConnectProvider: ( + ...args: Parameters + ): ReturnType => + isConnectProviderMocked + ? (mockUseConnectProvider(...args) as unknown as ReturnType< + typeof actualUseConnectProvider >) - : actualUseConnectGoogle(...args), + : actualUseConnectProvider(...args), })); afterAll(() => { // Hand later files the real hook — mock.module itself is process-wide. - isConnectGoogleMocked = false; + isConnectProviderMocked = false; }); const seedHealthyConnection = () => { @@ -76,7 +76,7 @@ describe("EnableContactSuggestionsNudge", () => { localStorage.clear(); resetContactsNudgeSessionForTests(); seedHealthyConnection(); - mockUseConnectGoogle.mockClear(); + mockUseConnectProvider.mockClear(); connectMock.mockClear(); }); @@ -130,8 +130,9 @@ describe("EnableContactSuggestionsNudge", () => { renderWithStore(); // The nudge's whole purpose: incremental re-consent WITH contacts. - expect(mockUseConnectGoogle).toHaveBeenCalled(); - expect(mockUseConnectGoogle.mock.calls[0]?.[0]).toEqual({ + expect(mockUseConnectProvider).toHaveBeenCalled(); + expect(mockUseConnectProvider.mock.calls[0]?.[0]).toBe("google"); + expect(mockUseConnectProvider.mock.calls[0]?.[1]).toEqual({ features: ["contacts"], }); diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx index ee6e37f8a6..04e314d3ff 100644 --- a/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx @@ -1,6 +1,6 @@ import { XIcon } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { dismissContactsNudge, markContactsNudgeShown, @@ -20,7 +20,7 @@ export const EnableContactSuggestionsNudge = () => { // Decided once per mount (one menu-open episode): the nudge either owns // this opening or stays away entirely — it never pops in mid-typing. const [isVisible, setIsVisible] = useState(() => shouldShowContactsNudge()); - const { connect, isConnecting } = useConnectGoogle({ + const { connect, isConnecting } = useConnectProvider("google", { features: ["contacts"], }); diff --git a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.test.ts b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.test.ts index 63ce18718b..4505b69c70 100644 --- a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.test.ts +++ b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.test.ts @@ -11,7 +11,7 @@ import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; // Patch the one method on the real AuthApi object rather than replacing the // module: mock.module is process-wide and permanent, and an AuthApi carrying // only loginOrSignup breaks every later file that reaches for another method -// (useConnectGoogle.scope.test.tsx spies on beginGoogleConnection). +// (useConnectProvider.scope.test.tsx spies on beginGoogleConnection). const mockLoginOrSignup = mock(); const realLoginOrSignup = AuthApi.loginOrSignup; AuthApi.loginOrSignup = mockLoginOrSignup as typeof AuthApi.loginOrSignup; diff --git a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx index 86cb4f477f..d839a9403e 100644 --- a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx +++ b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx @@ -36,7 +36,10 @@ export async function completeGoogleAuthCallback({ // the connect. Only the sync service's redirect-after-connect used to fire // this, which is why activation looked like it barely happened: the path // most new users actually take never reported it. - track("calendar_connected", { source: "signup_google" }); + track("calendar_connected", { + source: "signup_google", + provider: "google", + }); shortcutShowcaseActions.offerAfterSignupIfPending(); } else { track("login_completed", { method: "google" }); diff --git a/packages/web/src/views/Week/components/Grid/Grid.tsx b/packages/web/src/views/Week/components/Grid/Grid.tsx index e8c5c57cfb..b43a409017 100644 --- a/packages/web/src/views/Week/components/Grid/Grid.tsx +++ b/packages/web/src/views/Week/components/Grid/Grid.tsx @@ -2,11 +2,11 @@ import { type FC, useMemo } from "react"; import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; import { type Dayjs } from "@core/util/date/dayjs"; import { shouldShowContextualLoadError } from "@web/api/util/api.util"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { isFirstImportFailed, isFirstImportInProgress, -} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +} from "@web/auth/providers/connect.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { useWeekEventViewModel } from "@web/events/queries/useWeekEventsQuery"; import { selectGridDraft, useDraftStore } from "@web/events/stores/draft.store"; import { EventGrid, isEventGridLoading } from "@web/grid/components/EventGrid"; @@ -50,7 +50,11 @@ export const Grid: FC = ({ startOfView: weekProps.query.startOfView, endOfView: weekProps.query.endOfView, }); - const { connection, refresh, state: googleState } = useConnectGoogle(); + const { + connection, + refresh, + state: googleState, + } = useConnectProvider("google"); // Session expiry already surfaces SessionExpiredToast — don't also show // "Couldn't load events" / Retry for the same failure. const showEventsLoadError = shouldShowContextualLoadError(