From 224a25f8d9d59750734970c46c6e1cf0dd3966e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 00:30:44 +0000 Subject: [PATCH 1/6] feat(backend): add provider-neutral connection API Add generic begin/disconnect/refresh routes, wrap begin as a redirect union, emit CONNECTION_REVOKED next to GOOGLE_REVOKED, and expose connections[] with provider while keeping metadata.google overlap. Co-authored-by: Tyler Dane --- .../agent-loop/allowlists/providers-p0.txt | 4 + .../backend/src/auth/auth.routes.config.ts | 25 ++++- .../controllers/auth.controller.db.test.ts | 93 +++++++++++++++++++ .../auth/controllers/auth.controller.test.ts | 37 +++++++- .../src/auth/controllers/auth.controller.ts | 78 +++++++++++++--- .../common/constants/config.constants.test.ts | 12 +++ .../src/common/constants/config.util.ts | 21 +++++ .../src/common/errors/auth/auth.errors.ts | 44 +++++++-- .../connection-state.translation.test.ts | 12 +++ .../connection-state.translation.ts | 29 +++--- .../sync-service/google-connection-status.ts | 4 +- .../sync-connection-begin.test.ts | 3 + .../sync-service/sync-connection-begin.ts | 2 +- .../sync-service/sync-service.client.test.ts | 3 + packages/backend/src/event/event.error.ts | 2 + .../services/user-metadata.service.db.test.ts | 41 ++++++++ .../user/services/user-metadata.service.ts | 6 +- packages/core/src/types/auth.types.ts | 1 + .../src/types/event-command.contracts.test.ts | 1 + .../core/src/types/event-command.contracts.ts | 4 + .../types/server-message.contracts.test.ts | 44 +++++++++ .../src/types/server-message.contracts.ts | 34 ++++++- .../types/sync/connection.contracts.test.ts | 67 +++++++++++++ .../src/types/sync/connection.contracts.ts | 70 +++++++++++--- .../src/types/sync/identity.contracts.test.ts | 9 ++ .../core/src/types/sync/identity.contracts.ts | 10 ++ packages/core/src/types/user.types.ts | 14 ++- packages/web/src/api/auth.api.ts | 8 +- 28 files changed, 613 insertions(+), 65 deletions(-) create mode 100644 packages/backend/src/auth/controllers/auth.controller.db.test.ts diff --git a/.github/agent-loop/allowlists/providers-p0.txt b/.github/agent-loop/allowlists/providers-p0.txt index b6494005a4..7dabb8171a 100644 --- a/.github/agent-loop/allowlists/providers-p0.txt +++ b/.github/agent-loop/allowlists/providers-p0.txt @@ -2,3 +2,7 @@ # One extended-regex per line. Comments and blank lines are ignored. ^packages/sync/src/providers/ ^packages/sync/src/providers/__contract__/ +^packages/backend/src/auth/ +^packages/web/src/auth/ +^packages/web/src/api/auth\.api\.ts$ +^\.github/agent-loop/allowlists/providers-p0\.txt$ diff --git a/packages/backend/src/auth/auth.routes.config.ts b/packages/backend/src/auth/auth.routes.config.ts index c9e0eae147..2bf4243d3e 100644 --- a/packages/backend/src/auth/auth.routes.config.ts +++ b/packages/backend/src/auth/auth.routes.config.ts @@ -28,6 +28,28 @@ export class AuthRoutes extends CommonRoutesConfig { }); // Returns the provider consent URL for the browser to navigate to. + this.app + .route(`/api/auth/connections/begin`) + .all(requireSession) + .post((req, res) => { + authController.beginConnection(req, res); + }); + + this.app + .route(`/api/auth/connections/refresh`) + .all(requireSession) + .post((req, res) => { + authController.refreshConnection(req, res); + }); + + this.app + .route(`/api/auth/connections/:connectionId`) + .all(requireSession) + .delete((req, res) => { + authController.disconnectConnection(req, res); + }); + + // Google aliases kept for one release. They force provider: google. this.app .route(`/api/auth/google/connect/begin`) .all(requireSession) @@ -35,8 +57,6 @@ export class AuthRoutes extends CommonRoutesConfig { authController.beginGoogleConnection(req, res); }); - // Disconnect one connected Google account (the user's others are - // unaffected, as is their Compass sign-in). this.app .route(`/api/auth/google/connect/:connectionId`) .all(requireSession) @@ -44,7 +64,6 @@ export class AuthRoutes extends CommonRoutesConfig { authController.disconnectGoogleConnection(req, res); }); - // Enqueue Sync catch-up pulls for the signed-in user's calendars. this.app .route(`/api/auth/google/sync/refresh`) .all(requireSession) diff --git a/packages/backend/src/auth/controllers/auth.controller.db.test.ts b/packages/backend/src/auth/controllers/auth.controller.db.test.ts new file mode 100644 index 0000000000..1b9e891f8d --- /dev/null +++ b/packages/backend/src/auth/controllers/auth.controller.db.test.ts @@ -0,0 +1,93 @@ +import { Status } from "@core/errors/status.codes"; +import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; +import { UtilDriver } from "@backend/__tests__/drivers/util.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { restoreFileMocks } from "@backend/__tests__/helpers/mock.setup"; +import { CONFIG } from "@backend/common/constants/config.constants"; +import * as syncServiceFactory from "@backend/common/services/sync-service/sync-service.factory"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + spyOn, +} from "bun:test"; + +const AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + +describe("auth.controller connections API", () => { + const baseDriver = new BaseDriver(); + const originalMicrosoftId = CONFIG.MICROSOFT_CLIENT_ID; + const originalMicrosoftSecret = CONFIG.MICROSOFT_CLIENT_SECRET; + + beforeAll(() => setupTestDb(import.meta.url)); + beforeEach(cleanupCollections); + afterAll(cleanupTestDb); + + afterEach(() => { + CONFIG.MICROSOFT_CLIENT_ID = originalMicrosoftId; + CONFIG.MICROSOFT_CLIENT_SECRET = originalMicrosoftSecret; + restoreFileMocks(); + }); + + const sessionFor = (userId: string) => + baseDriver.setSessionPlugin({ userId }); + + it("returns the same redirect body from the new begin route and the Google alias", async () => { + const { user } = await UtilDriver.setupTestUser(); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + beginConnection: async () => ({ + ok: true, + value: { authorizationUrl: AUTHORIZATION_URL }, + correlationId: "corr-1", + }), + } as never); + + const expected = { + kind: "redirect", + authorizationUrl: AUTHORIZATION_URL, + }; + + const next = await baseDriver + .getServer() + .post("/api/auth/connections/begin") + .use(sessionFor(user._id.toString())) + .send({ provider: "google" }) + .expect(Status.OK); + + const alias = await baseDriver + .getServer() + .post("/api/auth/google/connect/begin") + .use(sessionFor(user._id.toString())) + .send({}) + .expect(Status.OK); + + expect(next.body).toEqual(expected); + expect(alias.body).toEqual(expected); + }); + + it("returns 409 PROVIDER_NOT_CONFIGURED for microsoft on a Google-only deployment", async () => { + CONFIG.MICROSOFT_CLIENT_ID = undefined; + CONFIG.MICROSOFT_CLIENT_SECRET = undefined; + const { user } = await UtilDriver.setupTestUser(); + const beginConnection = spyOn(syncServiceFactory, "getSyncServiceClient"); + + const response = await baseDriver + .getServer() + .post("/api/auth/connections/begin") + .use(sessionFor(user._id.toString())) + .send({ provider: "microsoft" }) + .expect(Status.CONFLICT); + + expect(response.body.code).toBe("PROVIDER_NOT_CONFIGURED"); + expect(response.body.message).toContain("Microsoft"); + expect(beginConnection).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/auth/controllers/auth.controller.test.ts b/packages/backend/src/auth/controllers/auth.controller.test.ts index 7437f53e44..4d1f985290 100644 --- a/packages/backend/src/auth/controllers/auth.controller.test.ts +++ b/packages/backend/src/auth/controllers/auth.controller.test.ts @@ -1,7 +1,8 @@ import { Status } from "@core/errors/status.codes"; import { CONFIG } from "@backend/common/constants/config.constants"; +import * as syncServiceFactory from "@backend/common/services/sync-service/sync-service.factory"; import authController from "./auth.controller"; -import { afterEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; describe("auth.controller", () => { describe("beginGoogleConnection", () => { @@ -34,4 +35,38 @@ describe("auth.controller", () => { }); }); }); + + describe("beginConnection", () => { + const originalMicrosoftId = CONFIG.MICROSOFT_CLIENT_ID; + const originalMicrosoftSecret = CONFIG.MICROSOFT_CLIENT_SECRET; + + afterEach(() => { + CONFIG.MICROSOFT_CLIENT_ID = originalMicrosoftId; + CONFIG.MICROSOFT_CLIENT_SECRET = originalMicrosoftSecret; + mock.restore(); + }); + + it("rejects an unconfigured microsoft provider with 409 PROVIDER_NOT_CONFIGURED", async () => { + CONFIG.MICROSOFT_CLIENT_ID = undefined; + CONFIG.MICROSOFT_CLIENT_SECRET = undefined; + const syncSpy = spyOn(syncServiceFactory, "getSyncServiceClient"); + const promise = mock(); + + authController.beginConnection( + { + body: { provider: "microsoft" }, + session: { getUserId: () => "507f1f77bcf86cd799439011" }, + } as never, + { promise } as never, + ); + + expect(syncSpy).not.toHaveBeenCalled(); + expect(promise).toHaveBeenCalledTimes(1); + const rejection = promise.mock.calls[0]?.[0] as Promise; + await expect(rejection).rejects.toMatchObject({ + statusCode: Status.CONFLICT, + code: "PROVIDER_NOT_CONFIGURED", + }); + }); + }); }); diff --git a/packages/backend/src/auth/controllers/auth.controller.ts b/packages/backend/src/auth/controllers/auth.controller.ts index dd8a003052..bb556b7ba7 100644 --- a/packages/backend/src/auth/controllers/auth.controller.ts +++ b/packages/backend/src/auth/controllers/auth.controller.ts @@ -4,10 +4,22 @@ import { Logger } from "@core/logger/winston.logger"; import { type ConnectionBeginRequest, ConnectionBeginRequestSchema, + toConnectionBeginRedirect, } from "@core/types/sync/connection.contracts"; -import { ConnectionIdSchema } from "@core/types/sync/identity.contracts"; +import { + ConnectionIdSchema, + type ProviderKind, + ProviderKindSchema, +} from "@core/types/sync/identity.contracts"; import { zObjectId } from "@core/types/type.utils"; import compassAuthService from "@backend/auth/services/compass/compass.auth.service"; +import { CONFIG } from "@backend/common/constants/config.constants"; +import { isOAuthConnectConfigured } from "@backend/common/constants/config.util"; +import { + AuthError, + authErrorCopy, +} from "@backend/common/errors/auth/auth.errors"; +import { error } from "@backend/common/errors/handlers/error.handler"; import { assertCloudMutationsAllowed } from "@backend/common/services/sync-service/cloud-mutation-mode"; import { beginSyncConnection } from "@backend/common/services/sync-service/sync-connection-begin"; import { toSyncPrincipal } from "@backend/common/services/sync-service/sync-principal"; @@ -33,6 +45,20 @@ const rejectIfMaintenance = (res: Res_Promise): boolean => { } }; +const assertProviderCanBegin = (provider: ProviderKind): void => { + // Google begin stays on the existing sync path so an unconfigured Google + // deploy keeps the pre-WP-07 error, not a new 409. + if (provider === "google") return; + if (isOAuthConnectConfigured(CONFIG, provider)) return; + throw error( + { + ...AuthError.ProviderNotConfigured, + description: authErrorCopy.notConfigured(provider), + }, + "Connect Failed", + ); +}; + class AuthController { createSession = async ( req: ReqBody<{ cUserId: string }>, @@ -65,28 +91,42 @@ class AuthController { res.promise({ userId }); }; - // Start a Google connection: return the provider consent URL the browser - // should navigate to. - beginGoogleConnection = ( + beginConnection = ( req: SReqBody, res: Res_Promise, + forcedProvider?: ProviderKind, ): void => { if (rejectIfMaintenance(res)) return; const client = getSyncServiceClient(); const userId = zObjectId.parse(req.session?.getUserId()).toString(); const request = ConnectionBeginRequestSchema.parse(req.body ?? {}); + const provider = forcedProvider ?? request.provider ?? "google"; + ProviderKindSchema.parse(provider); - res.promise(beginSyncConnection(client, toSyncPrincipal(userId), request)); + try { + assertProviderCanBegin(provider); + } catch (err) { + res.promise(Promise.reject(err)); + return; + } + + res.promise( + beginSyncConnection(client, toSyncPrincipal(userId), { + ...request, + provider, + }).then(toConnectionBeginRedirect), + ); }; - // Disconnect one connected Google account, leaving the user's others (and - // their Compass sign-in) alone. Sync scopes the disconnect to the signed - // principal, so a connection id the caller does not own is rejected there. - disconnectGoogleConnection = ( - req: SessionRequest, + beginGoogleConnection = ( + req: SReqBody, res: Res_Promise, ): void => { + this.beginConnection(req, res, "google"); + }; + + disconnectConnection = (req: SessionRequest, res: Res_Promise): void => { if (rejectIfMaintenance(res)) return; const client = getSyncServiceClient(); @@ -100,15 +140,21 @@ class AuthController { unwrapSyncResult(result, { logger, logMessage: "Sync disconnect failed", - userMessage: "Failed to disconnect Google account", + userMessage: "Failed to disconnect calendar account", }), ) .then(() => ({ statusCode: 204 })), ); }; - // User-triggered Google calendar catch-up (Refresh calendar CTA). - refreshGoogleSync = (req: SessionRequest, res: Res_Promise): void => { + disconnectGoogleConnection = ( + req: SessionRequest, + res: Res_Promise, + ): void => { + this.disconnectConnection(req, res); + }; + + refreshConnection = (req: SessionRequest, res: Res_Promise): void => { if (rejectIfMaintenance(res)) return; const client = getSyncServiceClient(); @@ -119,11 +165,15 @@ class AuthController { unwrapSyncResult(result, { logger, logMessage: "Sync refresh failed", - userMessage: "Failed to refresh Google Calendar", + userMessage: "Failed to refresh calendar", }), ), ); }; + + refreshGoogleSync = (req: SessionRequest, res: Res_Promise): void => { + this.refreshConnection(req, res); + }; } export default new AuthController(); diff --git a/packages/backend/src/common/constants/config.constants.test.ts b/packages/backend/src/common/constants/config.constants.test.ts index 14af6e285c..973e81723d 100644 --- a/packages/backend/src/common/constants/config.constants.test.ts +++ b/packages/backend/src/common/constants/config.constants.test.ts @@ -7,6 +7,7 @@ import { isBillingBypassed, isGoogleConfigured, isMicrosoftConfigured, + isOAuthConnectConfigured, isStripeConfigured, } from "@backend/common/constants/config.util"; import { describe, expect, it } from "bun:test"; @@ -176,6 +177,17 @@ describe("config.constants", () => { ).toThrow("Microsoft configuration requires both client ID and secret"); }); + it("reports OAuth connect configured per provider", () => { + const googleOnly = parseConfigFromEnv({ + ...validEnv, + GOOGLE_CLIENT_ID: "client-id", + GOOGLE_CLIENT_SECRET: "client-secret", + }); + expect(isOAuthConnectConfigured(googleOnly, "google")).toBe(true); + expect(isOAuthConnectConfigured(googleOnly, "microsoft")).toBe(false); + expect(isOAuthConnectConfigured(googleOnly, "apple")).toBe(false); + }); + it("reports Microsoft as configured only when both credentials are present", () => { const fromEnv = parseConfigFromEnv({ ...validEnv, diff --git a/packages/backend/src/common/constants/config.util.ts b/packages/backend/src/common/constants/config.util.ts index 4c33964157..427dc8ecef 100644 --- a/packages/backend/src/common/constants/config.util.ts +++ b/packages/backend/src/common/constants/config.util.ts @@ -1,3 +1,4 @@ +import { type ProviderKind } from "@core/types/sync/identity.contracts"; import { type Config } from "./config.constants"; export const isGoogleClientIdValid = (clientId?: string): boolean => @@ -39,6 +40,26 @@ export const isAppleConnectConfigured = ( env: Pick, ): boolean => isConfiguredValue(env.SYNC_CREDENTIAL_ENCRYPTION_KEY); +export const isOAuthConnectConfigured = ( + env: Pick< + Config, + | "GOOGLE_CLIENT_ID" + | "GOOGLE_CLIENT_SECRET" + | "MICROSOFT_CLIENT_ID" + | "MICROSOFT_CLIENT_SECRET" + >, + provider: ProviderKind, +): boolean => { + switch (provider) { + case "google": + return isGoogleConfigured(env); + case "microsoft": + return isMicrosoftConfigured(env); + case "apple": + return false; + } +}; + const isStripeValueValid = (value?: string): boolean => Boolean(value && value !== "undefined"); diff --git a/packages/backend/src/common/errors/auth/auth.errors.ts b/packages/backend/src/common/errors/auth/auth.errors.ts index 621a21d5d6..b5bb34a255 100644 --- a/packages/backend/src/common/errors/auth/auth.errors.ts +++ b/packages/backend/src/common/errors/auth/auth.errors.ts @@ -1,4 +1,8 @@ import { Status } from "@core/errors/status.codes"; +import { + type ProviderKind, + providerDisplayName, +} from "@core/types/sync/identity.contracts"; import { type ErrorMetadata } from "@backend/common/types/error.types"; interface AuthErrors { @@ -6,6 +10,7 @@ interface AuthErrors { GoogleAccountAlreadyConnected: ErrorMetadata; GoogleConnectEmailMismatch: ErrorMetadata; GoogleNotConfigured: ErrorMetadata; + ProviderNotConfigured: ErrorMetadata; GoogleRedirectUriMismatch: ErrorMetadata; GoogleRefreshTokenMissing: ErrorMetadata; GoogleSignInWhileAuthenticated: ErrorMetadata; @@ -14,6 +19,24 @@ interface AuthErrors { SyncConnectionUnavailable: ErrorMetadata; } +const calendarHostLabel = (provider?: ProviderKind): string => + provider ? providerDisplayName(provider) : "your calendar"; + +export const authErrorCopy = { + accountAlreadyConnected: (provider?: ProviderKind) => + `${calendarHostLabel(provider)} account is already connected to another Compass user`, + connectEmailMismatch: (provider?: ProviderKind) => + `${calendarHostLabel(provider)} account email does not match the signed-in Compass account`, + notConfigured: (provider?: ProviderKind) => + `${calendarHostLabel(provider)} is not configured for this Compass instance`, + redirectUriMismatch: (provider?: ProviderKind) => + `${calendarHostLabel(provider)} redirect URI does not match this Compass instance`, + refreshTokenMissing: (provider?: ProviderKind) => + `${calendarHostLabel(provider)} did not grant a fresh authorization. Please try again.`, + signInWhileAuthenticated: (provider?: ProviderKind) => + `You're already signed in. Use Settings → Add account to connect this ${calendarHostLabel(provider)} account.`, +}; + export const AuthError: AuthErrors = { DevOnly: { description: "Only available during development", @@ -22,39 +45,42 @@ export const AuthError: AuthErrors = { }, GoogleAccountAlreadyConnected: { code: "GOOGLE_ACCOUNT_ALREADY_CONNECTED", - description: "Google account is already connected to another Compass user", + description: authErrorCopy.accountAlreadyConnected("google"), status: Status.CONFLICT, isOperational: true, }, GoogleConnectEmailMismatch: { code: "GOOGLE_CONNECT_EMAIL_MISMATCH", - description: - "Google account email does not match the signed-in Compass account", + description: authErrorCopy.connectEmailMismatch("google"), status: Status.CONFLICT, isOperational: true, }, GoogleNotConfigured: { code: "GOOGLE_NOT_CONFIGURED", - description: "Google is not configured for this Compass instance", + description: authErrorCopy.notConfigured("google"), status: Status.SERVICE_UNAVAILABLE, isOperational: true, }, + ProviderNotConfigured: { + code: "PROVIDER_NOT_CONFIGURED", + description: authErrorCopy.notConfigured(), + status: Status.CONFLICT, + isOperational: true, + }, GoogleRedirectUriMismatch: { - description: "Google redirect URI does not match this Compass instance", + description: authErrorCopy.redirectUriMismatch("google"), status: Status.BAD_REQUEST, isOperational: true, }, GoogleRefreshTokenMissing: { code: "GOOGLE_REFRESH_TOKEN_MISSING", - description: - "Google did not grant a fresh authorization. Please try again.", + description: authErrorCopy.refreshTokenMissing("google"), status: Status.CONFLICT, isOperational: true, }, GoogleSignInWhileAuthenticated: { code: "GOOGLE_SIGNIN_WHILE_AUTHENTICATED", - description: - "You're already signed in. Use Settings → Add account to connect this Google account.", + description: authErrorCopy.signInWhileAuthenticated("google"), status: Status.CONFLICT, isOperational: true, }, diff --git a/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts b/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts index 0e2cb2088d..f9f15cb180 100644 --- a/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts +++ b/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts @@ -86,6 +86,17 @@ describe("toGoogleConnectionState", () => { toGoogleConnectionState([connection("healthy"), connection("healthy")]), ).toBe("HEALTHY"); }); + + it("ignores non-Google connections when collapsing the Google enum", () => { + expect( + toGoogleConnectionState([ + connection("healthy"), + connection("actionRequired", "authorizationRevoked", { + provider: "microsoft", + }), + ]), + ).toBe("HEALTHY"); + }); }); }); @@ -104,6 +115,7 @@ describe("toGoogleSyncConnectionSummary", () => { }; expect(toGoogleSyncConnectionSummary(record)).toEqual({ id: "c-summary", + provider: "google", state: "delayed", stateReason: "workOverdue", lastSyncedAt: "2026-07-24T10:00:00.000Z", diff --git a/packages/backend/src/common/services/sync-service/connection-state.translation.ts b/packages/backend/src/common/services/sync-service/connection-state.translation.ts index 918ed1e4cb..2b01f00478 100644 --- a/packages/backend/src/common/services/sync-service/connection-state.translation.ts +++ b/packages/backend/src/common/services/sync-service/connection-state.translation.ts @@ -5,19 +5,17 @@ import { } from "@core/types/sync/connection.contracts"; import { type GoogleConnectionState, - type GoogleSyncConnectionSummary, + type SyncConnectionSummary, } from "@core/types/user.types"; /** * Translate the sync service's multi-connection health model into the single - * `GoogleConnectionState` enum the browser already reads from `/user/metadata`. + * `GoogleConnectionState` enum the browser already reads from + * `metadata.google.connectionState`. * - * The two implementations model connections differently — sync tracks many - * connections each with a rich `state`/`stateReason`, while the legacy product - * exposes one derived Google enum — so delegating status to sync means - * translating here rather than passing a shape through. Keeping this a pure - * function makes the mapping exhaustively testable in isolation, independent of - * any route wiring. + * The enum stays Google-specific during the overlap: only Google connections + * collapse into it. Per-connection state lives on each summary. Keeping this + * a pure function makes the mapping exhaustively testable in isolation. */ // actionRequired reasons that mean the user must re-authorize (re-run the @@ -63,8 +61,7 @@ function translateConnection( // When a principal has more than one Google connection, surface the most // actionable state so a single broken account is never hidden behind a healthy -// one. A user has one Google account today; this keeps the contract honest if -// that changes. +// one. Microsoft and Apple connections do not participate in this enum. const PRECEDENCE: readonly GoogleConnectionState[] = [ "RECONNECT_REQUIRED", "ATTENTION", @@ -75,11 +72,12 @@ const PRECEDENCE: readonly GoogleConnectionState[] = [ export function toGoogleConnectionState( connections: readonly ProviderConnection[], ): GoogleConnectionState { - // Google is the only provider today and this enum is Google-specific, so - // every connection maps directly. - if (connections.length === 0) return "NOT_CONNECTED"; + const googleConnections = connections.filter( + (connection) => connection.provider === "google", + ); + if (googleConnections.length === 0) return "NOT_CONNECTED"; - const states = connections.map((connection) => + const states = googleConnections.map((connection) => translateConnection(connection.state, connection.stateReason), ); for (const candidate of PRECEDENCE) { @@ -92,9 +90,10 @@ export function toGoogleConnectionState( export function toGoogleSyncConnectionSummary( connection: ProviderConnection, -): GoogleSyncConnectionSummary { +): SyncConnectionSummary { return { id: connection.id, + provider: connection.provider, state: connection.state, stateReason: connection.stateReason, lastSyncedAt: connection.lastSyncedAt, diff --git a/packages/backend/src/common/services/sync-service/google-connection-status.ts b/packages/backend/src/common/services/sync-service/google-connection-status.ts index 4146de4c2c..6168e08671 100644 --- a/packages/backend/src/common/services/sync-service/google-connection-status.ts +++ b/packages/backend/src/common/services/sync-service/google-connection-status.ts @@ -1,7 +1,7 @@ import { Logger } from "@core/logger/winston.logger"; import { type GoogleConnectionState, - type GoogleSyncConnectionSummary, + type SyncConnectionSummary, } from "@core/types/user.types"; import { toGoogleConnectionState, @@ -21,7 +21,7 @@ export interface GoogleConnectionFromSync { // none / outage. The browser derives the precedence-winning one from this // plus connectionState (selectPrimaryGoogleSyncConnection) rather than // receiving a second, redundant copy of it over the wire. - connections: GoogleSyncConnectionSummary[]; + connections: SyncConnectionSummary[]; } /** diff --git a/packages/backend/src/common/services/sync-service/sync-connection-begin.test.ts b/packages/backend/src/common/services/sync-service/sync-connection-begin.test.ts index 4075d4ae18..9ea84c4bde 100644 --- a/packages/backend/src/common/services/sync-service/sync-connection-begin.test.ts +++ b/packages/backend/src/common/services/sync-service/sync-connection-begin.test.ts @@ -31,6 +31,9 @@ describe("beginSyncConnection", () => { const result = await beginSyncConnection(client, principal, {}); + if (!("authorizationUrl" in result)) { + throw new Error("expected a redirect begin response"); + } expect(result.authorizationUrl).toContain("accounts.google.com"); }); diff --git a/packages/backend/src/common/services/sync-service/sync-connection-begin.ts b/packages/backend/src/common/services/sync-service/sync-connection-begin.ts index 8a5bdca119..702a231936 100644 --- a/packages/backend/src/common/services/sync-service/sync-connection-begin.ts +++ b/packages/backend/src/common/services/sync-service/sync-connection-begin.ts @@ -30,6 +30,6 @@ export async function beginSyncConnection( return unwrapSyncResult(await client.beginConnection(principal, request), { logger, logMessage: "Sync begin-connection failed", - userMessage: "Failed to start Google connection", + userMessage: "Failed to start calendar connection", }); } diff --git a/packages/backend/src/common/services/sync-service/sync-service.client.test.ts b/packages/backend/src/common/services/sync-service/sync-service.client.test.ts index 1f853617ce..7d1757add6 100644 --- a/packages/backend/src/common/services/sync-service/sync-service.client.test.ts +++ b/packages/backend/src/common/services/sync-service/sync-service.client.test.ts @@ -703,6 +703,9 @@ describe("SyncServiceClient", () => { const result = await client(fn).beginConnection(who); if (!result.ok) throw new Error(`expected ok, got ${result.error.kind}`); + if (!("authorizationUrl" in result.value)) { + throw new Error("expected a redirect begin response"); + } expect(result.value.authorizationUrl).toContain("accounts.google.com"); const sent = calls[0]; diff --git a/packages/backend/src/event/event.error.ts b/packages/backend/src/event/event.error.ts index 9c8f155021..e80a45c57d 100644 --- a/packages/backend/src/event/event.error.ts +++ b/packages/backend/src/event/event.error.ts @@ -25,6 +25,7 @@ const STATUS_BY_CODE: Record = { // expiry and retries the request after refresh. Google revocation must not // share that status or event creates loop until maxRetryAttemptsForSessionRefresh. GOOGLE_REVOKED: Status.GONE, + CONNECTION_REVOKED: Status.GONE, MAINTENANCE: Status.SERVICE_UNAVAILABLE, MOVE_UNSUPPORTED: Status.BAD_REQUEST, INVALID_INPUT: Status.BAD_REQUEST, @@ -49,6 +50,7 @@ const RETRYABLE_BY_CODE: Record = { PROVIDER_FAILURE: true, SYNC_UNAVAILABLE: true, GOOGLE_REVOKED: false, + CONNECTION_REVOKED: false, MAINTENANCE: true, MOVE_UNSUPPORTED: false, INVALID_INPUT: false, diff --git a/packages/backend/src/user/services/user-metadata.service.db.test.ts b/packages/backend/src/user/services/user-metadata.service.db.test.ts index a295398819..62ced491ed 100644 --- a/packages/backend/src/user/services/user-metadata.service.db.test.ts +++ b/packages/backend/src/user/services/user-metadata.service.db.test.ts @@ -1,20 +1,25 @@ import { type UserMetadata } from "@core/types/user.types"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { UserMetadataServiceDriver } from "@backend/__tests__/drivers/user-metadata.service.driver"; +import { providerConnection } from "@backend/__tests__/factories/provider-connection.factory"; import { cleanupCollections, cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; +import { restoreFileMocks } from "@backend/__tests__/helpers/mock.setup"; import { getUserMetadataStore } from "@backend/auth/ports/supertokens.registry"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; +import * as syncServiceFactory from "@backend/common/services/sync-service/sync-service.factory"; import { afterAll, + afterEach, beforeAll, beforeEach, describe, expect, it, + spyOn, } from "bun:test"; describe("UserMetadataService", () => { @@ -24,6 +29,9 @@ describe("UserMetadataService", () => { beforeAll(() => setupTestDb(import.meta.url)); beforeEach(cleanupCollections); afterAll(cleanupTestDb); + afterEach(() => { + restoreFileMocks(); + }); describe("updateUserMetadata", () => { it("merges metadata and returns the latest snapshot", async () => { @@ -122,6 +130,39 @@ describe("UserMetadataService", () => { expect(metadata.sync?.importGCal).toBe("RESTART"); }); + it("returns connections[] with provider and keeps metadata.google", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const google = providerConnection("healthy"); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + listConnections: async () => ({ + ok: true, + value: { connections: [google] }, + correlationId: "corr-1", + }), + } as never); + + const metadata = await driver.fetchUserMetadata(userId); + + expect(metadata.connections).toEqual([ + expect.objectContaining({ + id: google.id, + provider: "google", + connectionState: "HEALTHY", + }), + ]); + expect(metadata.google).toEqual({ + connectionState: "HEALTHY", + connections: [ + expect.objectContaining({ + id: google.id, + provider: "google", + connectionState: "HEALTHY", + }), + ], + }); + }); + // assessGoogleMetadata's local fallback (no Sync client configured) is // covered separately in user-metadata.service.no-sync-client.db.test.ts: // getSyncServiceClient() caches its result for the life of the process, diff --git a/packages/backend/src/user/services/user-metadata.service.ts b/packages/backend/src/user/services/user-metadata.service.ts index d8ba2773f2..2d69fd3eff 100644 --- a/packages/backend/src/user/services/user-metadata.service.ts +++ b/packages/backend/src/user/services/user-metadata.service.ts @@ -113,12 +113,16 @@ class UserMetadataService { const { connectionState, connections } = await this.assessGoogleMetadata(userId); + const googleConnections = connections.filter( + (connection) => connection.provider === "google", + ); return { ...metadata, + connections, google: { connectionState, - ...(connections !== undefined ? { connections } : {}), + connections: googleConnections, }, }; }; diff --git a/packages/core/src/types/auth.types.ts b/packages/core/src/types/auth.types.ts index b7d716d8a5..182a916d47 100644 --- a/packages/core/src/types/auth.types.ts +++ b/packages/core/src/types/auth.types.ts @@ -38,6 +38,7 @@ const GoogleConnectErrorCodeSchema = z.enum([ "GOOGLE_ACCOUNT_ALREADY_CONNECTED", "GOOGLE_CONNECT_EMAIL_MISMATCH", "GOOGLE_NOT_CONFIGURED", + "PROVIDER_NOT_CONFIGURED", // Google withheld a refresh token because this browser already consented // to the app before (e.g. a prior signup attempt failed after Google-side // consent but before Compass finished linking). Retrying with `prompt: diff --git a/packages/core/src/types/event-command.contracts.test.ts b/packages/core/src/types/event-command.contracts.test.ts index 6eb4ec5eaa..6cf7aa907e 100644 --- a/packages/core/src/types/event-command.contracts.test.ts +++ b/packages/core/src/types/event-command.contracts.test.ts @@ -406,6 +406,7 @@ describe("Event Command Contracts", () => { "INVALID_SCHEDULE", "PROVIDER_FAILURE", "GOOGLE_REVOKED", + "CONNECTION_REVOKED", "MAINTENANCE", ] as const; diff --git a/packages/core/src/types/event-command.contracts.ts b/packages/core/src/types/event-command.contracts.ts index e31d3e77d1..92dec240c0 100644 --- a/packages/core/src/types/event-command.contracts.ts +++ b/packages/core/src/types/event-command.contracts.ts @@ -200,6 +200,9 @@ export const EventMutationErrorCodeSchema = z.enum([ // always safe to retry with the same idempotency key. "SYNC_UNAVAILABLE", "GOOGLE_REVOKED", + // Provider-neutral alias of GOOGLE_REVOKED. Both codes stay on the wire + // until milestone C drops the Google-named one. + "CONNECTION_REVOKED", // Scoped cutover maintenance: cloud/provider mutations paused (S50). "MAINTENANCE", // A replace tried to move a provider-linked event to a different calendar. @@ -231,5 +234,6 @@ export const EventMutationErrorSchema = z.strictObject({ code: EventMutationErrorCodeSchema, message: z.string().min(1), retryable: z.boolean(), + connectionId: z.string().optional(), }); export type EventMutationError = z.infer; diff --git a/packages/core/src/types/server-message.contracts.test.ts b/packages/core/src/types/server-message.contracts.test.ts index 0572234cd6..79ee94b133 100644 --- a/packages/core/src/types/server-message.contracts.test.ts +++ b/packages/core/src/types/server-message.contracts.test.ts @@ -3,6 +3,7 @@ import { CalendarChangeMessageSchema, EventChangeMessageSchema, ImportResultMessageSchema, + revokedConnectionServerMessages, ServerMessageSchema, SyncStatusMessageSchema, UserMetadataMessageSchema, @@ -83,6 +84,20 @@ describe("Server Message Contracts", () => { expect(SyncStatusMessageSchema.safeParse(message).success).toBe(true); }); + it("parses CONNECTION_REVOKED with a connectionId", () => { + const message = { + type: "syncStatusChanged", + sync: { + status: "attention", + code: "CONNECTION_REVOKED", + connectionId: calendarId(), + retryable: false, + }, + }; + + expect(SyncStatusMessageSchema.safeParse(message).success).toBe(true); + }); + it("rejects an attention status missing code and retryable", () => { const message = { type: "syncStatusChanged", @@ -169,5 +184,34 @@ describe("Server Message Contracts", () => { expect(ServerMessageSchema.safeParse(message).success).toBe(true); } }); + + it("emits CONNECTION_REVOKED and GOOGLE_REVOKED for a revoked connection", () => { + const connectionId = calendarId(); + const messages = revokedConnectionServerMessages(connectionId); + + expect(messages).toEqual([ + { + type: "syncStatusChanged", + sync: { + status: "attention", + code: "CONNECTION_REVOKED", + connectionId, + retryable: false, + }, + }, + { + type: "syncStatusChanged", + sync: { + status: "attention", + code: "GOOGLE_REVOKED", + retryable: false, + }, + }, + ]); + + for (const message of messages) { + expect(ServerMessageSchema.safeParse(message).success).toBe(true); + } + }); }); }); diff --git a/packages/core/src/types/server-message.contracts.ts b/packages/core/src/types/server-message.contracts.ts index a4c2b00245..b2d16efbc0 100644 --- a/packages/core/src/types/server-message.contracts.ts +++ b/packages/core/src/types/server-message.contracts.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; import { CalendarIdSchema, EventIdSchema } from "@core/types/domain-primitives"; +import { ConnectionIdSchema } from "@core/types/sync/identity.contracts"; export const EventChangeMessageSchema = z.strictObject({ type: z.literal("eventsChanged"), @@ -20,7 +21,13 @@ const SyncStateSchema = z.discriminatedUnion("status", [ z.strictObject({ status: z.literal("healthy") }), z.strictObject({ status: z.literal("attention"), - code: z.enum(["GOOGLE_REVOKED", "IMPORT_FAILED", "WATCH_REPAIR_FAILED"]), + code: z.enum([ + "GOOGLE_REVOKED", + "CONNECTION_REVOKED", + "IMPORT_FAILED", + "WATCH_REPAIR_FAILED", + ]), + connectionId: ConnectionIdSchema.optional(), retryable: z.boolean(), }), ]); @@ -61,3 +68,28 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ UserMetadataMessageSchema, ]); export type ServerMessage = z.infer; + +export function revokedConnectionServerMessages( + connectionId: string, +): ServerMessage[] { + const parsedId = ConnectionIdSchema.parse(connectionId); + return [ + { + type: "syncStatusChanged", + sync: { + status: "attention", + code: "CONNECTION_REVOKED", + connectionId: parsedId, + retryable: false, + }, + }, + { + type: "syncStatusChanged", + sync: { + status: "attention", + code: "GOOGLE_REVOKED", + retryable: false, + }, + }, + ]; +} diff --git a/packages/core/src/types/sync/connection.contracts.test.ts b/packages/core/src/types/sync/connection.contracts.test.ts index 4d4a8220d7..a5149ec7fe 100644 --- a/packages/core/src/types/sync/connection.contracts.test.ts +++ b/packages/core/src/types/sync/connection.contracts.test.ts @@ -4,13 +4,16 @@ import { CalendarListQuerySchema, ConnectionBeginFeaturesSchema, ConnectionBeginRequestSchema, + ConnectionBeginResponseSchema, ConnectionListResponseSchema, ConnectionStateSchema, GoogleConnectionAdoptionRequestSchema, ProviderAccountFactsSchema, ProviderCalendarSchema, + ProviderConnectionAdoptionRequestSchema, ProviderConnectionSchema, SyncCalendarListResponseSchema, + toConnectionBeginRedirect, } from "@core/types/sync/connection.contracts"; const objectId = () => faker.database.mongodbObjectId(); @@ -233,6 +236,51 @@ describe("Sync connection contracts", () => { }); }); + describe("ConnectionBeginResponseSchema", () => { + it("accepts the legacy sync-internal redirect body", () => { + expect( + ConnectionBeginResponseSchema.parse({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + }), + ).toEqual({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + }); + }); + + it("accepts a redirect result with kind", () => { + expect( + ConnectionBeginResponseSchema.parse({ + kind: "redirect", + authorizationUrl: "https://login.microsoftonline.com/common/oauth2", + }), + ).toEqual({ + kind: "redirect", + authorizationUrl: "https://login.microsoftonline.com/common/oauth2", + }); + }); + + it("accepts a connected result", () => { + const connectionId = objectId(); + expect( + ConnectionBeginResponseSchema.parse({ + kind: "connected", + connectionId, + }), + ).toEqual({ kind: "connected", connectionId }); + }); + + it("wraps a legacy body as a redirect", () => { + expect( + toConnectionBeginRedirect({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + }), + ).toEqual({ + kind: "redirect", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + }); + }); + }); + describe("ProviderAccountFactsSchema", () => { it("accepts null display fields", () => { const facts = { @@ -273,6 +321,25 @@ describe("Sync connection contracts", () => { ).toBe(true); }); + it("accepts an optional provider", () => { + expect( + ProviderConnectionAdoptionRequestSchema.safeParse({ + provider: "microsoft", + account: { + providerAccountId: "1122334455", + email: "connected@example.com", + displayName: "Connected User", + }, + credential: { + iv: "aGVsbG8=", + ciphertext: "Y2lwaGVydGV4dA==", + authTag: "dGFn", + }, + grantedScopes: ["Calendars.Read"], + }).success, + ).toBe(true); + }); + it("rejects an empty scope set", () => { expect( GoogleConnectionAdoptionRequestSchema.safeParse({ diff --git a/packages/core/src/types/sync/connection.contracts.ts b/packages/core/src/types/sync/connection.contracts.ts index 95acab1bc3..cbaacbcae5 100644 --- a/packages/core/src/types/sync/connection.contracts.ts +++ b/packages/core/src/types/sync/connection.contracts.ts @@ -78,10 +78,12 @@ export type EncryptedCredentialEnvelope = z.infer< typeof EncryptedCredentialEnvelopeSchema >; -// Trusted Compass API → Sync handoff after the normal Google sign-in flow has -// exchanged an authorization code. This is intentionally an internal contract: -// the browser never receives or submits the credential. -export const GoogleConnectionAdoptionRequestSchema = z.strictObject({ +// Trusted Compass API → Sync handoff after a sign-in flow has exchanged an +// authorization code. This is intentionally an internal contract: the +// browser never receives or submits the credential. Optional `provider` +// defaults to google so the existing Google adoption path stays byte-identical. +export const ProviderConnectionAdoptionRequestSchema = z.strictObject({ + provider: ProviderKindSchema.optional(), account: ProviderAccountFactsSchema, credential: EncryptedCredentialEnvelopeSchema, grantedScopes: z @@ -90,14 +92,21 @@ export const GoogleConnectionAdoptionRequestSchema = z.strictObject({ .max(64) .readonly(), }); -export type GoogleConnectionAdoptionRequest = z.infer< - typeof GoogleConnectionAdoptionRequestSchema +export type ProviderConnectionAdoptionRequest = z.infer< + typeof ProviderConnectionAdoptionRequestSchema >; +export const GoogleConnectionAdoptionRequestSchema = + ProviderConnectionAdoptionRequestSchema; +export type GoogleConnectionAdoptionRequest = ProviderConnectionAdoptionRequest; -export const GoogleConnectionAdoptionResponseSchema = z.strictObject({}); -export type GoogleConnectionAdoptionResponse = z.infer< - typeof GoogleConnectionAdoptionResponseSchema +export const ProviderConnectionAdoptionResponseSchema = z.strictObject({}); +export type ProviderConnectionAdoptionResponse = z.infer< + typeof ProviderConnectionAdoptionResponseSchema >; +export const GoogleConnectionAdoptionResponseSchema = + ProviderConnectionAdoptionResponseSchema; +export type GoogleConnectionAdoptionResponse = + ProviderConnectionAdoptionResponse; const STATES_WITH_REASON: ReadonlySet = new Set([ "delayed", @@ -206,15 +215,52 @@ export type ConnectionBeginRequest = z.infer< typeof ConnectionBeginRequestSchema >; -// The provider consent URL the browser is sent to. `begin` only mints the URL; -// the connection is created/updated when the provider calls back. -export const ConnectionBeginResponseSchema = z.strictObject({ +// Browser begin response: a redirect (OAuth) or an already-connected result +// (password credential flows, defined here; the route that produces +// `connected` lands in Apple WP-03). The legacy `{ authorizationUrl }` body +// is still accepted so a backend/web build that lands before the wrap still +// parses, and so the sync-internal begin reply stays byte-identical. +export const ConnectionBeginRedirectResponseSchema = z.strictObject({ + kind: z.literal("redirect"), + authorizationUrl: z.string().url(), +}); +export type ConnectionBeginRedirectResponse = z.infer< + typeof ConnectionBeginRedirectResponseSchema +>; + +export const ConnectionBeginConnectedResponseSchema = z.strictObject({ + kind: z.literal("connected"), + connectionId: ConnectionIdSchema, +}); +export type ConnectionBeginConnectedResponse = z.infer< + typeof ConnectionBeginConnectedResponseSchema +>; + +export const ConnectionBeginLegacyRedirectResponseSchema = z.strictObject({ authorizationUrl: z.string().url(), }); + +export const ConnectionBeginResponseSchema = z.union([ + ConnectionBeginRedirectResponseSchema, + ConnectionBeginConnectedResponseSchema, + ConnectionBeginLegacyRedirectResponseSchema, +]); export type ConnectionBeginResponse = z.infer< typeof ConnectionBeginResponseSchema >; +export function toConnectionBeginRedirect( + response: ConnectionBeginResponse, +): ConnectionBeginRedirectResponse { + if ("authorizationUrl" in response) { + return { + kind: "redirect", + authorizationUrl: response.authorizationUrl, + }; + } + throw new Error("Connection begin did not return a redirect"); +} + // User-triggered catch-up: enqueue (or boost) an incremental pull for each // events resource owned by the signed principal. // - `enqueued`: jobs that will actually run (created + boosted + revived failed) diff --git a/packages/core/src/types/sync/identity.contracts.test.ts b/packages/core/src/types/sync/identity.contracts.test.ts index f33fc3c908..420282fe2b 100644 --- a/packages/core/src/types/sync/identity.contracts.test.ts +++ b/packages/core/src/types/sync/identity.contracts.test.ts @@ -10,6 +10,7 @@ import { ProviderCapabilitySetSchema, ProviderEventIdSchema, ProviderKindSchema, + providerDisplayName, SyncCommandIdSchema, SyncJobIdSchema, TenantIdSchema, @@ -133,6 +134,14 @@ describe("Sync identity contracts", () => { }); }); + describe("providerDisplayName", () => { + it("names each provider for user-facing copy", () => { + expect(providerDisplayName("google")).toBe("Google"); + expect(providerDisplayName("microsoft")).toBe("Microsoft"); + expect(providerDisplayName("apple")).toBe("Apple"); + }); + }); + describe("ProviderCapabilitySchema", () => { it.each([ "readEvents", diff --git a/packages/core/src/types/sync/identity.contracts.ts b/packages/core/src/types/sync/identity.contracts.ts index 9103839231..174761f0b4 100644 --- a/packages/core/src/types/sync/identity.contracts.ts +++ b/packages/core/src/types/sync/identity.contracts.ts @@ -98,3 +98,13 @@ export const ProviderCapabilitySetSchema = z ) .readonly(); export type ProviderCapabilitySet = z.infer; + +export const PROVIDER_DISPLAY_NAMES = { + google: "Google", + microsoft: "Microsoft", + apple: "Apple", +} as const satisfies Record; + +export function providerDisplayName(kind: ProviderKind): string { + return PROVIDER_DISPLAY_NAMES[kind]; +} diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 44aadd29f4..b9020812f3 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -69,8 +69,9 @@ export interface Schema_UserBilling { } /** - * Unified Google connection state computed by the server. + * Unified connection state computed by the server. * Clients read this value directly instead of deriving state from multiple sources. + * The Google-named alias stays until WP-08b reads `connections[]` per provider. */ export type GoogleConnectionState = | "NOT_CONNECTED" @@ -86,8 +87,9 @@ export type GoogleConnectionState = // A type alias, not an interface: only aliases get the implicit index // signature that lets these values sit inside SuperTokens' JSONObject // metadata payload without a cast at every call site. -export type GoogleSyncConnectionSummary = { +export type SyncConnectionSummary = { id: string; + provider?: "google" | "microsoft" | "apple"; state: string; stateReason: string | null; lastSyncedAt: string | null; @@ -97,21 +99,25 @@ export type GoogleSyncConnectionSummary = { // sync state/reason. The browser renders per-account status and reconnect // from it directly, so sync's state vocabulary stays on the server. connectionState: GoogleConnectionState; - // True when this connection granted a Google contacts scope (sync's + // True when this connection granted a contacts scope (sync's // `suggestContacts` capability), so the attendee field can offer live // contact suggestions. False is an ordinary state — contacts are an // OPTIONAL grant — and gates the "enable contact suggestions" nudge, // never an error surface. canSuggestContacts: boolean; }; +export type GoogleSyncConnectionSummary = SyncConnectionSummary; // Intersection (not extends): SuperTokens JSONObject's string index signature // rejects a nested `google.connection` object on an interface extends clause, // even though every field is JSON-safe. export type UserMetadata = SupertokensUserMetadata.JSONObject & { + // Every connected provider account. WP-08b reads this; until then the + // overlap `google.connections` copy stays so the existing web keeps working. + connections?: SyncConnectionSummary[]; google?: { connectionState?: GoogleConnectionState; - // Every connected provider account, in connection order. The + // Every connected Google account, in connection order. The // precedence-winning one (for the top-level banner / unscoped hooks) is // derived client-side from this plus connectionState - see // selectPrimaryGoogleSyncConnection. diff --git a/packages/web/src/api/auth.api.ts b/packages/web/src/api/auth.api.ts index 65e304ee75..4f96fd8ebf 100644 --- a/packages/web/src/api/auth.api.ts +++ b/packages/web/src/api/auth.api.ts @@ -29,13 +29,17 @@ const AuthApi = { // reconnect an existing connection; omit it for a fresh one. async beginGoogleConnection( request: ConnectionBeginRequest = {}, - ): Promise { + ): Promise<{ authorizationUrl: string }> { const response = await BaseApi.post( `/auth/google/connect/begin`, request, ); - return ConnectionBeginResponseSchema.parse(response.data); + const parsed = ConnectionBeginResponseSchema.parse(response.data); + if (!("authorizationUrl" in parsed)) { + throw new Error("Google connect did not return a redirect"); + } + return { authorizationUrl: parsed.authorizationUrl }; }, // Disconnect one connected Google account. The user's other accounts, and From b41a375aa465c7f03b64cd47004f73e1ff48475f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 00:38:21 +0000 Subject: [PATCH 2/6] fix(backend): reject unconfigured providers before calling sync Keep PROVIDER_NOT_CONFIGURED on the connection begin path from ever constructing a sync client. Co-authored-by: Tyler Dane --- packages/backend/src/auth/controllers/auth.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/auth/controllers/auth.controller.ts b/packages/backend/src/auth/controllers/auth.controller.ts index bb556b7ba7..2f5a9a04ce 100644 --- a/packages/backend/src/auth/controllers/auth.controller.ts +++ b/packages/backend/src/auth/controllers/auth.controller.ts @@ -98,8 +98,6 @@ class AuthController { ): void => { if (rejectIfMaintenance(res)) return; - const client = getSyncServiceClient(); - const userId = zObjectId.parse(req.session?.getUserId()).toString(); const request = ConnectionBeginRequestSchema.parse(req.body ?? {}); const provider = forcedProvider ?? request.provider ?? "google"; ProviderKindSchema.parse(provider); @@ -111,6 +109,9 @@ class AuthController { return; } + const client = getSyncServiceClient(); + const userId = zObjectId.parse(req.session?.getUserId()).toString(); + res.promise( beginSyncConnection(client, toSyncPrincipal(userId), { ...request, From 82e6390007242dfb6233456c930c57866d170fdf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 00:44:11 +0000 Subject: [PATCH 3/6] chore(handoff): record p0 wp-07 verify pass Co-authored-by: Tyler Dane --- .agents/handoffs/3230.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .agents/handoffs/3230.md diff --git a/.agents/handoffs/3230.md b/.agents/handoffs/3230.md new file mode 100644 index 0000000000..4788d79bec --- /dev/null +++ b/.agents/handoffs/3230.md @@ -0,0 +1,28 @@ +--- +schema_version: 1 +task_id: "3230" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/backend/src/auth/auth.routes.config.ts + - path: packages/backend/src/auth/controllers/auth.controller.ts + - path: packages/core/src/types/sync/connection.contracts.ts + - path: packages/core/src/types/server-message.contracts.ts + - path: packages/backend/src/user/services/user-metadata.service.ts +evidence: + - command: bun run verify --strict + result: "VERDICT: PASS (test:core, test:web, test:backend, type-check, lint, knip, test:a11y, test:e2e)" +assumptions: + - "Google begin stays on the sync path when Google is unconfigured so that deploy keeps the pre-WP-07 error instead of a new 409." + - "P0 allowlist covers packages/backend/src/auth/ so this WP can auto-merge." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +P0 WP-07: provider-neutral backend connection API and metadata. From b184b07f754cf1fe837575ebfc112fde259309c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 00:53:00 +0000 Subject: [PATCH 4/6] feat(web): add additive provider connect layer Introduce useConnectProvider, shared config availability, and provider-neutral connect-status toasts. useConnectGoogle becomes a google wrapper so existing spies and tests stay unchanged. Co-authored-by: Tyler Dane --- .agents/handoffs/3231.md | 33 +++ .../src/__tests__/helpers/web-test-seams.ts | 7 + packages/web/src/api/auth.api.ts | 17 ++ packages/web/src/app.bootstrap.tsx | 16 +- .../google-connect-status.util.ts | 107 +------- .../useConnectGoogle/useConnectGoogle.ts | 230 +----------------- .../useConnectGoogle.types.ts | 23 ++ .../useIsGoogleAvailable.factory.ts | 114 +-------- .../useIsGoogleAvailable.ts | 11 +- .../providers/connect-status.util.test.ts | 102 ++++++++ .../src/auth/providers/connect-status.util.ts | 124 ++++++++++ .../provider-availability.factory.ts | 175 +++++++++++++ .../provider-availability.instance.ts | 8 + .../providers/useConnectProvider.test.tsx | 111 +++++++++ .../src/auth/providers/useConnectProvider.ts | 195 +++++++++++++++ .../providers/useIsProviderAvailable.test.tsx | 35 +++ .../auth/providers/useIsProviderAvailable.ts | 7 + 17 files changed, 873 insertions(+), 442 deletions(-) create mode 100644 .agents/handoffs/3231.md create mode 100644 packages/web/src/auth/providers/connect-status.util.test.ts create mode 100644 packages/web/src/auth/providers/connect-status.util.ts create mode 100644 packages/web/src/auth/providers/provider-availability.factory.ts create mode 100644 packages/web/src/auth/providers/provider-availability.instance.ts create mode 100644 packages/web/src/auth/providers/useConnectProvider.test.tsx create mode 100644 packages/web/src/auth/providers/useConnectProvider.ts create mode 100644 packages/web/src/auth/providers/useIsProviderAvailable.test.tsx create mode 100644 packages/web/src/auth/providers/useIsProviderAvailable.ts diff --git a/.agents/handoffs/3231.md b/.agents/handoffs/3231.md new file mode 100644 index 0000000000..2f3c3fee32 --- /dev/null +++ b/.agents/handoffs/3231.md @@ -0,0 +1,33 @@ +--- +schema_version: 1 +task_id: "3231" +from: Implementer +to: GitHub +owner: GitHub +status: implementing +artifact: + - path: packages/web/src/auth/providers/useConnectProvider.ts + - path: packages/web/src/auth/providers/useIsProviderAvailable.ts + - path: packages/web/src/auth/providers/connect-status.util.ts + - path: packages/web/src/auth/providers/provider-availability.factory.ts +evidence: + - command: bun test:web (focused providers + existing google tests) + result: pass (32 tests) + - command: bun lint + result: pass + - command: bun run type-check + result: pass + - command: bun knip + result: pass +assumptions: + - "Google begin still calls AuthApi.beginGoogleConnection so existing spies stay valid." + - "WP-08a PR waits until #3230 is on main so this branch does not re-ship WP-07." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: "PR #3378 (WP-07) to merge" +escalation: null +--- + +P0 WP-08a: additive auth/providers layer in the web app. diff --git a/packages/web/src/__tests__/helpers/web-test-seams.ts b/packages/web/src/__tests__/helpers/web-test-seams.ts index f11b41f806..fab3dc96d4 100644 --- a/packages/web/src/__tests__/helpers/web-test-seams.ts +++ b/packages/web/src/__tests__/helpers/web-test-seams.ts @@ -13,6 +13,10 @@ import { resetGoogleAvailabilityForTests, setGoogleAvailabilityForTests, } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; +import { + resetProviderAvailabilityForTests, + setProviderAvailabilityForTests, +} from "@web/auth/providers/useIsProviderAvailable"; import { resetEmbeddedCheckoutForTests } from "@web/billing/embedded-checkout/embedded-checkout.seam"; import { registerToastPort, @@ -172,6 +176,8 @@ export function installDefaultWebTestSeams(): void { // Skip /config fetch (no MSW handler); "unavailable" matches prior failed-fetch default. resetGoogleAvailabilityForTests(); setGoogleAvailabilityForTests("unavailable"); + setProviderAvailabilityForTests("microsoft", "unavailable"); + setProviderAvailabilityForTests("apple", "unavailable"); } export function resetWebTestSeams(): void { @@ -182,5 +188,6 @@ export function resetWebTestSeams(): void { // AuthModal owns emailpassword reset — production SuperTokens patches XHR vs MSW. resetUseCompleteAuthenticationForTests(); resetGoogleAvailabilityForTests(); + resetProviderAvailabilityForTests(); resetEmbeddedCheckoutForTests(); } diff --git a/packages/web/src/api/auth.api.ts b/packages/web/src/api/auth.api.ts index 4f96fd8ebf..92c9d17710 100644 --- a/packages/web/src/api/auth.api.ts +++ b/packages/web/src/api/auth.api.ts @@ -42,6 +42,23 @@ const AuthApi = { return { authorizationUrl: parsed.authorizationUrl }; }, + async beginConnection( + request: ConnectionBeginRequest = {}, + ): Promise { + const provider = request.provider ?? "google"; + if (provider === "google") { + const { provider: _provider, ...rest } = request; + const google = await AuthApi.beginGoogleConnection(rest); + return { kind: "redirect", authorizationUrl: google.authorizationUrl }; + } + + const response = await BaseApi.post( + `/auth/connections/begin`, + { ...request, provider }, + ); + return ConnectionBeginResponseSchema.parse(response.data); + }, + // Disconnect one connected Google account. The user's other accounts, and // their Compass sign-in, are unaffected. async disconnectGoogleConnection(connectionId: string): Promise { diff --git a/packages/web/src/app.bootstrap.tsx b/packages/web/src/app.bootstrap.tsx index 730a7b9f00..fe454af839 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 { - readGoogleConnectStatus, - refreshUserMetadataAfterGoogleConnect, - showGoogleConnectStatusToast, -} from "@web/auth/google/authorization/google-connect-status.util"; import { configureGoogleRevocationApiHandler } from "@web/auth/google/util/google-revocation-api.config"; +import { + readConnectStatus, + refreshUserMetadataAfterConnect, + showConnectStatusToast, +} from "@web/auth/providers/connect-status.util"; import { initializeDatabaseWithErrorHandling, showDbInitErrorToast, @@ -22,7 +22,7 @@ export async function bootstrapApp(): Promise { // Read before the router mounts: validateAuthSearch strips unrecognized // query params (like these) on the first navigation. - const connectStatus = readGoogleConnectStatus(); + const connectStatus = readConnectStatus(); const container = document.getElementById("root"); if (!container) { @@ -46,7 +46,7 @@ export async function bootstrapApp(): Promise { showDbInitErrorToast(dbInitError); } if (connectStatus) { - showGoogleConnectStatusToast(connectStatus); - refreshUserMetadataAfterGoogleConnect(connectStatus); + showConnectStatusToast(connectStatus); + refreshUserMetadataAfterConnect(connectStatus.status); } } 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 index d531cc7944..11df26e3f1 100644 --- a/packages/web/src/auth/google/authorization/google-connect-status.util.ts +++ b/packages/web/src/auth/google/authorization/google-connect-status.util.ts @@ -1,114 +1,27 @@ -import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; -import { track } from "@web/auth/posthog/track"; import { - GOOGLE_CONNECT_FAILED_TOAST_ID, - getToastDefaultOptions, -} from "@web/common/constants/toast.constants"; -import { getToast } from "@web/common/utils/toast/toast.port"; + type ConnectStatus, + readConnectStatus, + refreshUserMetadataAfterConnect, + showConnectStatusToast, +} from "@web/auth/providers/connect-status.util"; -// Mirrors the `status` values sync/server/connection.routes.ts's -// redirectAfterConnect can send: "connected" on a successful link, "declined" -// when the user canceled or Google returned an error on its consent screen, -// "missingScopes" when Google granted a subset of scopes that leaves out -// calendar access (the box was unchecked), "error" for everything else -// (expired OAuth state, a failed code exchange, a failed link). Anything else -// in the URL (a stray/foreign value, or the params simply absent) is not a -// connect redirect at all. -export type GoogleConnectStatus = - | "connected" - | "declined" - | "missingScopes" - | "error"; +export type GoogleConnectStatus = ConnectStatus; -const CONNECT_DECLINED_TOAST_ID = "google-connect-declined"; -const CONNECT_SUCCESS_TOAST_ID = "google-connect-success"; -const CONNECT_MISSING_SCOPES_TOAST_ID = "google-connect-missing-scopes"; - -const STATUS_VALUES: readonly GoogleConnectStatus[] = [ - "connected", - "declined", - "missingScopes", - "error", -]; - -// Read the post-connect redirect params BEFORE the router mounts: the root -// route's search validation strips unrecognized params (including these) on -// the first navigation, so this has to run at bootstrap or the signal is -// gone. export function readGoogleConnectStatus( search = window.location.search, ): GoogleConnectStatus | null { - const params = new URLSearchParams(search); - if (params.get("provider") !== "google") return null; - const status = params.get("status"); - return (STATUS_VALUES as readonly string[]).includes(status ?? "") - ? (status as GoogleConnectStatus) - : null; + const redirect = readConnectStatus(search); + return redirect?.provider === "google" ? redirect.status : null; } -// Every non-success outcome of the add-account/reconnect OAuth round-trip -// used to be a silent dead end: the user cancels on Google's consent screen, -// or the OAuth state expires, or linking fails, and lands back on an -// unchanged calendar with no feedback at all - the button they clicked looks -// like it did nothing. Called once at bootstrap, immediately after -// `root.render` - deferred a frame (like showDbInitErrorToast) so the -// ToastContainer has mounted before the toast fires. export function showGoogleConnectStatusToast( status: GoogleConnectStatus, ): void { - requestAnimationFrame(() => { - requestAnimationFrame(() => fireGoogleConnectStatusToast(status)); - }); + showConnectStatusToast({ provider: "google", status }); } -// A completed connect/reconnect may have widened what the connection can do -// (e.g. the optional contacts grant behind attendee suggestions), so force -// the freshest connection summaries into the store the moment the browser -// lands back — the new capability goes live in this page load, no manual -// reload. `force` chains onto any bootstrap fetch already in flight rather -// than racing it. Called once at bootstrap alongside the status toast. export function refreshUserMetadataAfterGoogleConnect( status: GoogleConnectStatus, ): void { - if (status !== "connected") return; - void refreshUserMetadata({ force: true }); -} - -function fireGoogleConnectStatusToast(status: GoogleConnectStatus): void { - const toast = getToast(); - switch (status) { - case "connected": - track("calendar_connected", { source: "connect_redirect" }); - toast.success("Google Calendar connected.", { - ...getToastDefaultOptions(), - toastId: CONNECT_SUCCESS_TOAST_ID, - }); - return; - case "declined": - toast.info( - "No problem - nothing was connected. You can add the account anytime from Settings.", - { ...getToastDefaultOptions(), toastId: CONNECT_DECLINED_TOAST_ID }, - ); - return; - case "missingScopes": - toast.error( - "Compass needs calendar permission to sync. Reconnect from Settings and leave the calendar box checked.", - { - ...getToastDefaultOptions(), - autoClose: false, - toastId: CONNECT_MISSING_SCOPES_TOAST_ID, - }, - ); - return; - case "error": - toast.error( - "We couldn't connect your Google account. Please try again from Settings.", - { - ...getToastDefaultOptions(), - autoClose: false, - toastId: GOOGLE_CONNECT_FAILED_TOAST_ID, - }, - ); - return; - } + refreshUserMetadataAfterConnect(status); } diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index 8669a6a24e..6df5968c4c 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts @@ -1,229 +1,11 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { type ConnectionBeginFeatures } from "@core/types/sync/connection.contracts"; -import { type ConnectionId } from "@core/types/sync/identity.contracts"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; -import { AuthApi } from "@web/api/auth.api"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; import { - noteGoogleSyncRefreshImproved, - refreshGoogleSync, - useGoogleSyncRefreshSnapshot, -} from "@web/auth/google/state/google.sync.refresh"; -import { - selectPrimaryGoogleSyncConnection, - useUserMetadataStore, -} from "@web/auth/state/user-metadata.store"; -import { - GOOGLE_CONNECT_FAILED_TOAST_ID, - GOOGLE_REFRESH_ALREADY_IN_FLIGHT_TOAST_ID, - GOOGLE_REFRESH_FAILED_TOAST_ID, -} from "@web/common/constants/toast.constants"; -import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; -import { getToast } from "@web/common/utils/toast/toast.port"; -import { eventQueryKeys } from "@web/events/queries/event.query.keys"; -import { settingsActions } from "@web/settings/settings.store"; -import { useIsConnectGoogleAvailable } from "../useIsGoogleAvailable/useIsGoogleAvailable"; -import { type UseConnectGoogleResult } from "./useConnectGoogle.types"; -import { - connectionHasReconnectRequired, - getGoogleConnectionConfig, -} from "./useConnectGoogle.util"; -import { useGoogleUiState } from "./useGoogleUiState"; + type UseConnectGoogleOptions, + type UseConnectGoogleResult, +} from "./useConnectGoogle.types"; -export interface UseConnectGoogleOptions { - /** - * Scope the hook to one connected account: its own state drives the action - * and status, and reconnect rebinds consent to that connection rather than - * the precedence-winning one. Omit for the aggregate (whole-user) view. - */ - connection?: GoogleSyncConnectionSummary | null; - /** - * Always start a new-account OAuth round-trip (`{}`), even when some other - * account is `RECONNECT_REQUIRED`. Settings "Add account" must never bind - * to a reconnect. - */ - newAccount?: boolean; - /** - * Optional feature groups to add to the consent request (e.g. - * `["contacts"]` for attendee suggestions). Each maps to OPTIONAL scopes - * the user may decline while the connect flow still completes; omitted, - * the begin request stays byte-identical to before features existed. - */ - features?: ConnectionBeginFeatures; -} +export type { UseConnectGoogleOptions, UseConnectGoogleResult }; export const useConnectGoogle = ( options?: UseConnectGoogleOptions, -): UseConnectGoogleResult => { - const isAvailable = useIsConnectGoogleAvailable(); - const aggregateState = useGoogleUiState(); - const primaryConnection = useUserMetadataStore( - selectPrimaryGoogleSyncConnection, - ); - const scopedConnection = options?.connection; - const syncConnection = scopedConnection ?? primaryConnection; - // Scope to this connection only — do not inherit a sibling account's - // aggregate RECONNECT_REQUIRED into a still-healthy account. - const state = - scopedConnection != null && connectionHasReconnectRequired(scopedConnection) - ? "RECONNECT_REQUIRED" - : (scopedConnection?.connectionState ?? aggregateState); - const queryClient = useQueryClient(); - const [isConnecting, setIsConnecting] = useState(false); - // Sync guard so rapid re-clicks before React re-renders cannot start a - // second OAuth attempt; isConnecting alone would still be false in-handler. - const isConnectingRef = useRef(false); - const refreshSnapshot = useGoogleSyncRefreshSnapshot(); - const isRefreshing = refreshSnapshot.isRefreshing; - const stopConnecting = useCallback(() => { - isConnectingRef.current = false; - setIsConnecting(false); - }, []); - - // OAuth uses a full navigation. If the user backs out and the page is - // restored from bfcache, React state is frozen mid-connecting and would - // otherwise leave the sidebar button disabled forever. - useEffect(() => { - const onPageShow = (event: PageTransitionEvent) => { - if (event.persisted) { - stopConnecting(); - } - }; - window.addEventListener("pageshow", onPageShow); - return () => window.removeEventListener("pageshow", onPageShow); - }, [stopConnecting]); - - // Clear the Refresh catch-up wait once Sync leaves the delayed band that - // showed the CTA (SSE syncStatusChanged already refetches metadata). - useEffect(() => { - if (!refreshSnapshot.refreshRequestedAt && !refreshSnapshot.gaveUp) { - return; - } - const connectionState = syncConnection?.state; - if ( - connectionState && - connectionState !== "delayed" && - state !== "ATTENTION" - ) { - noteGoogleSyncRefreshImproved(); - } - }, [ - refreshSnapshot.gaveUp, - refreshSnapshot.refreshRequestedAt, - state, - syncConnection?.state, - ]); - - const onOpenGoogleAuth = useCallback(() => { - if (isConnectingRef.current) { - return; - } - - // Show loading on the sidebar/command action immediately — - // beginGoogleConnection runs before the OAuth redirect. Any local events - // still in IndexedDB stay there across the redirect and flush once the - // callback lands and a Google calendar exists to target (see - // useCompleteAuthentication) — flushing here would have no Google - // calendar yet and land events on the local calendar instead. - isConnectingRef.current = true; - setIsConnecting(true); - - const start = async () => { - settingsActions.closeCmdPalette(); - - // 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. Reconnect binds the flow to the primary connection id - // from metadata so the wrong account cannot spawn a second. - try { - const beginRequest = { - ...(options?.newAccount || - !(state === "RECONNECT_REQUIRED" && syncConnection?.id) - ? {} - : { connectionId: syncConnection.id as ConnectionId }), - // Optional feature scopes ride along on connect AND reconnect; - // absent, the request body is byte-identical to before. - ...(options?.features !== undefined - ? { features: options.features } - : {}), - }; - const { authorizationUrl } = - await AuthApi.beginGoogleConnection(beginRequest); - window.location.assign(authorizationUrl); - } catch { - stopConnecting(); - showErrorToast( - "We couldn't start connecting your Google Calendar. Please try again.", - { toastId: GOOGLE_CONNECT_FAILED_TOAST_ID }, - ); - } - }; - - void start(); - }, [ - options?.features, - options?.newAccount, - state, - stopConnecting, - syncConnection?.id, - ]); - - const onRefreshGoogle = useCallback( - (options?: { silent?: boolean }) => { - if (isConnectingRef.current || refreshSnapshot.isRefreshing) { - return; - } - - if (!options?.silent) { - settingsActions.closeCmdPalette(); - } - - void refreshGoogleSync() - .then((result) => { - void queryClient.invalidateQueries({ queryKey: eventQueryKeys.all }); - if ( - !options?.silent && - result.inFlight > 0 && - result.enqueued === 0 - ) { - getToast().info("Already refreshing your calendars", { - toastId: GOOGLE_REFRESH_ALREADY_IN_FLIGHT_TOAST_ID, - }); - } - }) - .catch(() => { - // A background-triggered refresh (tab focus) failing transiently - // isn't worth interrupting the user for — only a refresh they - // explicitly asked for surfaces the failure. - if (!options?.silent) { - showErrorToast( - "We couldn't refresh your calendar. Please try again in a moment.", - { toastId: GOOGLE_REFRESH_FAILED_TOAST_ID }, - ); - } - }); - }, - [queryClient, refreshSnapshot.isRefreshing], - ); - - return { - ...getGoogleConnectionConfig( - state, - { - onConnectGoogle: onOpenGoogleAuth, - onRefreshGoogle, - }, - { - refreshGaveUp: refreshSnapshot.gaveUp, - }, - ), - connect: onOpenGoogleAuth, - connection: syncConnection, - refresh: onRefreshGoogle, - isAvailable, - isConnecting, - isRefreshing, - state, - }; -}; +): UseConnectGoogleResult => useConnectProvider("google", options); diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts index 6e6ca37e46..2b0abacfea 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.types.ts @@ -1,4 +1,5 @@ import { type Icon } from "@phosphor-icons/react"; +import { type ConnectionBeginFeatures } from "@core/types/sync/connection.contracts"; import { type GoogleConnectionState, type GoogleSyncConnectionSummary, @@ -16,6 +17,28 @@ export type GoogleUiConfig = { } | null; }; +export type UseConnectGoogleOptions = { + /** + * Scope the hook to one connected account: its own state drives the action + * and status, and reconnect rebinds consent to that connection rather than + * the precedence-winning one. Omit for the aggregate (whole-user) view. + */ + connection?: GoogleSyncConnectionSummary | null; + /** + * Always start a new-account OAuth round-trip (`{}`), even when some other + * account is `RECONNECT_REQUIRED`. Settings "Add account" must never bind + * to a reconnect. + */ + newAccount?: boolean; + /** + * Optional feature groups to add to the consent request (e.g. + * `["contacts"]` for attendee suggestions). Each maps to OPTIONAL scopes + * the user may decline while the connect flow still completes; omitted, + * the begin request stays byte-identical to before features existed. + */ + features?: ConnectionBeginFeatures; +}; + export type UseConnectGoogleResult = GoogleUiConfig & { /** The scoped connection when passed in, else the aggregate's primary. */ connection: GoogleSyncConnectionSummary | null; 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 c8d9b55820..c012b4ae06 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.factory.ts @@ -1,6 +1,4 @@ -import { useEffect, useSyncExternalStore } from "react"; - -type BackendGoogleAvailability = "available" | "unavailable" | "unknown"; +import { createProviderAvailability } from "@web/auth/providers/provider-availability.factory"; type AppConfigResponse = { google?: { isConfigured?: boolean }; @@ -15,108 +13,16 @@ export function createGoogleAvailability({ getConfig, isGoogleAuthConfigured, }: GoogleAvailabilityDependencies) { - const listeners = new Set<() => void>(); - let backendGoogleAvailability: BackendGoogleAvailability = "unknown"; - let loadPromise: Promise | undefined; - - const emit = () => { - for (const listener of listeners) { - listener(); - } - }; - - const setBackendGoogleAvailability = ( - availability: BackendGoogleAvailability, - ) => { - backendGoogleAvailability = availability; - emit(); - }; - - const subscribe = (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; - }; - - const getBackendGoogleAvailabilitySnapshot = (): boolean => - backendGoogleAvailability === "available"; - - const loadBackendGoogleAvailability = async (): Promise => { - // Always fetch /config, even without a baked GOOGLE_CLIENT_ID: the sync - // redirect connect flow needs no client-side id at all, so bailing out - // here would leave connect permanently unavailable for exactly the - // deployments that need it (e.g. a self-host web image that hasn't - // rebuilt with its own client id). Sign-in availability still requires - // the baked id — see useIsGoogleAvailable below. - if (!loadPromise) { - loadPromise = getConfig() - .then((config) => { - setBackendGoogleAvailability( - config.google?.isConfigured ? "available" : "unavailable", - ); - }) - .catch(() => { - loadPromise = undefined; - setBackendGoogleAvailability("unavailable"); - }); - } - - return loadPromise; - }; - - const useIsGoogleAvailable = (): boolean => { - const isBackendGoogleConfigured = useSyncExternalStore( - subscribe, - getBackendGoogleAvailabilitySnapshot, - getBackendGoogleAvailabilitySnapshot, - ); - - useEffect(() => { - void loadBackendGoogleAvailability(); - }, []); - - return isGoogleAuthConfigured && isBackendGoogleConfigured; - }; - - // Connect-calendar availability, distinct from sign-in (useIsGoogleAvailable - // above): the sync redirect flow never runs client-side code exchange, so - // it needs no baked GOOGLE_CLIENT_ID — only that the backend has Google - // configured at all. - const useIsConnectGoogleAvailable = (): boolean => { - const isBackendGoogleConfigured = useSyncExternalStore( - subscribe, - getBackendGoogleAvailabilitySnapshot, - getBackendGoogleAvailabilitySnapshot, - ); - - useEffect(() => { - void loadBackendGoogleAvailability(); - }, []); - - return isBackendGoogleConfigured; - }; - - const resetGoogleAvailabilityForTests = () => { - backendGoogleAvailability = "unknown"; - loadPromise = undefined; - emit(); - }; - - /** Pins availability for tests and skips the config fetch. */ - const setGoogleAvailabilityForTests = ( - availability: BackendGoogleAvailability, - ) => { - backendGoogleAvailability = availability; - loadPromise = Promise.resolve(); - emit(); - }; + const availability = createProviderAvailability({ + getConfig, + isGoogleAuthConfigured, + }); return { - resetGoogleAvailabilityForTests, - setGoogleAvailabilityForTests, - useIsGoogleAvailable, - useIsConnectGoogleAvailable, + resetGoogleAvailabilityForTests: + availability.resetGoogleAvailabilityForTests, + setGoogleAvailabilityForTests: availability.setGoogleAvailabilityForTests, + useIsGoogleAvailable: availability.useIsGoogleAvailable, + useIsConnectGoogleAvailable: availability.useIsConnectGoogleAvailable, }; } diff --git a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts index 1397e2bfbc..85f4c34a98 100644 --- a/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts +++ b/packages/web/src/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable.ts @@ -1,15 +1,8 @@ -import { AppConfigApi } from "@web/api/app-config.api"; -import { IS_GOOGLE_AUTH_CONFIGURED } from "@web/auth/google/google-auth-config"; -import { createGoogleAvailability } from "./useIsGoogleAvailable.factory"; - -const googleAvailability = createGoogleAvailability({ - getConfig: AppConfigApi.get, - isGoogleAuthConfigured: IS_GOOGLE_AUTH_CONFIGURED, -}); +import { providerAvailability } from "@web/auth/providers/provider-availability.instance"; export const { resetGoogleAvailabilityForTests, setGoogleAvailabilityForTests, useIsGoogleAvailable, useIsConnectGoogleAvailable, -} = googleAvailability; +} = providerAvailability; diff --git a/packages/web/src/auth/providers/connect-status.util.test.ts b/packages/web/src/auth/providers/connect-status.util.test.ts new file mode 100644 index 0000000000..b71cfbc666 --- /dev/null +++ b/packages/web/src/auth/providers/connect-status.util.test.ts @@ -0,0 +1,102 @@ +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 { + readConnectStatus, + refreshUserMetadataAfterConnect, + showConnectStatusToast, +} from "./connect-status.util"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; + +describe("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(); + }); + + 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("readConnectStatus", () => { + it("reads google and microsoft redirects", () => { + expect(readConnectStatus("?provider=google&status=connected")).toEqual({ + provider: "google", + status: "connected", + }); + expect(readConnectStatus("?provider=microsoft&status=connected")).toEqual( + { + provider: "microsoft", + status: "connected", + }, + ); + }); + + it("returns null when provider or status is missing", () => { + expect(readConnectStatus("?status=connected")).toBeNull(); + expect( + readConnectStatus("?provider=outlook&status=connected"), + ).toBeNull(); + expect(readConnectStatus("?provider=google&status=pending")).toBeNull(); + expect(readConnectStatus("")).toBeNull(); + }); + }); + + describe("showConnectStatusToast", () => { + it("keeps the Google connected toast unchanged", () => { + showConnectStatusToast({ provider: "google", status: "connected" }); + runToastAfterPaint(); + expect(mocks.success).toHaveBeenCalledWith( + "Google Calendar connected.", + expect.objectContaining({ toastId: "google-connect-success" }), + ); + }); + + it("shows a Microsoft connected toast", () => { + showConnectStatusToast({ provider: "microsoft", status: "connected" }); + runToastAfterPaint(); + expect(mocks.success).toHaveBeenCalledWith( + "Microsoft connected.", + expect.objectContaining({ toastId: "connect-success" }), + ); + }); + }); + + describe("refreshUserMetadataAfterConnect", () => { + it("force-refreshes metadata after a completed connect", () => { + const refreshSpy = spyOn( + userMetadataUtil, + "refreshUserMetadata", + ).mockResolvedValue(undefined); + + refreshUserMetadataAfterConnect("connected"); + expect(refreshSpy).toHaveBeenCalledWith({ force: true }); + + refreshSpy.mockRestore(); + }); + }); +}); diff --git a/packages/web/src/auth/providers/connect-status.util.ts b/packages/web/src/auth/providers/connect-status.util.ts new file mode 100644 index 0000000000..03c0b14945 --- /dev/null +++ b/packages/web/src/auth/providers/connect-status.util.ts @@ -0,0 +1,124 @@ +import { + type ProviderKind, + ProviderKindSchema, + providerDisplayName, +} from "@core/types/sync/identity.contracts"; +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; +import { track } from "@web/auth/posthog/track"; +import { + GOOGLE_CONNECT_FAILED_TOAST_ID, + getToastDefaultOptions, +} from "@web/common/constants/toast.constants"; +import { getToast } from "@web/common/utils/toast/toast.port"; + +export type ConnectStatus = + | "connected" + | "declined" + | "missingScopes" + | "error"; + +export type ConnectRedirect = { + provider: ProviderKind; + status: ConnectStatus; +}; + +const STATUS_VALUES: readonly ConnectStatus[] = [ + "connected", + "declined", + "missingScopes", + "error", +]; + +const SUCCESS_TOAST_ID: Record = { + google: "google-connect-success", + microsoft: "connect-success", + apple: "connect-success", +}; + +const DECLINED_TOAST_ID: Record = { + google: "google-connect-declined", + microsoft: "connect-declined", + apple: "connect-declined", +}; + +const MISSING_SCOPES_TOAST_ID: Record = { + google: "google-connect-missing-scopes", + microsoft: "connect-missing-scopes", + apple: "connect-missing-scopes", +}; + +const CONNECTED_COPY: Record = { + google: "Google Calendar connected.", + microsoft: "Microsoft connected.", + apple: "Apple connected.", +}; + +export function readConnectStatus( + search = window.location.search, +): ConnectRedirect | null { + const params = new URLSearchParams(search); + const providerResult = ProviderKindSchema.safeParse(params.get("provider")); + if (!providerResult.success) return null; + const status = params.get("status"); + if (!(STATUS_VALUES as readonly string[]).includes(status ?? "")) return null; + return { + provider: providerResult.data, + status: status as ConnectStatus, + }; +} + +export function showConnectStatusToast(redirect: ConnectRedirect): void { + requestAnimationFrame(() => { + requestAnimationFrame(() => fireConnectStatusToast(redirect)); + }); +} + +export function refreshUserMetadataAfterConnect(status: ConnectStatus): void { + if (status !== "connected") return; + void refreshUserMetadata({ force: true }); +} + +function connectedCopy(provider: ProviderKind): string { + return CONNECTED_COPY[provider]; +} + +function errorCopy(provider: ProviderKind): string { + const name = providerDisplayName(provider); + return `We couldn't connect your ${name} account. Please try again from Settings.`; +} + +function fireConnectStatusToast({ provider, status }: ConnectRedirect): void { + const toast = getToast(); + switch (status) { + case "connected": + track("calendar_connected", { source: "connect_redirect" }); + toast.success(connectedCopy(provider), { + ...getToastDefaultOptions(), + toastId: SUCCESS_TOAST_ID[provider], + }); + return; + case "declined": + toast.info( + "No problem - nothing was connected. You can add the account anytime from Settings.", + { ...getToastDefaultOptions(), toastId: DECLINED_TOAST_ID[provider] }, + ); + return; + case "missingScopes": + toast.error( + "Compass needs calendar permission to sync. Reconnect from Settings and leave the calendar box checked.", + { + ...getToastDefaultOptions(), + autoClose: false, + toastId: MISSING_SCOPES_TOAST_ID[provider], + }, + ); + return; + case "error": + toast.error(errorCopy(provider), { + ...getToastDefaultOptions(), + autoClose: false, + toastId: GOOGLE_CONNECT_FAILED_TOAST_ID, + }); + return; + } +} diff --git a/packages/web/src/auth/providers/provider-availability.factory.ts b/packages/web/src/auth/providers/provider-availability.factory.ts new file mode 100644 index 0000000000..073704b791 --- /dev/null +++ b/packages/web/src/auth/providers/provider-availability.factory.ts @@ -0,0 +1,175 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; + +export type BackendProviderAvailability = + | "available" + | "unavailable" + | "unknown"; + +export type ProviderAvailabilityMode = "signIn" | "connect"; + +type ProviderFlags = { signIn: boolean; connect: boolean }; + +type AppConfigResponse = { + google?: { isConfigured?: boolean }; + providers?: Partial< + Record + >; +}; + +type ProviderAvailabilityDependencies = { + getConfig: () => Promise; + isGoogleAuthConfigured: boolean; +}; + +const unavailableFlags: ProviderFlags = { signIn: false, connect: false }; + +const flagsFromConfig = ( + config: AppConfigResponse, +): Record => { + const googleConfigured = Boolean(config.google?.isConfigured); + const google = config.providers?.google; + const microsoft = config.providers?.microsoft; + const apple = config.providers?.apple; + return { + google: { + signIn: google?.signIn ?? googleConfigured, + connect: google?.connect ?? googleConfigured, + }, + microsoft: { + signIn: microsoft?.signIn ?? false, + connect: microsoft?.connect ?? false, + }, + apple: { + signIn: apple?.signIn ?? false, + connect: apple?.connect ?? false, + }, + }; +}; + +export function createProviderAvailability({ + getConfig, + isGoogleAuthConfigured, +}: ProviderAvailabilityDependencies) { + const listeners = new Set<() => void>(); + let flags: Record = { + google: unavailableFlags, + microsoft: unavailableFlags, + apple: unavailableFlags, + }; + let loadPromise: Promise | undefined; + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const snapshotFor = ( + kind: ProviderKind, + mode: ProviderAvailabilityMode, + ): boolean => { + const ready = flags[kind][mode]; + if (kind === "google" && mode === "signIn") { + return isGoogleAuthConfigured && ready; + } + return ready; + }; + + const load = async (): Promise => { + if (!loadPromise) { + loadPromise = getConfig() + .then((config) => { + flags = flagsFromConfig(config); + emit(); + }) + .catch(() => { + loadPromise = undefined; + flags = { + google: unavailableFlags, + microsoft: unavailableFlags, + apple: unavailableFlags, + }; + emit(); + }); + } + return loadPromise; + }; + + const useIsProviderAvailable = ( + kind: ProviderKind, + mode: ProviderAvailabilityMode, + ): boolean => { + const available = useSyncExternalStore( + subscribe, + () => snapshotFor(kind, mode), + () => snapshotFor(kind, mode), + ); + + useEffect(() => { + void load(); + }, []); + + return available; + }; + + const useIsGoogleAvailable = (): boolean => + useIsProviderAvailable("google", "signIn"); + + const useIsConnectGoogleAvailable = (): boolean => + useIsProviderAvailable("google", "connect"); + + const resetGoogleAvailabilityForTests = () => { + flags = { + google: unavailableFlags, + microsoft: unavailableFlags, + apple: unavailableFlags, + }; + loadPromise = undefined; + emit(); + }; + + const setGoogleAvailabilityForTests = ( + availability: BackendProviderAvailability, + ) => { + const ready = availability === "available"; + flags = { + ...flags, + google: { signIn: ready, connect: ready }, + }; + loadPromise = Promise.resolve(); + emit(); + }; + + const setProviderAvailabilityForTests = ( + kind: ProviderKind, + availability: BackendProviderAvailability, + ) => { + const ready = availability === "available"; + flags = { + ...flags, + [kind]: { signIn: ready, connect: ready }, + }; + loadPromise = Promise.resolve(); + emit(); + }; + + const resetProviderAvailabilityForTests = resetGoogleAvailabilityForTests; + + return { + resetGoogleAvailabilityForTests, + resetProviderAvailabilityForTests, + setGoogleAvailabilityForTests, + setProviderAvailabilityForTests, + useIsGoogleAvailable, + useIsConnectGoogleAvailable, + useIsProviderAvailable, + }; +} diff --git a/packages/web/src/auth/providers/provider-availability.instance.ts b/packages/web/src/auth/providers/provider-availability.instance.ts new file mode 100644 index 0000000000..f7c04f1438 --- /dev/null +++ b/packages/web/src/auth/providers/provider-availability.instance.ts @@ -0,0 +1,8 @@ +import { AppConfigApi } from "@web/api/app-config.api"; +import { IS_GOOGLE_AUTH_CONFIGURED } from "@web/auth/google/google-auth-config"; +import { createProviderAvailability } from "@web/auth/providers/provider-availability.factory"; + +export const providerAvailability = createProviderAvailability({ + getConfig: AppConfigApi.get, + isGoogleAuthConfigured: IS_GOOGLE_AUTH_CONFIGURED, +}); diff --git a/packages/web/src/auth/providers/useConnectProvider.test.tsx b/packages/web/src/auth/providers/useConnectProvider.test.tsx new file mode 100644 index 0000000000..0ce60e9324 --- /dev/null +++ b/packages/web/src/auth/providers/useConnectProvider.test.tsx @@ -0,0 +1,111 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { ConnectionIdSchema } from "@core/types/sync/identity.contracts"; +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 { useConnectProvider } from "./useConnectProvider"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; + +const connection = ( + overrides: Partial, +): GoogleSyncConnectionSummary => ({ + id: "connection-primary", + state: "actionRequired", + stateReason: "authorizationRevoked", + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "primary@example.com", + connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, + ...overrides, +}); + +describe("useConnectProvider", () => { + beforeEach(() => { + userMetadataActions.set({ + google: { + connectionState: "RECONNECT_REQUIRED", + connections: [connection({})], + }, + }); + }); + + afterEach(() => { + cleanup(); + userMetadataActions.clear(); + }); + + it("navigates on a redirect begin response", async () => { + const assign = spyOn(window.location, "assign").mockImplementation( + () => {}, + ); + const beginSpy = spyOn(AuthApi, "beginConnection").mockResolvedValue({ + kind: "redirect", + authorizationUrl: "#consent", + }); + + const { wrapper } = createStoreWrapper(); + const { result } = renderHook( + () => useConnectProvider("google", { newAccount: true }), + { wrapper }, + ); + act(() => result.current.connect()); + + await waitFor(() => { + expect(assign).toHaveBeenCalledWith("#consent"); + }); + + beginSpy.mockRestore(); + assign.mockRestore(); + }); + + it("does not navigate on a connected begin response", async () => { + const assign = spyOn(window.location, "assign").mockImplementation( + () => {}, + ); + const beginSpy = spyOn(AuthApi, "beginConnection").mockResolvedValue({ + kind: "connected", + connectionId: ConnectionIdSchema.parse("64b7f9c2e1a2b3c4d5e6f7a8"), + } as Awaited>); + + const { wrapper } = createStoreWrapper(); + const { result } = renderHook( + () => useConnectProvider("apple", { newAccount: true }), + { wrapper }, + ); + act(() => result.current.connect()); + + await waitFor(() => { + expect(beginSpy).toHaveBeenCalled(); + }); + expect(assign).not.toHaveBeenCalled(); + + beginSpy.mockRestore(); + assign.mockRestore(); + }); + + it("binds reconnect to the scoped account's connection id", async () => { + const beginSpy = spyOn(AuthApi, "beginGoogleConnection").mockResolvedValue({ + authorizationUrl: "#consent", + }); + + const { wrapper } = createStoreWrapper(); + const { result } = renderHook( + () => + useConnectProvider("google", { + connection: connection({ id: "connection-second" }), + }), + { wrapper }, + ); + act(() => result.current.connect()); + + await waitFor(() => { + expect(beginSpy).toHaveBeenCalledWith({ + connectionId: "connection-second", + }); + }); + + beginSpy.mockRestore(); + }); +}); diff --git a/packages/web/src/auth/providers/useConnectProvider.ts b/packages/web/src/auth/providers/useConnectProvider.ts new file mode 100644 index 0000000000..c9091d5f6b --- /dev/null +++ b/packages/web/src/auth/providers/useConnectProvider.ts @@ -0,0 +1,195 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ConnectionId, + type ProviderKind, + providerDisplayName, +} from "@core/types/sync/identity.contracts"; +import { AuthApi } from "@web/api/auth.api"; +import { + type UseConnectGoogleOptions, + type UseConnectGoogleResult, +} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; +import { + connectionHasReconnectRequired, + getGoogleConnectionConfig, +} from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { useGoogleUiState } from "@web/auth/google/hooks/useConnectGoogle/useGoogleUiState"; +import { + noteGoogleSyncRefreshImproved, + refreshGoogleSync, + useGoogleSyncRefreshSnapshot, +} from "@web/auth/google/state/google.sync.refresh"; +import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; +import { + selectPrimaryGoogleSyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; +import { + GOOGLE_CONNECT_FAILED_TOAST_ID, + GOOGLE_REFRESH_ALREADY_IN_FLIGHT_TOAST_ID, + GOOGLE_REFRESH_FAILED_TOAST_ID, +} from "@web/common/constants/toast.constants"; +import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; +import { getToast } from "@web/common/utils/toast/toast.port"; +import { eventQueryKeys } from "@web/events/queries/event.query.keys"; +import { settingsActions } from "@web/settings/settings.store"; + +export type UseConnectProviderOptions = UseConnectGoogleOptions; + +export const useConnectProvider = ( + kind: ProviderKind, + options?: UseConnectProviderOptions, +): UseConnectGoogleResult => { + const isAvailable = useIsProviderAvailable(kind, "connect"); + const aggregateState = useGoogleUiState(); + const primaryConnection = useUserMetadataStore( + selectPrimaryGoogleSyncConnection, + ); + const scopedConnection = options?.connection; + const syncConnection = scopedConnection ?? primaryConnection; + const state = + scopedConnection != null && connectionHasReconnectRequired(scopedConnection) + ? "RECONNECT_REQUIRED" + : (scopedConnection?.connectionState ?? aggregateState); + const queryClient = useQueryClient(); + const [isConnecting, setIsConnecting] = useState(false); + const isConnectingRef = useRef(false); + const refreshSnapshot = useGoogleSyncRefreshSnapshot(); + const isRefreshing = refreshSnapshot.isRefreshing; + const stopConnecting = useCallback(() => { + isConnectingRef.current = false; + setIsConnecting(false); + }, []); + + useEffect(() => { + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + stopConnecting(); + } + }; + window.addEventListener("pageshow", onPageShow); + return () => window.removeEventListener("pageshow", onPageShow); + }, [stopConnecting]); + + useEffect(() => { + if (!refreshSnapshot.refreshRequestedAt && !refreshSnapshot.gaveUp) { + return; + } + const connectionState = syncConnection?.state; + if ( + connectionState && + connectionState !== "delayed" && + state !== "ATTENTION" + ) { + noteGoogleSyncRefreshImproved(); + } + }, [ + refreshSnapshot.gaveUp, + refreshSnapshot.refreshRequestedAt, + state, + syncConnection?.state, + ]); + + const onOpenAuth = useCallback(() => { + if (isConnectingRef.current) { + return; + } + + isConnectingRef.current = true; + setIsConnecting(true); + + const start = async () => { + settingsActions.closeCmdPalette(); + + try { + const beginRequest = { + ...(options?.newAccount || + !(state === "RECONNECT_REQUIRED" && syncConnection?.id) + ? {} + : { connectionId: syncConnection.id as ConnectionId }), + ...(options?.features !== undefined + ? { features: options.features } + : {}), + provider: kind, + }; + const result = await AuthApi.beginConnection(beginRequest); + if ("authorizationUrl" in result) { + window.location.assign(result.authorizationUrl); + return; + } + stopConnecting(); + } catch { + stopConnecting(); + showErrorToast( + `We couldn't start connecting your ${providerDisplayName(kind)} Calendar. Please try again.`, + { toastId: GOOGLE_CONNECT_FAILED_TOAST_ID }, + ); + } + }; + + void start(); + }, [ + kind, + options?.features, + options?.newAccount, + state, + stopConnecting, + syncConnection?.id, + ]); + + const onRefresh = useCallback( + (refreshOptions?: { silent?: boolean }) => { + if (isConnectingRef.current || refreshSnapshot.isRefreshing) { + return; + } + + if (!refreshOptions?.silent) { + settingsActions.closeCmdPalette(); + } + + void refreshGoogleSync() + .then((result) => { + void queryClient.invalidateQueries({ queryKey: eventQueryKeys.all }); + if ( + !refreshOptions?.silent && + result.inFlight > 0 && + result.enqueued === 0 + ) { + getToast().info("Already refreshing your calendars", { + toastId: GOOGLE_REFRESH_ALREADY_IN_FLIGHT_TOAST_ID, + }); + } + }) + .catch(() => { + if (!refreshOptions?.silent) { + showErrorToast( + "We couldn't refresh your calendar. Please try again in a moment.", + { toastId: GOOGLE_REFRESH_FAILED_TOAST_ID }, + ); + } + }); + }, + [queryClient, refreshSnapshot.isRefreshing], + ); + + return { + ...getGoogleConnectionConfig( + state, + { + onConnectGoogle: onOpenAuth, + onRefreshGoogle: onRefresh, + }, + { + refreshGaveUp: refreshSnapshot.gaveUp, + }, + ), + connect: onOpenAuth, + connection: syncConnection, + refresh: onRefresh, + isAvailable, + isConnecting, + isRefreshing, + state, + }; +}; diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx new file mode 100644 index 0000000000..39df8c1199 --- /dev/null +++ b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx @@ -0,0 +1,35 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { createProviderAvailability } from "./provider-availability.factory"; +import { describe, expect, it, mock } from "bun:test"; + +const getConfig = mock(); + +describe("useIsProviderAvailable", () => { + it("reads connect flags from providers on the same config fetch", 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, useIsProviderAvailable } = + createProviderAvailability({ + getConfig, + isGoogleAuthConfigured: true, + }); + resetProviderAvailabilityForTests(); + + const { result } = renderHook(() => + useIsProviderAvailable("microsoft", "connect"), + ); + + expect(result.current).toBe(false); + await waitFor(() => { + expect(result.current).toBe(true); + }); + expect(getConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.ts b/packages/web/src/auth/providers/useIsProviderAvailable.ts new file mode 100644 index 0000000000..01a66d1d5b --- /dev/null +++ b/packages/web/src/auth/providers/useIsProviderAvailable.ts @@ -0,0 +1,7 @@ +import { providerAvailability } from "@web/auth/providers/provider-availability.instance"; + +export const { + useIsProviderAvailable, + setProviderAvailabilityForTests, + resetProviderAvailabilityForTests, +} = providerAvailability; From 165dc709ead07fc1382e2c5a71e98d8d061ca7bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 01:22:54 +0000 Subject: [PATCH 5/6] feat(web): migrate settings, sidebar, banners and toasts to the provider layer Settings, calendar list, banners and reconnect toasts now read metadata.connections[] and name the connection's provider. Google copy stays byte-identical; Microsoft reconnect copy uses Outlook. Co-authored-by: Tyler Dane --- .agents/handoffs/3232.md | 29 +++++ .../auth/providers/ConnectProviderAction.tsx | 61 ++++++++++ .../auth/providers/ProviderConnectChooser.tsx | 58 +++++++++ .../provider-availability.factory.ts | 36 ++++++ .../auth/providers/provider-copy.util.test.ts | 104 ++++++++++++++++ .../src/auth/providers/provider-copy.util.ts | 113 ++++++++++++++++++ .../providers/useConnectProvider.test.tsx | 1 + .../src/auth/providers/useConnectProvider.ts | 43 ++++--- .../providers/useIsProviderAvailable.test.tsx | 25 ++++ .../auth/providers/useIsProviderAvailable.ts | 1 + .../web/src/auth/state/user-metadata.store.ts | 61 ++++++++-- packages/web/src/calendars/calendar.util.ts | 6 +- .../utils/toast/google-delayed.toast.test.tsx | 8 +- .../utils/toast/google-delayed.toast.tsx | 14 ++- .../toast/google-reconnect.toast.test.tsx | 42 ++++--- .../utils/toast/google-reconnect.toast.tsx | 45 ++++--- .../CalendarConnectionBanner.test.tsx | 15 ++- .../CalendarConnectionBanner.tsx | 11 +- .../CalendarConnectionBannerGate.tsx | 22 +++- .../PointerHint/PointerHint.test.tsx | 32 +++++ .../components/PointerHint/PointerHint.tsx | 17 ++- .../Settings/SettingsModal.test.tsx | 64 +++++++++- .../src/components/Settings/SettingsModal.tsx | 67 +++++------ .../AccountSectionHeader.test.tsx | 47 ++++++-- .../CalendarList/AccountSectionHeader.tsx | 4 +- .../CalendarList/CalendarList.test.tsx | 40 +++++-- .../Sidebar/CalendarList/CalendarList.tsx | 13 +- .../CalendarList/CalendarListHeader.test.tsx | 71 ++++++++--- .../CalendarList/CalendarListHeader.tsx | 12 +- .../CalendarList/useAccountHeaderStatus.ts | 9 +- .../Sidebar/SidebarStatusBar.test.tsx | 12 +- .../components/Sidebar/SidebarStatusBar.tsx | 13 +- 32 files changed, 932 insertions(+), 164 deletions(-) create mode 100644 .agents/handoffs/3232.md create mode 100644 packages/web/src/auth/providers/ConnectProviderAction.tsx create mode 100644 packages/web/src/auth/providers/ProviderConnectChooser.tsx create mode 100644 packages/web/src/auth/providers/provider-copy.util.test.ts create mode 100644 packages/web/src/auth/providers/provider-copy.util.ts diff --git a/.agents/handoffs/3232.md b/.agents/handoffs/3232.md new file mode 100644 index 0000000000..7720da6070 --- /dev/null +++ b/.agents/handoffs/3232.md @@ -0,0 +1,29 @@ +--- +schema_version: 1 +task_id: "3232" +from: Implementer +to: GitHub +owner: GitHub +status: implementing +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 test:web (focused, in progress) + result: pending +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: "PR #3380 (WP-08a) to merge before this PR targets a clean main" +escalation: null +--- + +P0 WP-08b: migrate Settings, sidebar, banners and toasts to the provider layer. 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..264af7f769 --- /dev/null +++ b/packages/web/src/auth/providers/ProviderConnectChooser.tsx @@ -0,0 +1,58 @@ +import { type FC } from "react"; +import { providerDisplayName } from "@core/types/sync/identity.contracts"; +import { ConnectProviderAction } from "@web/auth/providers/ConnectProviderAction"; +import { + 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"; +} + +export const ProviderConnectChooser: FC = ({ + showShortcuts = false, + variant, +}) => { + const connectable = useConnectableProviders(); + if (connectable.length === 0) return null; + + if (variant === "settings") { + const single = connectable.length === 1; + return ( + + {connectable.map((kind, index) => ( + + ))} + + ); + } + + return ( +
+ {connectable.map((kind) => ( + + ))} +
+ ); +}; diff --git a/packages/web/src/auth/providers/provider-availability.factory.ts b/packages/web/src/auth/providers/provider-availability.factory.ts index 073704b791..abf37aac7a 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,6 +140,20 @@ export function createProviderAvailability({ return available; }; + const useConnectableProviders = (): ProviderKind[] => { + const connectable = useSyncExternalStore( + subscribe, + connectableSnapshot, + connectableSnapshot, + ); + + useEffect(() => { + void load(); + }, []); + + return connectable; + }; + const useIsGoogleAvailable = (): boolean => useIsProviderAvailable("google", "signIn"); @@ -133,6 +167,7 @@ export function createProviderAvailability({ apple: unavailableFlags, }; loadPromise = undefined; + connectableCache = NO_CONNECTABLE; emit(); }; @@ -171,5 +206,6 @@ export function createProviderAvailability({ 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..419bc04ec0 --- /dev/null +++ b/packages/web/src/auth/providers/provider-copy.util.test.ts @@ -0,0 +1,104 @@ +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { + 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("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..f03a8c47bf --- /dev/null +++ b/packages/web/src/auth/providers/provider-copy.util.ts @@ -0,0 +1,113 @@ +import { + type ProviderKind, + providerDisplayName, +} from "@core/types/sync/identity.contracts"; +import { type SyncConnectionSummary } from "@core/types/user.types"; +import { type GoogleUiConfig } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.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."; +} + +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/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..8bf62f5686 100644 --- a/packages/web/src/auth/providers/useConnectProvider.ts +++ b/packages/web/src/auth/providers/useConnectProvider.ts @@ -20,9 +20,13 @@ import { refreshGoogleSync, useGoogleSyncRefreshSnapshot, } from "@web/auth/google/state/google.sync.refresh"; +import { + connectionProvider, + relabelConnectCommand, +} from "@web/auth/providers/provider-copy.util"; import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; 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/providers/useIsProviderAvailable.test.tsx b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx index 39df8c1199..a0ce272404 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx +++ b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx @@ -32,4 +32,29 @@ 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"]); + }); + }); }); diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.ts b/packages/web/src/auth/providers/useIsProviderAvailable.ts index 01a66d1d5b..bedef85e0b 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.ts +++ b/packages/web/src/auth/providers/useIsProviderAvailable.ts @@ -2,6 +2,7 @@ import { providerAvailability } from "@web/auth/providers/provider-availability. export const { useIsProviderAvailable, + useConnectableProviders, setProviderAvailabilityForTests, resetProviderAvailabilityForTests, } = providerAvailability; diff --git a/packages/web/src/auth/state/user-metadata.store.ts b/packages/web/src/auth/state/user-metadata.store.ts index df2a99e822..302810e5bf 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,6 +186,11 @@ 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, diff --git a/packages/web/src/calendars/calendar.util.ts b/packages/web/src/calendars/calendar.util.ts index fa6b3589fc..7e0113f4ee 100644 --- a/packages/web/src/calendars/calendar.util.ts +++ b/packages/web/src/calendars/calendar.util.ts @@ -1,5 +1,5 @@ import { type Calendar } from "@core/types/calendar.contracts"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { isCalendarReconnectRequired } from "@web/auth/google/state/google.reconnect.calendar"; export function getLocalCalendar(calendars: Calendar[]): Calendar | undefined { @@ -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[] = []; 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..1af9464896 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 ProviderKind } from "@core/types/sync/identity.contracts"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { type GoogleReconnectTarget } from "@web/auth/google/state/google.reconnect.state"; import { - selectGoogleSyncConnections, + connectionProvider, + RECONNECT_CALENDAR_LABEL, + reconnectToastBody, + reconnectToastTitle, +} from "@web/auth/providers/provider-copy.util"; +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/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..c9fe86ddfc 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 ProviderKind } from "@core/types/sync/identity.contracts"; import { type CalendarConnectionBannerKind } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.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..e5a1f3e790 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 { 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/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..5d8d31ed32 100644 --- a/packages/web/src/components/Settings/SettingsModal.test.tsx +++ b/packages/web/src/components/Settings/SettingsModal.test.tsx @@ -9,10 +9,12 @@ import { createStoreWrapper } from "@web/__tests__/render-with-store"; import { createMockCalendar } from "@web/__tests__/utils/factories/calendar.factory"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; import { AuthApi } from "@web/api/auth.api"; +import { setGoogleAvailabilityForTests } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; import { markAccountReconnectRequired, resetGoogleReconnectRequiredForTests, } from "@web/auth/google/state/google.reconnect.state"; +import { 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 +165,7 @@ const renderSettings = ({ } = {}) => { authenticated = isAuthenticated; userMetadataActions.set({ + connections, google: { connectionState: "HEALTHY", connections }, }); const { queryClient, wrapper } = createStoreWrapper(); @@ -511,7 +514,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 +1056,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..96add47084 100644 --- a/packages/web/src/components/Settings/SettingsModal.tsx +++ b/packages/web/src/components/Settings/SettingsModal.tsx @@ -1,9 +1,8 @@ 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, @@ -12,8 +11,14 @@ import { } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; import { useDisconnectGoogleAccount } from "@web/auth/google/hooks/useDisconnectGoogleAccount"; import { useGoogleSyncRefreshSnapshot } from "@web/auth/google/state/google.sync.refresh"; +import { ProviderConnectChooser } from "@web/auth/providers/ProviderConnectChooser"; import { - selectGoogleSyncConnections, + connectionProvider, + defaultCalendarGroupLabel, +} from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +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) => (