diff --git a/packages/web/src/api/auth.api.ts b/packages/web/src/api/auth.api.ts index 882a55c70..e43dc0fd1 100644 --- a/packages/web/src/api/auth.api.ts +++ b/packages/web/src/api/auth.api.ts @@ -3,6 +3,10 @@ import { type GoogleConnectResponse, type Result_Auth_Compass, } from "@core/types/auth.types"; +import { + type ConnectionBeginResponse, + ConnectionBeginResponseSchema, +} from "@core/types/sync/connection.contracts"; import { type ApiMethodConfig } from "@web/api/api.types"; import { BaseApi } from "@web/api/base/base.api"; @@ -31,6 +35,19 @@ const AuthApi = { return response.data; }, + + // Sync-delegated connect: ask the backend for the provider consent URL the + // browser should navigate to (the redirect flow). Only meaningful where the + // deployment delegates connections to the sync service; the legacy flow uses + // connectGoogle above. `connectionId` is omitted for a fresh connection. + async beginGoogleConnection(): Promise { + const response = await BaseApi.post( + `/auth/google/connect/begin`, + {}, + ); + + return ConnectionBeginResponseSchema.parse(response.data); + }, }; export { AuthApi }; diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index ff95107b6..431709432 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { AuthApi } from "@web/api/auth.api"; import { SyncApi } from "@web/api/sync.api"; import { getApiErrorCode, isApiError } from "@web/api/util/api.util"; import { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization"; @@ -7,16 +8,23 @@ import { setRepairingSyncIndicatorOverride, } from "@web/auth/google/state/google.sync.state"; import { syncPendingLocalEvents } from "@web/auth/google/util/google.auth.util"; -import { GOOGLE_REPAIR_FAILED_TOAST_ID } from "@web/common/constants/toast.constants"; +import { + GOOGLE_CONNECT_FAILED_TOAST_ID, + GOOGLE_REPAIR_FAILED_TOAST_ID, +} from "@web/common/constants/toast.constants"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; import { settingsActions } from "@web/settings/settings.store"; -import { useIsGoogleAvailable } from "../useIsGoogleAvailable/useIsGoogleAvailable"; +import { + useIsConnectDelegatedToSync, + useIsGoogleAvailable, +} from "../useIsGoogleAvailable/useIsGoogleAvailable"; import { type UseConnectGoogleResult } from "./useConnectGoogle.types"; import { getGoogleConnectionConfig } from "./useConnectGoogle.util"; import { useGoogleUiState } from "./useGoogleUiState"; export const useConnectGoogle = (): UseConnectGoogleResult => { const isAvailable = useIsGoogleAvailable(); + const isConnectDelegatedToSync = useIsConnectDelegatedToSync(); const state = useGoogleUiState(); const { startGoogleAuthorization } = useStartGoogleAuthorization({ intent: "connectCalendar", @@ -32,11 +40,29 @@ export const useConnectGoogle = (): UseConnectGoogleResult => { } settingsActions.closeCmdPalette(); - void startGoogleAuthorization(); + + if (!isConnectDelegatedToSync) { + void startGoogleAuthorization(); + return; + } + + // Sync-delegated connect: the sync service owns the OAuth round-trip, so + // the browser just navigates to the consent URL it mints. No client-side + // code exchange happens here; the connection is linked when Google calls + // back to the sync service. + try { + const { authorizationUrl } = await AuthApi.beginGoogleConnection(); + window.location.assign(authorizationUrl); + } catch { + showErrorToast( + "We couldn't start connecting your Google Calendar. Please try again.", + { toastId: GOOGLE_CONNECT_FAILED_TOAST_ID }, + ); + } }; void start(); - }, [startGoogleAuthorization]); + }, [isConnectDelegatedToSync, startGoogleAuthorization]); const onRepairGoogle = useCallback(() => { const startRepair = async () => { diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts index 993824a91..e169299c7 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts @@ -2,8 +2,12 @@ import { useEffect, useSyncExternalStore } from "react"; type BackendGoogleAvailability = "available" | "unavailable" | "unknown"; +type AppConfigResponse = { + google?: { isConfigured?: boolean; connectDelegatedToSync?: boolean }; +}; + type GoogleAvailabilityDependencies = { - getConfig: () => Promise<{ google?: { isConfigured?: boolean } }>; + getConfig: () => Promise; isGoogleAuthConfigured: boolean; }; @@ -13,6 +17,11 @@ export function createGoogleAvailability({ }: GoogleAvailabilityDependencies) { const listeners = new Set<() => void>(); let backendGoogleAvailability: BackendGoogleAvailability = "unknown"; + // Deployment posture: does the backend delegate Google connections to the + // sync service (redirect flow) instead of the legacy code-exchange flow? + // Loaded from the same /config response; false until known, so the connect + // UX stays on the legacy path unless delegation is confirmed on. + let connectDelegatedToSync = false; let loadPromise: Promise | undefined; const emit = () => { @@ -28,7 +37,7 @@ export function createGoogleAvailability({ emit(); }; - const subscribeToBackendGoogleAvailability = (listener: () => void) => { + const subscribe = (listener: () => void) => { listeners.add(listener); return () => { @@ -39,6 +48,9 @@ export function createGoogleAvailability({ const getBackendGoogleAvailabilitySnapshot = (): boolean => backendGoogleAvailability === "available"; + const getConnectDelegatedToSyncSnapshot = (): boolean => + connectDelegatedToSync; + const loadBackendGoogleAvailability = async (): Promise => { if (!isGoogleAuthConfigured) { setBackendGoogleAvailability("unavailable"); @@ -48,12 +60,15 @@ export function createGoogleAvailability({ if (!loadPromise) { loadPromise = getConfig() .then((config) => { + connectDelegatedToSync = + config.google?.connectDelegatedToSync ?? false; setBackendGoogleAvailability( config.google?.isConfigured ? "available" : "unavailable", ); }) .catch(() => { loadPromise = undefined; + connectDelegatedToSync = false; setBackendGoogleAvailability("unavailable"); }); } @@ -63,7 +78,7 @@ export function createGoogleAvailability({ const useIsGoogleAvailable = (): boolean => { const isBackendGoogleConfigured = useSyncExternalStore( - subscribeToBackendGoogleAvailability, + subscribe, getBackendGoogleAvailabilitySnapshot, getBackendGoogleAvailabilitySnapshot, ); @@ -75,8 +90,23 @@ export function createGoogleAvailability({ return isGoogleAuthConfigured && isBackendGoogleConfigured; }; + const useIsConnectDelegatedToSync = (): boolean => { + const delegated = useSyncExternalStore( + subscribe, + getConnectDelegatedToSyncSnapshot, + getConnectDelegatedToSyncSnapshot, + ); + + useEffect(() => { + void loadBackendGoogleAvailability(); + }, []); + + return delegated; + }; + const resetGoogleAvailabilityForTests = () => { backendGoogleAvailability = "unknown"; + connectDelegatedToSync = false; loadPromise = undefined; emit(); }; @@ -90,9 +120,18 @@ export function createGoogleAvailability({ emit(); }; + /** Pins connect-delegation for tests and skips the config fetch. */ + const setConnectDelegatedToSyncForTests = (delegated: boolean) => { + connectDelegatedToSync = delegated; + loadPromise = Promise.resolve(); + emit(); + }; + return { resetGoogleAvailabilityForTests, setGoogleAvailabilityForTests, + setConnectDelegatedToSyncForTests, useIsGoogleAvailable, + useIsConnectDelegatedToSync, }; } diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx index 13f9f26bf..f35cdde61 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.test.tsx @@ -16,6 +16,18 @@ const createHook = () => { return useIsGoogleAvailable; }; +const createDelegationHook = () => { + const { resetGoogleAvailabilityForTests, useIsConnectDelegatedToSync } = + createGoogleAvailability({ + getConfig, + isGoogleAuthConfigured: true, + }); + + resetGoogleAvailabilityForTests(); + + return useIsConnectDelegatedToSync; +}; + describe("useIsGoogleAvailable", () => { it("uses the backend config response before exposing Google UI", async () => { getConfig.mockClear(); @@ -63,3 +75,42 @@ describe("useIsGoogleAvailable", () => { expect(getConfig).toHaveBeenCalledTimes(2); }); }); + +describe("useIsConnectDelegatedToSync", () => { + it("reflects the backend connect-delegation flag from config", async () => { + getConfig.mockClear(); + getConfig.mockResolvedValue({ + google: { + isConfigured: true, + connectDelegatedToSync: true, + }, + }); + const useIsConnectDelegatedToSync = createDelegationHook(); + + const { result } = renderHook(() => useIsConnectDelegatedToSync()); + + // Defaults to legacy (false) until the config load resolves. + expect(result.current).toBe(false); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("stays on legacy when the flag is absent from an older backend", async () => { + getConfig.mockClear(); + getConfig.mockResolvedValue({ + google: { + isConfigured: true, + }, + }); + const useIsConnectDelegatedToSync = createDelegationHook(); + + const { result } = renderHook(() => useIsConnectDelegatedToSync()); + + await waitFor(() => { + expect(getConfig).toHaveBeenCalledTimes(1); + }); + 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 index 17e905682..5c954ffb2 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts @@ -10,5 +10,7 @@ const googleAvailability = createGoogleAvailability({ export const { resetGoogleAvailabilityForTests, setGoogleAvailabilityForTests, + setConnectDelegatedToSyncForTests, useIsGoogleAvailable, + useIsConnectDelegatedToSync, } = googleAvailability; diff --git a/packages/web/src/common/constants/toast.constants.ts b/packages/web/src/common/constants/toast.constants.ts index 8732477a0..cb36d7107 100644 --- a/packages/web/src/common/constants/toast.constants.ts +++ b/packages/web/src/common/constants/toast.constants.ts @@ -6,6 +6,7 @@ import { useThemeStore } from "@web/settings/theme/theme.store"; export const EVENT_DELETED_TOAST_ID: Id = "event-deleted"; export const GOOGLE_REVOKED_TOAST_ID: Id = "google-revoked-api"; export const GOOGLE_REPAIR_FAILED_TOAST_ID: Id = "google-repair-failed"; +export const GOOGLE_CONNECT_FAILED_TOAST_ID: Id = "google-connect-failed"; export const SUBSCRIBE_TO_UPDATES_TOAST_ID: Id = "subscribe-to-updates"; export const EXPORT_MY_DATA_TOAST_ID: Id = "export-my-data";