diff --git a/.agents/handoffs/3232.md b/.agents/handoffs/3232.md new file mode 100644 index 0000000000..7e5e2d3db1 --- /dev/null +++ b/.agents/handoffs/3232.md @@ -0,0 +1,29 @@ +--- +schema_version: 1 +task_id: "3232" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/web/src/auth/providers/provider-copy.util.ts + - path: packages/web/src/auth/providers/ProviderConnectChooser.tsx + - path: packages/web/src/auth/providers/ConnectProviderAction.tsx + - path: packages/web/src/components/Settings/SettingsModal.tsx + - path: packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx + - path: packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx +evidence: + - command: bun run verify --strict + result: "VERDICT: PASS (test:web, type-check, lint, knip, test:a11y, test:e2e)" +assumptions: + - "Microsoft reconnect copy uses Outlook as the product name so banner text matches the WP-08b example." + - "Default calendar optgroups now include (Provider) even for Google." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +P0 WP-08b: migrate Settings, sidebar, banners and toasts to the provider layer. diff --git a/.agents/handoffs/3233.md b/.agents/handoffs/3233.md new file mode 100644 index 0000000000..88763ab210 --- /dev/null +++ b/.agents/handoffs/3233.md @@ -0,0 +1,43 @@ +--- +schema_version: 1 +task_id: "3233" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/web/src/sse/hooks/useSyncSSE.ts + - path: packages/web/src/sse/hooks/useSyncSSE.factory.ts + - path: packages/web/src/auth/providers/connection-revoked.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)" +assumptions: + - "Booking empty-chooser copy stays Google-identical so BookingSettings tests pass when connectable is empty." + - "WP-08c PR includes WP-08b commits until #3383 merges." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: human +waiting_on: "human review on PR #3384" +escalation: "Issue #3233 approval boundary is human; label agent-loop-needs-human." +--- + +P0 WP-08c: migrate SSE, booking, calendars, modals and analytics off the Google connect layer. + +Remaining `auth/google/` files (sign-in only, 762 lines): + +- `packages/web/src/auth/google/google-auth-config.ts` +- `packages/web/src/auth/google/google-auth-config.test.ts` +- `packages/web/src/auth/google/authorization/complete-google-authorization.ts` +- `packages/web/src/auth/google/authorization/google-authorization.constants.ts` +- `packages/web/src/auth/google/authorization/google-authorization.storage.ts` +- `packages/web/src/auth/google/authorization/google-authorization.storage.test.ts` +- `packages/web/src/auth/google/authorization/google-authorization.util.ts` +- `packages/web/src/auth/google/authorization/google-authorization.util.test.ts` +- `packages/web/src/auth/google/authorization/google-authorization.test.ts` +- `packages/web/src/auth/google/authorization/useStartGoogleAuthorization.ts` +- `packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.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..7d89b81c31 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,9 +5,10 @@ import { getGoogleReconnectRequiredAccountEmails, hasGoogleReconnectRequired, syncReconnectRequiredFromConnections, -} from "@web/auth/google/state/google.reconnect.state"; +} from "@web/auth/providers/reconnect.state"; import { findPrimaryGoogleSyncConnectionFromMetadata, + findSyncConnectionsFromMetadata, userMetadataActions, } from "@web/auth/state/user-metadata.store"; import { @@ -30,11 +31,14 @@ let metadataFetchEpoch = 0; * metadata payload, whether it arrived from REST refresh or SSE. */ export const applyUserMetadataSideEffects = (metadata: UserMetadata): void => { - const connections = metadata.google?.connections ?? []; + const connections = findSyncConnectionsFromMetadata(metadata); syncReconnectRequiredFromConnections(connections); const needsReconnect = metadata.google?.connectionState === "RECONNECT_REQUIRED" || + connections.some( + (connection) => connection.connectionState === "RECONNECT_REQUIRED", + ) || hasGoogleReconnectRequired(); if (needsReconnect) { 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/ConnectProviderAction.tsx b/packages/web/src/auth/providers/ConnectProviderAction.tsx new file mode 100644 index 0000000000..335ee13551 --- /dev/null +++ b/packages/web/src/auth/providers/ConnectProviderAction.tsx @@ -0,0 +1,61 @@ +import { type FC } from "react"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { OverlayPanelActionButton } from "@web/components/OverlayPanel/OverlayPanel"; +import { settingsShortcutAttrs } from "@web/settings/useSettingsShortcuts"; + +const SIDEBAR_BUTTON_CLASSNAME = + "c-button-compact c-button-primary w-full rounded-xs px-2 py-1.5 text-left text-xs"; + +interface ConnectProviderActionProps { + connectingLabel: string; + idleLabel: string; + kind: ProviderKind; + newAccount?: boolean; + shortcut?: string; + shortcutAttr?: boolean; + showShortcut?: boolean; + variant: "settings" | "sidebar"; +} + +export const ConnectProviderAction: FC = ({ + connectingLabel, + idleLabel, + kind, + newAccount, + shortcut, + shortcutAttr = false, + showShortcut, + variant, +}) => { + const { connect, isConnecting } = useConnectProvider(kind, { newAccount }); + const label = isConnecting ? connectingLabel : idleLabel; + + if (variant === "settings") { + return ( + + {label} + + ); + } + + return ( + + ); +}; diff --git a/packages/web/src/auth/providers/ProviderConnectChooser.tsx b/packages/web/src/auth/providers/ProviderConnectChooser.tsx new file mode 100644 index 0000000000..215cf277d4 --- /dev/null +++ b/packages/web/src/auth/providers/ProviderConnectChooser.tsx @@ -0,0 +1,67 @@ +import { type FC } from "react"; +import { providerDisplayName } from "@core/types/sync/identity.contracts"; +import { ConnectProviderAction } from "@web/auth/providers/ConnectProviderAction"; +import { + BOOKING_CONNECT_BUTTON_LABEL, + CONNECT_CALENDAR_LABEL, + openingProviderCopy, +} from "@web/auth/providers/provider-copy.util"; +import { useConnectableProviders } from "@web/auth/providers/useIsProviderAvailable"; +import { OverlayPanelActions } from "@web/components/OverlayPanel/OverlayPanel"; + +interface ProviderConnectChooserProps { + showShortcuts?: boolean; + variant: "settings" | "sidebar" | "prompt"; +} + +export const ProviderConnectChooser: FC = ({ + showShortcuts = false, + variant, +}) => { + const connectable = useConnectableProviders(); + if (connectable.length === 0) return null; + + if (variant === "settings" || variant === "prompt") { + const single = connectable.length === 1; + return ( + + {connectable.map((kind, index) => ( + + ))} + + ); + } + + return ( +
+ {connectable.map((kind) => ( + + ))} +
+ ); +}; 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 1c08e35aed..e04a6ed92b 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts +++ b/packages/web/src/auth/providers/connect.util.ts @@ -3,13 +3,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-availability.factory.ts b/packages/web/src/auth/providers/provider-availability.factory.ts index 073704b791..c51c59b415 100644 --- a/packages/web/src/auth/providers/provider-availability.factory.ts +++ b/packages/web/src/auth/providers/provider-availability.factory.ts @@ -1,6 +1,13 @@ import { useEffect, useSyncExternalStore } from "react"; import { type ProviderKind } from "@core/types/sync/identity.contracts"; +const PROVIDER_KINDS: readonly ProviderKind[] = [ + "google", + "microsoft", + "apple", +]; +const NO_CONNECTABLE: ProviderKind[] = []; + export type BackendProviderAvailability = | "available" | "unavailable" @@ -58,6 +65,7 @@ export function createProviderAvailability({ apple: unavailableFlags, }; let loadPromise: Promise | undefined; + let connectableCache: ProviderKind[] = NO_CONNECTABLE; const emit = () => { for (const listener of listeners) { @@ -83,6 +91,18 @@ export function createProviderAvailability({ return ready; }; + const connectableSnapshot = (): ProviderKind[] => { + const next = PROVIDER_KINDS.filter((kind) => snapshotFor(kind, "connect")); + if ( + next.length === connectableCache.length && + next.every((kind, index) => kind === connectableCache[index]) + ) { + return connectableCache; + } + connectableCache = next.length === 0 ? NO_CONNECTABLE : next; + return connectableCache; + }; + const load = async (): Promise => { if (!loadPromise) { loadPromise = getConfig() @@ -120,11 +140,19 @@ export function createProviderAvailability({ return available; }; - const useIsGoogleAvailable = (): boolean => - useIsProviderAvailable("google", "signIn"); + const useConnectableProviders = (): ProviderKind[] => { + const connectable = useSyncExternalStore( + subscribe, + connectableSnapshot, + connectableSnapshot, + ); - const useIsConnectGoogleAvailable = (): boolean => - useIsProviderAvailable("google", "connect"); + useEffect(() => { + void load(); + }, []); + + return connectable; + }; const resetGoogleAvailabilityForTests = () => { flags = { @@ -133,6 +161,7 @@ export function createProviderAvailability({ apple: unavailableFlags, }; loadPromise = undefined; + connectableCache = NO_CONNECTABLE; emit(); }; @@ -168,8 +197,7 @@ export function createProviderAvailability({ resetProviderAvailabilityForTests, setGoogleAvailabilityForTests, setProviderAvailabilityForTests, - useIsGoogleAvailable, - useIsConnectGoogleAvailable, useIsProviderAvailable, + useConnectableProviders, }; } 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 84% rename from packages/web/src/auth/google/state/google.reconnect.calendar.ts rename to packages/web/src/auth/providers/reconnect.calendar.ts index e93975e024..cbedcc54e9 100644 --- a/packages/web/src/auth/google/state/google.reconnect.calendar.ts +++ b/packages/web/src/auth/providers/reconnect.calendar.ts @@ -2,9 +2,9 @@ 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, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; @@ -18,7 +18,7 @@ export function isCalendarReconnectRequired( if (!calendar?.accountEmail) return false; if (isAccountReconnectRequired(calendar.accountEmail)) return true; - const connection = selectGoogleSyncConnections( + const connection = selectSyncConnections( useUserMetadataStore.getState(), ).find((entry) => entry.accountEmail === calendar.accountEmail); return isConnectionReconnectRequired(connection?.id); 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.test.tsx b/packages/web/src/auth/providers/useConnectProvider.test.tsx index 0ce60e9324..15b64ac8ab 100644 --- a/packages/web/src/auth/providers/useConnectProvider.test.tsx +++ b/packages/web/src/auth/providers/useConnectProvider.test.tsx @@ -24,6 +24,7 @@ const connection = ( describe("useConnectProvider", () => { beforeEach(() => { userMetadataActions.set({ + connections: [connection({})], google: { connectionState: "RECONNECT_REQUIRED", connections: [connection({})], diff --git a/packages/web/src/auth/providers/useConnectProvider.ts b/packages/web/src/auth/providers/useConnectProvider.ts index c9091d5f6b..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,17 +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, - }, - ), + ...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..c20a7e42b0 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx +++ b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx @@ -32,4 +32,73 @@ describe("useIsProviderAvailable", () => { }); expect(getConfig).toHaveBeenCalledTimes(1); }); + + it("lists every provider whose connect flag is true", async () => { + getConfig.mockClear(); + getConfig.mockResolvedValue({ + google: { isConfigured: true }, + providers: { + google: { signIn: true, connect: true }, + microsoft: { signIn: false, connect: true }, + apple: { signIn: false, connect: false }, + }, + }); + const { resetProviderAvailabilityForTests, useConnectableProviders } = + createProviderAvailability({ + getConfig, + isGoogleAuthConfigured: true, + }); + resetProviderAvailabilityForTests(); + + const { result } = renderHook(() => useConnectableProviders()); + + expect(result.current).toEqual([]); + await waitFor(() => { + expect(result.current).toEqual(["google", "microsoft"]); + }); + }); + + 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..fb17794cb9 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, + useConnectableProviders, 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 df2a99e822..f29d441b39 100644 --- a/packages/web/src/auth/state/user-metadata.store.ts +++ b/packages/web/src/auth/state/user-metadata.store.ts @@ -3,6 +3,7 @@ import { devtools } from "zustand/middleware"; import { type GoogleConnectionState, type GoogleSyncConnectionSummary, + type SyncConnectionSummary, type UserMetadata, } from "@core/types/user.types"; import { IS_DEV } from "@web/common/constants/env.constants"; @@ -55,16 +56,26 @@ export const userMetadataActions = { removeConnection: (connectionId: string) => useUserMetadataStore.setState( (state) => { - if (!state.current?.google) return state; + if (!state.current) return state; + const filter = (connection: SyncConnectionSummary) => + connection.id !== connectionId; + const nextConnections = ( + state.current.connections ?? + state.current.google?.connections ?? + [] + ).filter(filter); return { current: { ...state.current, - google: { - ...state.current.google, - connections: (state.current.google.connections ?? []).filter( - (connection) => connection.id !== connectionId, - ), - }, + connections: nextConnections, + google: state.current.google + ? { + ...state.current.google, + connections: (state.current.google.connections ?? []).filter( + filter, + ), + } + : state.current.google, }, }; }, @@ -105,8 +116,19 @@ const NO_CONNECTIONS: GoogleSyncConnectionSummary[] = []; /** * Every connected provider account, in connection order. Empty when metadata - * hasn't loaded, no account is connected, or the payload predates the plural - * field. + * hasn't loaded or no account is connected. Prefers the WP-07 + * `connections[]` field and falls back to the Google overlap copy. + */ +export const selectSyncConnections = ( + state: UserMetadataState, +): SyncConnectionSummary[] => + state.current?.connections ?? + state.current?.google?.connections ?? + NO_CONNECTIONS; + +/** + * Google-only slice. Prefer {@link selectSyncConnections} for surfaces that + * render any provider. */ export const selectGoogleSyncConnections = ( state: UserMetadataState, @@ -136,6 +158,22 @@ export const selectCanSuggestContacts = (state: UserMetadataState): boolean => * which was exactly this array's own connectionState re-derived - the browser * has everything it needs to compute it locally instead. */ +function findPrimarySyncConnection( + metadata: UserMetadata | null | undefined, +): SyncConnectionSummary | null { + const connections = + metadata?.connections ?? metadata?.google?.connections ?? NO_CONNECTIONS; + if (connections.length === 0) return null; + return ( + connections.find( + (connection) => + connection.connectionState === metadata?.google?.connectionState, + ) ?? + connections[0] ?? + null + ); +} + function findPrimaryGoogleSyncConnection( google: UserMetadata["google"], ): GoogleSyncConnectionSummary | null { @@ -148,16 +186,47 @@ function findPrimaryGoogleSyncConnection( ); } +/** Store-selector form of {@link findPrimarySyncConnection}. */ +export const selectPrimarySyncConnection = ( + state: UserMetadataState, +): SyncConnectionSummary | null => findPrimarySyncConnection(state.current); + /** Store-selector form of {@link findPrimaryGoogleSyncConnection}. */ export const selectPrimaryGoogleSyncConnection = ( state: UserMetadataState, ): 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 findPrimarySyncConnection(metadata); +}; + /** * 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..0d4a350c0d 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 { ProviderConnectChooser } from "@web/auth/providers/ProviderConnectChooser"; +import { bookingConnectPromptCopy } from "@web/auth/providers/provider-copy.util"; +import { useConnectableProviders } from "@web/auth/providers/useIsProviderAvailable"; export function BookingConnectGooglePrompt() { - const { connect, isAvailable, isConnecting } = useConnectGoogle({ - newAccount: true, - }); + const connectable = useConnectableProviders(); 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..232eea6eb0 100644 --- a/packages/web/src/booking/BookingSettingsSection.test.tsx +++ b/packages/web/src/booking/BookingSettingsSection.test.tsx @@ -162,6 +162,37 @@ 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(); + expect( + screen.queryByText( + /Connect a calendar 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 fa6b3589fc..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 GoogleSyncConnectionSummary } from "@core/types/user.types"; -import { isCalendarReconnectRequired } from "@web/auth/google/state/google.reconnect.calendar"; +import { type SyncConnectionSummary } from "@core/types/user.types"; +import { isCalendarReconnectRequired } from "@web/auth/providers/reconnect.calendar"; export function getLocalCalendar(calendars: Calendar[]): Calendar | undefined { return calendars.find((calendar) => calendar.provider === "local"); @@ -112,7 +112,7 @@ export function spansMultipleAccounts(calendars: Calendar[]): boolean { export interface AccountGroup { accountEmail: string; - connection: GoogleSyncConnectionSummary | undefined; + connection: SyncConnectionSummary | undefined; calendars: Calendar[]; } @@ -130,7 +130,7 @@ export interface AccountGroup { */ export function groupCalendarsByAccount( calendars: Calendar[], - connections: GoogleSyncConnectionSummary[], + connections: SyncConnectionSummary[], compassEmail?: string | null, ): { groups: AccountGroup[]; ungrouped: Calendar[] } { const groups: AccountGroup[] = []; @@ -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..42d65bc993 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,11 @@ import { createElement } from "react"; import { type Id } from "react-toastify"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { connectionProvider } from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; import { rememberPendingDelayed, shouldDeferAttentionToasts, @@ -22,9 +27,12 @@ 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 primary = useUserMetadataStore(selectPrimarySyncConnection); + const { refresh } = useConnectProvider(connectionProvider(primary), { + connection: primary, + }); 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 0012cccec1..7f938555c9 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 @@ -3,7 +3,7 @@ import { fireEvent, 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 { isBillingGateOwningScreen, resetBillingGateAttentionForTests, @@ -29,18 +29,12 @@ import { eventJumpActions } from "@web/shortcuts/shift-hint/event-jump.store"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const mockConnect = mock(); -const mockUseConnectGoogle = mock(() => ({ connect: mockConnect })); - -// useConnectGoogle owns the flush-pending-events -> delegation-fork -> -// legacy-popup-or-sync-redirect logic (the exact thing that drifted out of -// sync here before: this toast used to reimplement a legacy-only copy of it -// directly). Mocking the hook keeps this file testing only what it owns — -// that a click dismisses the toast and calls connect() — not re-deriving -// useConnectGoogle's own behavior. +const mockUseConnectProvider = mock(() => ({ connect: mockConnect })); + mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: mockUseConnectGoogle }, + "@web/auth/providers/useConnectProvider", + realConnectProvider, + { useConnectProvider: mockUseConnectProvider }, ); describe("GoogleReconnectToast", () => { @@ -51,19 +45,23 @@ describe("GoogleReconnectToast", () => { document.body.removeAttribute("data-app-locked"); eventJumpActions.reset(); mockConnect.mockClear(); - mockUseConnectGoogle.mockClear(); + mockUseConnectProvider.mockClear(); mocks.error.mockClear(); mocks.dismiss.mockClear(); mocks.isActive.mockReturnValue(false); registerToastPort(port); }); - const renderToast = (accountEmail?: string) => + const renderToast = ( + accountEmail?: string, + provider?: "google" | "microsoft" | "apple", + ) => render( , @@ -95,6 +93,22 @@ describe("GoogleReconnectToast", () => { ).toBeInTheDocument(); }); + it("names a Microsoft connection with Outlook copy", () => { + renderToast("ada@outlook.com", "microsoft"); + + expect( + screen.getByText("Outlook disconnected (ada@outlook.com)"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Access for ada@outlook.com expired or was revoked. Your events are still safe in Outlook. Reconnect and Compass will re-import them.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reconnect Outlook" }), + ).toBeInTheDocument(); + }); + it("dismisses itself and starts connect() on click", () => { renderToast("lance@example.com"); 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 bfff4ba090..07a44c56fd 100644 --- a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx +++ b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx @@ -1,10 +1,17 @@ import { createElement } from "react"; import { type Id } from "react-toastify"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; -import { type GoogleReconnectTarget } from "@web/auth/google/state/google.reconnect.state"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { - selectGoogleSyncConnections, + 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, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { @@ -42,13 +49,16 @@ interface GoogleReconnectToastProps { toastId: Id; accountEmail?: string | null; connectionId?: string | null; + provider?: ProviderKind; } const toastScopedConnection = ( connectionId: string | null | undefined, accountEmail: string | null | undefined, -): GoogleSyncConnectionSummary => ({ + provider: ProviderKind, +): SyncConnectionSummary => ({ id: connectionId?.trim() || "reconnect-target", + provider, state: "actionRequired", stateReason: "authorizationRevoked", lastSyncedAt: null, @@ -65,25 +75,34 @@ const toastScopedConnection = ( // stay accurate for either cause. Hooks are fine here: ToastContainer renders // inside GoogleOAuthProvider (CompassProvider). // -// Delegates to useConnectGoogle's connect() — the same trigger the command +// Delegates to useConnectProvider's connect() — the same trigger the command // palette uses — rather than driving the OAuth redirect flow directly, so // this toast can't drift out of sync with the one place that flow lives. export const GoogleReconnectToast = ({ toastId, accountEmail, connectionId, + provider: providerProp, }: GoogleReconnectToastProps) => { - const connections = useUserMetadataStore(selectGoogleSyncConnections); + 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), + ); // Props keep the target even while metadata is refetching, so Reconnect // still binds OAuth to the broken connectionId instead of adding a new one. const connection = connectionFromStore ?? - (connectionId ? toastScopedConnection(connectionId, accountEmail) : null); - const { connect } = useConnectGoogle(connection ? { connection } : undefined); + (connectionId + ? toastScopedConnection(connectionId, accountEmail, kind) + : null); + const { connect } = useConnectProvider( + kind, + connection ? { connection } : undefined, + ); const handleReconnect = () => { getToast().dismiss(toastId); @@ -95,20 +114,16 @@ export const GoogleReconnectToast = ({ return (

- {namedAccount - ? `Google Calendar disconnected (${namedAccount})` - : "Google Calendar disconnected"} + {reconnectToastTitle(kind, namedAccount)}

- {namedAccount - ? `Access for ${namedAccount} expired or was revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.` - : "This happens when access expires or is revoked. Your events are still safe in Google. Reconnect and Compass will re-import them."} + {reconnectToastBody(kind, namedAccount)}

- Reconnect Google Calendar + {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.test.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx index f8ec5a055a..0eb8f05ed9 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx @@ -21,10 +21,15 @@ afterEach(() => { const renderBanner = ( kind: "reconnect" | "importFailed" | "delayed", onAction = mock(), + provider?: "google" | "microsoft" | "apple", ) => render( - + , ); @@ -48,6 +53,14 @@ describe("CalendarConnectionBanner", () => { expect(onAction).toHaveBeenCalledTimes(1); }); + it("names a Microsoft reconnect with Outlook copy", () => { + renderBanner("reconnect", mock(), "microsoft"); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Outlook needs reconnecting.", + ); + }); + it("shows a G keycap and reconnects when G is pressed", () => { const onAction = mock(); renderBanner("reconnect", onAction); diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx index d7453b4a5d..12c6feb2e6 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx @@ -1,5 +1,7 @@ import { type FC } from "react"; -import { type CalendarConnectionBannerKind } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { type CalendarConnectionBannerKind } from "@web/auth/providers/connect.util"; +import { RECONNECT_BANNER_MESSAGE } from "@web/auth/providers/provider-copy.util"; import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys"; import { POINTER_ACTION_ATTRIBUTE, @@ -16,7 +18,7 @@ const COPY: Record< { message: string; action: string } > = { reconnect: { - message: "Google Calendar needs reconnecting.", + message: RECONNECT_BANNER_MESSAGE.google, action: "Reconnect", }, importFailed: { @@ -32,13 +34,18 @@ const COPY: Record< interface CalendarConnectionBannerProps { kind: CalendarConnectionBannerKind; onAction: () => void; + provider?: ProviderKind; } export const CalendarConnectionBanner: FC = ({ kind, onAction, + provider = "google", }) => { - const { message, action } = COPY[kind]; + const { message, action } = + kind === "reconnect" + ? { message: RECONNECT_BANNER_MESSAGE[provider], action: "Reconnect" } + : COPY[kind]; const isError = kind === "reconnect" || kind === "importFailed"; const pointerAction = kind === "reconnect" ? POINTER_ACTIONS.reconnectGoogle : undefined; diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx index f0ac576120..6b0e5c2903 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx @@ -1,17 +1,27 @@ import { type FC } from "react"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; -import { getCalendarConnectionBannerKind } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { getCalendarConnectionBannerKind } from "@web/auth/providers/connect.util"; +import { connectionProvider } from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; import { CalendarConnectionBanner } from "@web/components/CalendarConnectionBanner/CalendarConnectionBanner"; export const CalendarConnectionBannerGate: FC = () => { - const { connect, connection, refresh, state } = useConnectGoogle(); - const kind = getCalendarConnectionBannerKind(state, connection); - if (!kind) return null; + const primary = useUserMetadataStore(selectPrimarySyncConnection); + const kind = connectionProvider(primary); + const { connect, connection, refresh, state } = useConnectProvider(kind, { + connection: primary, + }); + const bannerKind = getCalendarConnectionBannerKind(state, connection); + if (!bannerKind) return null; return ( refresh()} + kind={bannerKind} + onAction={bannerKind === "reconnect" ? connect : () => refresh()} + provider={connectionProvider(connection)} /> ); }; 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/PointerHint/PointerHint.test.tsx b/packages/web/src/components/PointerHint/PointerHint.test.tsx index 532ccb23e0..2a2a72a6d9 100644 --- a/packages/web/src/components/PointerHint/PointerHint.test.tsx +++ b/packages/web/src/components/PointerHint/PointerHint.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { PointerHint } from "@web/components/PointerHint/PointerHint"; import { initialShortcutShowcaseState, @@ -111,6 +112,37 @@ describe("PointerHint", () => { ); }); + it("teaches the reconnect shortcut with Outlook copy for a Microsoft connection", () => { + userMetadataActions.set({ + connections: [ + { + id: "conn-ms", + provider: "microsoft", + state: "actionRequired", + stateReason: "authorizationRevoked", + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "ada@outlook.com", + connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, + }, + ], + google: { connectionState: "RECONNECT_REQUIRED", connections: [] }, + }); + render(); + + act(() => { + pointerConfusionActions.triggerHintForTests({ + actionId: POINTER_ACTIONS.reconnectGoogle, + shortcutKey: "G", + }); + }); + + expect(screen.getByRole("status")).toHaveTextContent( + "Press G to reconnect Outlook.", + ); + }); + it("teaches Esc for the up-next dismiss target", () => { render(); diff --git a/packages/web/src/components/PointerHint/PointerHint.tsx b/packages/web/src/components/PointerHint/PointerHint.tsx index 3c58983e4b..8ff975a47a 100644 --- a/packages/web/src/components/PointerHint/PointerHint.tsx +++ b/packages/web/src/components/PointerHint/PointerHint.tsx @@ -1,5 +1,13 @@ import { X } from "@phosphor-icons/react"; import { type FC, type ReactNode, useEffect, useState } from "react"; +import { + calendarProductName, + connectionProvider, +} from "@web/auth/providers/provider-copy.util"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; import IconButton from "@web/components/IconButton/IconButton"; import { @@ -41,11 +49,13 @@ const Key = ({ children }: { children: string }) => ( const pointerHintMessage = ({ attempt, eventJumpKey, + provider, showcaseActive, welcomeOpen, }: { attempt: BlockedPointerAttempt | null; eventJumpKey: string | null; + provider: ReturnType; showcaseActive: boolean; welcomeOpen: boolean; }): ReactNode => { @@ -125,8 +135,8 @@ const pointerHintMessage = ({ if (attempt?.actionId === POINTER_ACTIONS.reconnectGoogle) { return ( <> - Press {CONNECTION_BANNER_SHORTCUT_KEY} to reconnect Google - Calendar. + Press {CONNECTION_BANNER_SHORTCUT_KEY} to reconnect{" "} + {calendarProductName(provider)}. ); } @@ -166,6 +176,8 @@ export const PointerHint: FC = () => { const eventJumpKey = useEventJumpStore(selectEventJumpPointerHintKey); const showcaseActive = useShortcutShowcaseStore(selectShowcaseActive); const welcomeOpen = useWelcomeGuideStore(selectWelcomeSurfaceOpen); + const primary = useUserMetadataStore(selectPrimarySyncConnection); + const provider = connectionProvider(primary); const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -189,6 +201,7 @@ export const PointerHint: FC = () => { {pointerHintMessage({ attempt, eventJumpKey, + provider, showcaseActive, welcomeOpen, })} diff --git a/packages/web/src/components/Settings/SettingsModal.test.tsx b/packages/web/src/components/Settings/SettingsModal.test.tsx index 920252e0c6..8cc52a8f19 100644 --- a/packages/web/src/components/Settings/SettingsModal.test.tsx +++ b/packages/web/src/components/Settings/SettingsModal.test.tsx @@ -12,7 +12,11 @@ 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 { + setGoogleAvailabilityForTests, + setProviderAvailabilityForTests, +} from "@web/auth/providers/useIsProviderAvailable"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { UpgradeConfirmationProvider } from "@web/billing/UpgradeConfirmation/UpgradeConfirmationProvider"; import { type AppAccess } from "@web/billing/useAppAccess"; @@ -163,6 +167,7 @@ const renderSettings = ({ } = {}) => { authenticated = isAuthenticated; userMetadataActions.set({ + connections, google: { connectionState: "HEALTHY", connections }, }); const { queryClient, wrapper } = createStoreWrapper(); @@ -511,7 +516,9 @@ describe("SettingsModal", () => { const combobox = screen.getByRole("combobox", { name: "Default Calendar" }); expect( - within(combobox).getByRole("group", { name: "ahab@pequod.com" }), + within(combobox).getByRole("group", { + name: "ahab@pequod.com (Google)", + }), ).toBeInTheDocument(); }); @@ -1051,4 +1058,61 @@ describe("SettingsModal", () => { await user.keyboard("h"); expect(document.activeElement).not.toBe(screen.getByLabelText("Monday")); }); + + it("keeps today's Google add-account copy when Google is the only connectable provider", () => { + setGoogleAvailabilityForTests("available"); + renderSettings({ connections: [connection({ provider: "google" })] }); + + expect( + screen.getByRole("button", { name: "Add account" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Microsoft" }), + ).not.toBeInTheDocument(); + }); + + it("renders a Microsoft connection with Microsoft copy and a Google connection with today's copy", () => { + setGoogleAvailabilityForTests("available"); + setProviderAvailabilityForTests("microsoft", "available"); + const google = connection({ + provider: "google", + accountEmail: "ahab@gmail.com", + }); + const microsoft = connection({ + id: "connection-ms", + provider: "microsoft", + accountEmail: "ada@outlook.com", + }); + const googleCal = createMockCalendar({ + name: "Gmail", + accountEmail: "ahab@gmail.com", + }); + const outlookCal = createMockCalendar({ + name: "Outlook", + accountEmail: "ada@outlook.com", + provider: "microsoft", + }); + + renderSettings({ + connections: [google, microsoft], + calendars: [googleCal, outlookCal], + }); + + const combobox = screen.getByRole("combobox", { name: "Default Calendar" }); + expect( + within(combobox).getByRole("group", { name: "ahab@gmail.com (Google)" }), + ).toBeInTheDocument(); + expect( + within(combobox).getByRole("group", { + name: "ada@outlook.com (Microsoft)", + }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Google" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Microsoft" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add account" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/Settings/SettingsModal.tsx b/packages/web/src/components/Settings/SettingsModal.tsx index 3bc1aa526d..5634fdc2c2 100644 --- a/packages/web/src/components/Settings/SettingsModal.tsx +++ b/packages/web/src/components/Settings/SettingsModal.tsx @@ -1,19 +1,24 @@ import { type FC, Suspense, useEffect, useRef, useState } from "react"; import { type Calendar } from "@core/types/calendar.contracts"; import { type CalendarId } from "@core/types/domain-primitives"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { useSession } from "@web/auth/compass/session/useSession"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; 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"; +} from "@web/auth/providers/connect.util"; +import { ProviderConnectChooser } from "@web/auth/providers/ProviderConnectChooser"; import { - selectGoogleSyncConnections, + connectionProvider, + defaultCalendarGroupLabel, +} from "@web/auth/providers/provider-copy.util"; +import { useGoogleSyncRefreshSnapshot } from "@web/auth/providers/sync.refresh"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { useDisconnectGoogleAccount } from "@web/auth/providers/useDisconnectAccount"; +import { + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { PlanSection } from "@web/billing/PlanSection"; @@ -74,7 +79,7 @@ const navButtonClassName = (current: boolean) => : "c-focus-ring flex w-full items-center justify-between rounded px-2 py-1 text-left text-sm text-text-muted transition-colors hover:bg-surface-overlay hover:text-text"; /** - * The app's Settings menu (Mod+,): Accounts (timezone, calendars, Google + * The app's Settings menu (Mod+,): Accounts (timezone, calendars, provider * connections, export / delete / log out) and Billing (plan) as sibling * pages. ESC steps back a level - out of an open disconnect * confirmation first, then out of a dirty Booking form's discard @@ -131,7 +136,7 @@ export const SettingsModal: FC = () => { }, [page]); const { data } = useCalendarsQuery(); - const connections = useUserMetadataStore(selectGoogleSyncConnections); + const connections = useUserMetadataStore(selectSyncConnections); const accountEmailOrder = useConnectedAccountEmails(); // useDefaultTargetCalendar subscribes to session reconnect overrides, so // writableCalendars recomputes when a 410 lands before Sync metadata catches up. @@ -312,7 +317,7 @@ export const SettingsModal: FC = () => { interface DefaultCalendarPickerProps { calendars: Calendar[]; - connections: GoogleSyncConnectionSummary[]; + connections: SyncConnectionSummary[]; resolvedDefault: Calendar | undefined; } @@ -345,7 +350,13 @@ const DefaultCalendarPicker: FC = ({ {groups .filter((group) => group.calendars.length > 0) .map((group) => ( - + {group.calendars.map((calendar) => (
- {isAvailable ? ( - - - {isConnecting ? "Opening Google…" : "Add account"} - - - ) : null} + ); }; interface AccountRowProps { - connection: GoogleSyncConnectionSummary; + connection: SyncConnectionSummary; disconnect: (connectionId: string, accountEmail: string) => Promise; isConfirming: boolean; isDefault: boolean; @@ -449,18 +446,16 @@ const AccountRow: FC = ({ isDisconnecting, setConfirming, }) => { + const { state } = useConnectProvider(connectionProvider(connection), { + connection, + }); const accountEmail = connection.accountEmail ?? "Unknown account"; const refreshSnapshot = useGoogleSyncRefreshSnapshot(); const sseDegraded = useSseDegraded(); - const syncStatus = getGoogleSyncStatus( - connection.connectionState ?? "NOT_CONNECTED", - connection, - Date.now(), - { - refreshGaveUp: refreshSnapshot.gaveUp, - refreshInFlight: refreshSnapshot.isRefreshing, - }, - ); + const syncStatus = getGoogleSyncStatus(state, connection, Date.now(), { + refreshGaveUp: refreshSnapshot.gaveUp, + refreshInFlight: refreshSnapshot.isRefreshing, + }); // Only override an otherwise-healthy "Calendar connected" - a real // reconnect/attention/importing status already says something more // important and must not be preempted by the live-updates warning. diff --git a/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx b/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx index b4ad5c23d2..1a86c4d9e1 100644 --- a/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.test.tsx @@ -4,7 +4,8 @@ import userEvent from "@testing-library/user-event"; import { type GoogleSyncConnectionSummary } 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 { RECONNECT_CALENDAR_LABEL } from "@web/auth/providers/provider-copy.util"; import { toggleAccountCollapsed } from "@web/calendars/collapsed-accounts.store"; import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; @@ -15,21 +16,30 @@ const EMAIL = "ahab@pequod.com"; // (flipped in afterAll) decides which one runs, leaving later files with the // real hook. Without a mock here this file would instead inherit whichever // other file's useConnectGoogle mock loaded last. -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"; const onSelect = mock(); -const commandActionFor = (state: GoogleUiState) => +const commandActionFor = ( + state: GoogleUiState, + kind: "google" | "microsoft" | "apple" = "google", +) => state === "RECONNECT_REQUIRED" - ? { label: "Reconnect Google Calendar", onSelect } + ? { + label: + kind === "google" + ? "Reconnect Google Calendar" + : RECONNECT_CALENDAR_LABEL[kind], + onSelect, + } : null; -mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ - useConnectGoogle: (...args: Parameters) => +mock.module("@web/auth/providers/useConnectProvider", () => ({ + useConnectProvider: (...args: Parameters) => isConnectGoogleMocked ? { - commandAction: commandActionFor(googleState), + commandAction: commandActionFor(googleState, args[0]), connect: mock(), refresh: mock(), isAvailable: true, @@ -37,7 +47,7 @@ mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ isRefreshing: false, state: googleState, } - : actualUseConnectGoogle(...args), + : actualUseConnectProvider(...args), })); afterAll(() => { @@ -131,6 +141,25 @@ describe("AccountSectionHeader", () => { expect(onSelect).toHaveBeenCalledTimes(1); }); + it("names a Microsoft reconnect action with Outlook copy", async () => { + const user = userEvent.setup({ delay: null }); + googleState = "RECONNECT_REQUIRED"; + + renderHeader({ + provider: "microsoft", + state: "actionRequired", + stateReason: "authorizationRevoked", + connectionState: "RECONNECT_REQUIRED", + }); + + await user.click( + screen.getByRole("button", { + name: `${RECONNECT_CALENDAR_LABEL.microsoft} for ${EMAIL}`, + }), + ); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + it("shows the shimmer on the email while the account's first import runs", () => { googleState = "IMPORTING"; diff --git a/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.tsx b/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.tsx index acd0191d23..2c1165754d 100644 --- a/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/AccountSectionHeader.tsx @@ -1,7 +1,7 @@ import { CaretDownIcon } from "@phosphor-icons/react"; import classNames from "classnames"; import { type FC } from "react"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { accountCalendarListId, toggleAccountCollapsed, @@ -22,7 +22,7 @@ import { useAccountHeaderStatus } from "./useAccountHeaderStatus"; */ export const AccountSectionHeader: FC<{ accountEmail: string; - connection: GoogleSyncConnectionSummary | undefined; + connection: SyncConnectionSummary | undefined; }> = ({ accountEmail, connection }) => { const { actionLabel, diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx index d00d17324c..6fdf26b50a 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarList.test.tsx @@ -18,6 +18,11 @@ import { type ApiRequestConfig } from "@web/api/api.types"; import { BaseApi } from "@web/api/base/base.api"; import { createApiError } from "@web/api/util/api.util"; import { session } from "@web/auth/compass/session/Session"; +import { emptyCalendarsCopy } from "@web/auth/providers/provider-copy.util"; +import { + setGoogleAvailabilityForTests, + setProviderAvailabilityForTests, +} from "@web/auth/providers/useIsProviderAvailable"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { isCalendarHidden } from "@web/calendars/calendar-visibility.storage"; @@ -51,16 +56,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 // 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 +75,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 @@ -145,6 +151,7 @@ const renderCalendarList = ( if (connections) { userMetadataActions.set({ + connections, google: { connectionState: "HEALTHY", connections }, }); } @@ -841,4 +848,25 @@ describe("CalendarList", () => { signOutSpy.mockRestore(); }); + + it("shows today's Google empty-list copy when only Google can connect", () => { + setGoogleAvailabilityForTests("available"); + renderCalendarList([], { authenticated: true, connections: [] }); + + expect( + screen.getByText("Connect Google to see your calendars."), + ).toBeInTheDocument(); + }); + + it("shows Microsoft empty-list copy for a Microsoft-only deploy", () => { + setProviderAvailabilityForTests("microsoft", "available"); + renderCalendarList([], { authenticated: true, connections: [] }); + + expect( + screen.getByText(emptyCalendarsCopy(["microsoft"])), + ).toBeInTheDocument(); + expect( + screen.queryByText("Connect Google to see your calendars."), + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx index bd92b1bea8..2d7b620ada 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx @@ -3,9 +3,10 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { shouldShowContextualLoadError } from "@web/api/util/api.util"; import { useSession } from "@web/auth/compass/session/useSession"; import { useUser } from "@web/auth/compass/user/hooks/useUser"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { emptyCalendarsCopy } from "@web/auth/providers/provider-copy.util"; +import { useConnectableProviders } from "@web/auth/providers/useIsProviderAvailable"; import { - selectGoogleSyncConnections, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; @@ -30,11 +31,11 @@ import { CalendarListHeader } from "./CalendarListHeader"; export const CalendarList: FC = () => { const { authenticated } = useSession(); const { email } = useUser(); - const { isAvailable, state } = useConnectGoogle(); + const connectable = useConnectableProviders(); const { data, error, isPending, isError, refetch } = useCalendarsQuery(); const { toggleCalendarVisibility, failureAnnouncement } = useCalendarVisibility(); - const connections = useUserMetadataStore(selectGoogleSyncConnections); + const connections = useUserMetadataStore(selectSyncConnections); const accountEmailOrder = useConnectedAccountEmails(); const collapsedKeys = useCollapsedAccountKeys(); @@ -103,8 +104,8 @@ export const CalendarList: FC = () => { ) : calendars.length === 0 && groups.length === 0 ? (

- {authenticated && state === "NOT_CONNECTED" && isAvailable - ? "Connect Google to see your calendars." + {authenticated && connections.length === 0 && connectable.length > 0 + ? emptyCalendarsCopy(connectable) : "No calendars yet."}

) : ( diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx index 1ac1fe69f7..9b36467e9f 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx @@ -5,8 +5,10 @@ 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 * as realConnectGoogle from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; -import { type GoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { type GoogleUiState } from "@web/auth/providers/connect.types"; +import { CONNECT_CALENDAR_LABEL } from "@web/auth/providers/provider-copy.util"; +import * as realConnectProvider from "@web/auth/providers/useConnectProvider"; +import { setGoogleAvailabilityForTests } from "@web/auth/providers/useIsProviderAvailable"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import * as realAuthModal from "@web/components/AuthModal/hooks/useAuthModal"; import { beforeEach, describe, expect, it, mock } from "bun:test"; @@ -33,13 +35,16 @@ const googleCommandActionFor = (state: GoogleUiState) => { }; let mockIsConnecting = false; let mockIsRefreshing = false; -const mockUseConnectGoogle = mock(() => ({ - state: mockGoogleState, - isAvailable: true, - isConnecting: mockIsConnecting, - isRefreshing: mockIsRefreshing, - commandAction: googleCommandActionFor(mockGoogleState), -})); +const mockUseConnectProvider = mock( + (_kind: "google" | "microsoft" | "apple") => ({ + state: mockGoogleState, + isAvailable: true, + isConnecting: mockIsConnecting, + isRefreshing: mockIsRefreshing, + connect: mockConnectGoogle, + commandAction: googleCommandActionFor(mockGoogleState), + }), +); mockModuleForFile( "@web/auth/compass/state/auth.state.util", @@ -55,9 +60,9 @@ mockModuleForFile("@web/auth/compass/user/hooks/useUser", realUserHook, { }); mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: mockUseConnectGoogle }, + "@web/auth/providers/useConnectProvider", + realConnectProvider, + { useConnectProvider: mockUseConnectProvider }, ); mockModuleForFile( @@ -88,6 +93,35 @@ const renderHeader = () => , ); +const seedOwnConnection = ( + connectionState: "HEALTHY" | "IMPORTING" | "ATTENTION" | "RECONNECT_REQUIRED", +) => { + const connection = { + id: "conn-1", + state: + connectionState === "RECONNECT_REQUIRED" + ? "actionRequired" + : connectionState === "ATTENTION" + ? "delayed" + : connectionState === "IMPORTING" + ? "importing" + : "healthy", + stateReason: + connectionState === "RECONNECT_REQUIRED" ? "authorizationRevoked" : null, + lastSyncedAt: new Date().toISOString(), + lastHealthyAt: + connectionState === "IMPORTING" ? null : new Date().toISOString(), + accountEmail: mockEmail ?? null, + connectionState, + canSuggestContacts: false, + }; + userMetadataActions.set({ + connections: [connection], + google: { connectionState, connections: [connection] }, + }); + return connection; +}; + describe("CalendarListHeader", () => { beforeEach(() => { mockEmail = undefined; @@ -96,9 +130,10 @@ describe("CalendarListHeader", () => { mockIsRefreshing = false; mockIsAnonymousDirty = false; mockOpenModal.mockClear(); - mockUseConnectGoogle.mockClear(); + mockUseConnectProvider.mockClear(); mockConnectGoogle.mockClear(); userMetadataActions.clear(); + setGoogleAvailabilityForTests("available"); }); it("shows a connect Google button when authenticated and Google is not connected", async () => { @@ -112,7 +147,7 @@ describe("CalendarListHeader", () => { screen.getByRole("heading", { name: "ahab@pequod.com" }), ).toBeInTheDocument(); const connectButton = screen.getByRole("button", { - name: "Connect Google Calendar", + name: CONNECT_CALENDAR_LABEL.google, }); await user.click(connectButton); expect(mockConnectGoogle).toHaveBeenCalledTimes(1); @@ -122,6 +157,7 @@ describe("CalendarListHeader", () => { const user = userEvent.setup(); mockEmail = "ahab@pequod.com"; mockGoogleState = "HEALTHY"; + seedOwnConnection("HEALTHY"); renderHeader(); @@ -140,6 +176,7 @@ describe("CalendarListHeader", () => { const user = userEvent.setup(); mockEmail = "ahab@pequod.com"; mockGoogleState = "IMPORTING"; + seedOwnConnection("IMPORTING"); renderHeader(); @@ -157,6 +194,7 @@ describe("CalendarListHeader", () => { const user = userEvent.setup(); mockEmail = "ahab@pequod.com"; mockGoogleState = "ATTENTION"; + seedOwnConnection("ATTENTION"); renderHeader(); @@ -176,6 +214,7 @@ describe("CalendarListHeader", () => { const user = userEvent.setup(); mockEmail = "ahab@pequod.com"; mockGoogleState = "RECONNECT_REQUIRED"; + seedOwnConnection("RECONNECT_REQUIRED"); renderHeader(); @@ -196,6 +235,7 @@ describe("CalendarListHeader", () => { mockEmail = "ahab@pequod.com"; mockGoogleState = "RECONNECT_REQUIRED"; mockIsConnecting = true; + seedOwnConnection("RECONNECT_REQUIRED"); renderHeader(); @@ -227,6 +267,7 @@ describe("CalendarListHeader", () => { mockEmail = "ahab@pequod.com"; mockGoogleState = "ATTENTION"; mockIsRefreshing = true; + seedOwnConnection("ATTENTION"); renderHeader(); @@ -251,6 +292,7 @@ describe("CalendarListHeader", () => { canSuggestContacts: false, }; userMetadataActions.set({ + connections: [connection], google: { connectionState: "IMPORTING", connections: [connection], @@ -294,6 +336,7 @@ describe("CalendarListHeader", () => { canSuggestContacts: false, }; userMetadataActions.set({ + connections: [otherAccountsBrokenConnection, ownConnection], google: { connectionState: "RECONNECT_REQUIRED", connections: [otherAccountsBrokenConnection, ownConnection], @@ -302,7 +345,7 @@ describe("CalendarListHeader", () => { renderHeader(); - expect(mockUseConnectGoogle).toHaveBeenCalledWith({ + expect(mockUseConnectProvider).toHaveBeenCalledWith("google", { connection: ownConnection, }); }); diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx index 1c7827cd4d..d9fd927fa6 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx @@ -1,8 +1,9 @@ import classNames from "classnames"; import { type FC, useMemo } from "react"; import { useUser } from "@web/auth/compass/user/hooks/useUser"; +import { ProviderConnectChooser } from "@web/auth/providers/ProviderConnectChooser"; import { - selectGoogleSyncConnections, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { useAccountHeaderStatus } from "./useAccountHeaderStatus"; @@ -36,7 +37,7 @@ const CalendarListHeaderContent: FC<{ email: string }> = ({ email }) => { // second account's problem flashes under the first account's name for as // long as that gap lasts (2026-08-04, caught disconnecting one of two live // accounts). - const connections = useUserMetadataStore(selectGoogleSyncConnections); + const connections = useUserMetadataStore(selectSyncConnections); const ownConnection = useMemo( () => connections.find((c) => c.accountEmail === email) ?? null, [connections, email], @@ -65,7 +66,10 @@ const CalendarListHeaderContent: FC<{ email: string }> = ({ email }) => { {email} - {isAvailable && commandAction != null && actionLabel != null ? ( + {ownConnection && + isAvailable && + commandAction != null && + actionLabel != null ? ( + ) : !ownConnection ? ( + ) : null} ); diff --git a/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts b/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts index 27dab67a4c..6ce239048d 100644 --- a/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts +++ b/packages/web/src/components/Sidebar/CalendarList/useAccountHeaderStatus.ts @@ -1,16 +1,17 @@ -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; -import { getSidebarSyncStatus } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { type SyncConnectionSummary } from "@core/types/user.types"; +import { getSidebarSyncStatus } from "@web/auth/providers/connect.util"; +import { connectionProvider } from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; /** * Shared by AccountSectionHeader and CalendarListHeader: this account's sync * status plus the label for its connect/reconnect/refresh action, if any. */ export function useAccountHeaderStatus( - connection: GoogleSyncConnectionSummary | null | undefined, + connection: SyncConnectionSummary | null | undefined, ) { const { commandAction, isAvailable, isConnecting, isRefreshing, state } = - useConnectGoogle({ connection }); + useConnectProvider(connectionProvider(connection), { connection }); const syncStatus = getSidebarSyncStatus({ connection, isConnecting, state }); const actionLabel = commandAction == null 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..89a19b7bf2 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx @@ -1,10 +1,15 @@ 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 { connectionProvider } from "@web/auth/providers/provider-copy.util"; +import { useGoogleSyncRefreshSnapshot } from "@web/auth/providers/sync.refresh"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; 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 +73,11 @@ 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 primary = useUserMetadataStore(selectPrimarySyncConnection); + const { connection, isConnecting, state } = useConnectProvider( + connectionProvider(primary), + { connection: primary }, + ); 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..f47085aca2 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts @@ -1,22 +1,25 @@ 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 // meaningful gap in attention — mirrors useVersionCheck's threshold. const MIN_HIDDEN_DURATION_MS = 30_000; +const useDefaultConnect = (): UseConnectGoogleResult => + useConnectProvider("google"); + /** - * Triggers the same Google Calendar sync refresh as the sidebar's "Refresh - * calendar" CTA (`useConnectGoogle().refresh`), automatically: on mount and + * Triggers the same calendar sync refresh as the sidebar's "Refresh + * 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,14 +28,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 + * `useConnectImpl` is a test seam (default: the real hook) so tests can * pass a fake implementation instead of mock.module-ing a hook other files * also mock. */ export const useSyncFocusRefresh = ( - useConnectGoogleImpl: () => UseConnectGoogleResult = useConnectGoogle, + useConnectImpl: () => UseConnectGoogleResult = useDefaultConnect, ) => { - const { isAvailable, refresh, state } = useConnectGoogleImpl(); + const { isAvailable, refresh, state } = useConnectImpl(); const didReconcileMetadataOnMount = useRef(false); const didProviderRefreshOnMount = useRef(false); const canRefresh = 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..7fd89feff8 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(); @@ -139,6 +144,34 @@ describe("useGcalSSE", () => { }); }); + it("keeps the syncing override when a non-google connection is still importing", async () => { + render(); + + act(() => { + fireMessage({ type: "syncStatusChanged", sync: { status: "syncing" } }); + fireUserMetadata({ + google: { connectionState: "HEALTHY" }, + connections: [ + createMockConnection("ahab@pequod.com", { + id: "64b7f9c2e1a2b3c4d5e6f7a8", + provider: "google", + state: "healthy", + }), + createMockConnection("ada@outlook.com", { + id: "64b7f9c2e1a2b3c4d5e6f7a9", + provider: "microsoft", + state: "importing", + lastHealthyAt: null, + }), + ], + }); + }); + + await waitFor(() => { + expect(getGoogleSyncIndicatorOverride()).toBe("syncing"); + }); + }); + it("refetches metadata and events after importCompleted without clearing syncing", async () => { setSyncingSyncIndicatorOverride(); @@ -207,7 +240,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 +260,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..0046e123e6 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 realConnectGoogle 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, @@ -39,11 +39,9 @@ mockModuleForFile( realUserMetadata, { refreshUserMetadata: mock().mockResolvedValue(undefined) }, ); -mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: mockUseConnectGoogle }, -); +mockModuleForFile("@web/auth/providers/useConnectProvider", realConnectGoogle, { + useConnectProvider: mockUseConnectProvider, +}); mockModuleForFile("@web/sse/client/sse.client", realSseClient, { openStream, closeStream, 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.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(