diff --git a/.changeset/eight-cameras-fix.md b/.changeset/eight-cameras-fix.md new file mode 100644 index 0000000..9c57e10 --- /dev/null +++ b/.changeset/eight-cameras-fix.md @@ -0,0 +1,3 @@ +"@syncdown/cli": patch + +Improve the TUI Google enable flow so Gmail and Google Calendar reconnect automatically when stored permissions are outdated or the saved Google setup is incomplete. diff --git a/packages/tui/src/app.test.ts b/packages/tui/src/app.test.ts index 205cf8c..3a9410d 100644 --- a/packages/tui/src/app.test.ts +++ b/packages/tui/src/app.test.ts @@ -1165,6 +1165,224 @@ test("google calendar selection route loads calendars and saves selected ids", a tui.destroy(); }); +test("enabling Google Calendar starts Google login immediately when scopes need an upgrade", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const paths = createPaths(); + const secrets = createSecretsStore( + new Map([ + [ + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID).clientId, + "client-id", + ], + [ + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID).clientSecret, + "client-secret", + ], + [ + getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID) + .refreshToken, + "old-refresh-token", + ], + ]), + ); + const config = createConfig(); + getDefaultIntegration(config, "gmail").enabled = true; + const draft = createDraftState(config, { + googleClientIdStored: true, + googleClientSecretStored: true, + googleRefreshTokenStored: true, + }); + let googleSetupOpenCalls = 0; + const startGoogleSessionCalls: Array<{ + clientId: string; + clientSecret: string; + scopes: string[]; + }> = []; + const validateCalls: Array<{ + refreshToken: string; + requiredScopes: readonly string[]; + }> = []; + const tui = await ConfigTuiApp.create( + { + app: createApp(), + docsBaseUrl: DOCS_BASE_URL, + io: createIo(), + secrets: secrets.store, + session: createSessionStub().session, + }, + paths, + draft, + renderer, + { + ...createDefaultAuthService(), + async startGoogleSession( + clientId, + clientSecret, + scopes, + ): Promise { + startGoogleSessionCalls.push({ clientId, clientSecret, scopes }); + return { + authorizationUrl: "https://accounts.example/auth", + browserOpened: true, + async complete() { + return { refreshToken: "new-refresh-token" }; + }, + async cancel() {}, + }; + }, + async openGoogleOAuthSetup() { + googleSetupOpenCalls += 1; + return { opened: true }; + }, + async validateGoogleCredentials(_paths, credentials, requiredScopes) { + validateCalls.push({ + refreshToken: credentials.refreshToken, + requiredScopes, + }); + if (credentials.refreshToken === "old-refresh-token") { + throw new Error( + "Google account is missing required scopes: https://www.googleapis.com/auth/calendar.readonly", + ); + } + }, + async listGoogleCalendars(credentials) { + expect(credentials.refreshToken).toBe("new-refresh-token"); + return [{ id: "primary", summary: "Primary", primary: true }]; + }, + }, + ); + + (tui as any).ui.routes = [createConnectorDetailsRoute("google-calendar")]; + + await (tui as any).activateCurrentSelection(); + + expect(googleSetupOpenCalls).toBe(0); + expect(startGoogleSessionCalls).toEqual([ + { + clientId: "client-id", + clientSecret: "client-secret", + scopes: [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/gmail.readonly", + ], + }, + ]); + expect(validateCalls).toEqual([ + { + refreshToken: "old-refresh-token", + requiredScopes: [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/gmail.readonly", + ], + }, + { + refreshToken: "new-refresh-token", + requiredScopes: [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/gmail.readonly", + ], + }, + ]); + expect((tui as any).ui.routes.at(-1).id).toBe("googleCalendarSelection"); + expect((tui as any).ui.routes.at(-1).calendars).toEqual([ + { id: "primary", summary: "Primary", primary: true }, + ]); + expect( + await secrets.store.getSecret( + getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID).refreshToken, + paths, + ), + ).toBe("new-refresh-token"); + + tui.destroy(); +}); + +test("enabling Gmail reconnects immediately when Google setup is incomplete", async () => { + const { renderer } = await createTestRenderer({ width: 100, height: 30 }); + const paths = createPaths(); + const secrets = createSecretsStore( + new Map([ + [ + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID).clientId, + "client-id", + ], + [ + getGoogleOAuthAppSecretNames(DEFAULT_GOOGLE_OAUTH_APP_ID).clientSecret, + "client-secret", + ], + ]), + ); + const draft = createDraftState(createConfig(), { + googleClientIdStored: true, + googleClientSecretStored: true, + googleRefreshTokenStored: false, + }); + const startGoogleSessionCalls: Array<{ + clientId: string; + clientSecret: string; + scopes: string[]; + }> = []; + let validateCalls = 0; + const tui = await ConfigTuiApp.create( + { + app: createApp(), + docsBaseUrl: DOCS_BASE_URL, + io: createIo(), + secrets: secrets.store, + session: createSessionStub().session, + }, + paths, + draft, + renderer, + { + ...createDefaultAuthService(), + async startGoogleSession( + clientId, + clientSecret, + scopes, + ): Promise { + startGoogleSessionCalls.push({ clientId, clientSecret, scopes }); + return { + authorizationUrl: "https://accounts.example/auth", + browserOpened: true, + async complete() { + return { refreshToken: "new-refresh-token" }; + }, + async cancel() {}, + }; + }, + async validateGoogleCredentials() { + validateCalls += 1; + }, + }, + ); + + (tui as any).ui.routes = [createConnectorDetailsRoute("gmail")]; + + await (tui as any).activateCurrentSelection(); + + expect(validateCalls).toBe(1); + expect(startGoogleSessionCalls).toEqual([ + { + clientId: "client-id", + clientSecret: "client-secret", + scopes: ["https://www.googleapis.com/auth/gmail.readonly"], + }, + ]); + expect( + getDefaultIntegration((tui as any).draft.config, "gmail").enabled, + ).toBe(true); + expect( + await secrets.store.getSecret( + getGoogleConnectionSecretNames(DEFAULT_GOOGLE_CONNECTION_ID).refreshToken, + paths, + ), + ).toBe("new-refresh-token"); + expect((tui as any).ui.notice?.text).toBe("Gmail enabled."); + + tui.destroy(); +}); + test("disconnect confirmation persists immediately", async () => { const { renderer } = await createTestRenderer({ width: 100, height: 30 }); const paths = createPaths(); diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index 2c6a9e5..afca983 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -737,6 +737,11 @@ export class ConfigTuiApp { this.refreshView(); await this.refreshGoogleCalendarSelection(calendarRoute); } else if (selection === "enable" && route.connector === "gmail") { + const hasScopes = await this.ensureGoogleScopesForConnector("gmail"); + if (!hasScopes) { + return; + } + const saved = await this.persistDraftMutation( (draft) => setConnectorEnabled(draft, "gmail", true), "Failed to enable Gmail.", @@ -751,6 +756,12 @@ export class ConfigTuiApp { selection === "enable" && route.connector === "google-calendar" ) { + const hasScopes = + await this.ensureGoogleScopesForConnector("google-calendar"); + if (!hasScopes) { + return; + } + if (getDraftSelectedGoogleCalendarIds(this.draft).length === 0) { const calendarRoute = createGoogleCalendarSelectionRoute([]); pushRoute(this.ui, calendarRoute); @@ -759,14 +770,6 @@ export class ConfigTuiApp { return; } - const hasScopes = await this.ensureGoogleScopesForConnector( - "google-calendar", - route.connector, - ); - if (!hasScopes) { - return; - } - const saved = await this.persistDraftMutation( (draft) => setConnectorEnabled(draft, "google-calendar", true), "Failed to enable Google Calendar.", @@ -1525,7 +1528,10 @@ export class ConfigTuiApp { }); } - private async getCurrentGoogleCredentials(): Promise { + private async getCurrentGoogleOAuthAppCredentials(): Promise<{ + clientId: string; + clientSecret: string; + } | null> { const clientId = this.draft.googleClientId.action === "set" ? this.draft.googleClientId.value @@ -1546,6 +1552,19 @@ export class ConfigTuiApp { .clientSecret, this.paths, ); + if (!clientId || !clientSecret) { + return null; + } + + return { + clientId, + clientSecret, + }; + } + + private async getCurrentGoogleCredentials(): Promise { + const oauthAppCredentials = + await this.getCurrentGoogleOAuthAppCredentials(); const refreshToken = this.draft.googleRefreshToken.action === "set" ? this.draft.googleRefreshToken.value @@ -1557,52 +1576,75 @@ export class ConfigTuiApp { this.paths, ); - if (!clientId || !clientSecret || !refreshToken) { + if (!oauthAppCredentials || !refreshToken) { return null; } return { - clientId, - clientSecret, + clientId: oauthAppCredentials.clientId, + clientSecret: oauthAppCredentials.clientSecret, refreshToken, }; } private async ensureGoogleScopesForConnector( - connectorId: "gmail" | "google-calendar", - connectorTarget: "gmail" | "google-calendar", + connector: "gmail" | "google-calendar", ): Promise { const credentials = await this.getCurrentGoogleCredentials(); - if (!credentials) { - return false; - } - - try { - await this.authService.validateGoogleCredentials( - this.paths, - credentials, - await this.getRequiredGoogleScopes(connectorId), - ); - return true; - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Google account is missing required scopes."; + const requiredScopes = await this.getRequiredGoogleScopes(connector); + if (credentials) { + try { + await this.authService.validateGoogleCredentials( + this.paths, + credentials, + requiredScopes, + ); + return true; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Google account is missing required scopes."; + setNotice(this.ui, { + kind: "error", + text: message, + }); + } + } else { setNotice(this.ui, { kind: "error", - text: message, + text: "Google account setup is incomplete. Reconnect to continue.", }); - const authRoute = createConnectorAuthRoute( - connectorTarget, - "google-oauth", - ); - pushRoute(this.ui, authRoute); - await this.openOAuthSetupPage(authRoute, () => - this.authService.openGoogleOAuthSetup(), + } + + const authRoute = createConnectorAuthRoute(connector, "google-oauth"); + pushRoute(this.ui, authRoute); + const oauthAppCredentials = + await this.getCurrentGoogleOAuthAppCredentials(); + if (oauthAppCredentials) { + authRoute.values.googleClientId = oauthAppCredentials.clientId; + authRoute.values.googleClientSecret = oauthAppCredentials.clientSecret; + await this.runGoogleConnectFlow( + authRoute, + oauthAppCredentials.clientId, + oauthAppCredentials.clientSecret, ); - return false; + if ( + getCurrentRoute(this.ui) !== authRoute || + authRoute.stage !== "success" + ) { + return false; + } + + popRoute(this.ui); + this.refreshView(); + return true; } + + await this.openOAuthSetupPage(authRoute, () => + this.authService.openGoogleOAuthSetup(), + ); + return false; } private async refreshGoogleCalendarSelection( diff --git a/packages/tui/src/view-state.ts b/packages/tui/src/view-state.ts index 5d6227f..0f61555 100644 --- a/packages/tui/src/view-state.ts +++ b/packages/tui/src/view-state.ts @@ -1151,7 +1151,7 @@ export function getRouteOptions( ? "Enable Gmail" : "Enable Google Calendar", description: - "Use the stored Google account without reauthenticating", + "Enable with the stored Google account and verify permissions if needed", value: "enable", } satisfies UiSelectOption, {