Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/web/src/api/auth.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<ConnectionBeginResponse> {
const response = await BaseApi.post<ConnectionBeginResponse>(
`/auth/google/connect/begin`,
{},
);

return ConnectionBeginResponseSchema.parse(response.data);
},
};

export { AuthApi };
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppConfigResponse>;
isGoogleAuthConfigured: boolean;
};

Expand All @@ -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<void> | undefined;

const emit = () => {
Expand All @@ -28,7 +37,7 @@ export function createGoogleAvailability({
emit();
};

const subscribeToBackendGoogleAvailability = (listener: () => void) => {
const subscribe = (listener: () => void) => {
listeners.add(listener);

return () => {
Expand All @@ -39,6 +48,9 @@ export function createGoogleAvailability({
const getBackendGoogleAvailabilitySnapshot = (): boolean =>
backendGoogleAvailability === "available";

const getConnectDelegatedToSyncSnapshot = (): boolean =>
connectDelegatedToSync;

const loadBackendGoogleAvailability = async (): Promise<void> => {
if (!isGoogleAuthConfigured) {
setBackendGoogleAvailability("unavailable");
Expand All @@ -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");
});
}
Expand All @@ -63,7 +78,7 @@ export function createGoogleAvailability({

const useIsGoogleAvailable = (): boolean => {
const isBackendGoogleConfigured = useSyncExternalStore(
subscribeToBackendGoogleAvailability,
subscribe,
getBackendGoogleAvailabilitySnapshot,
getBackendGoogleAvailabilitySnapshot,
);
Expand All @@ -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();
};
Expand All @@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ const googleAvailability = createGoogleAvailability({
export const {
resetGoogleAvailabilityForTests,
setGoogleAvailabilityForTests,
setConnectDelegatedToSyncForTests,
useIsGoogleAvailable,
useIsConnectDelegatedToSync,
} = googleAvailability;
1 change: 1 addition & 0 deletions packages/web/src/common/constants/toast.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down