From 487e1a5e6801233b9b4dbc423e923fad4ba96a35 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 15:14:33 +0900 Subject: [PATCH 1/3] fix: recover google enable flow when setup is incomplete --- packages/tui/src/app.test.ts | 218 +++++++++++++++++++++++++++++++++ packages/tui/src/app.ts | 122 ++++++++++++------ packages/tui/src/view-state.ts | 2 +- 3 files changed, 304 insertions(+), 38 deletions(-) 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..6fba5af 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -737,6 +737,14 @@ export class ConfigTuiApp { this.refreshView(); await this.refreshGoogleCalendarSelection(calendarRoute); } else if (selection === "enable" && route.connector === "gmail") { + const hasScopes = await this.ensureGoogleScopesForConnector( + "gmail", + route.connector, + ); + if (!hasScopes) { + return; + } + const saved = await this.persistDraftMutation( (draft) => setConnectorEnabled(draft, "gmail", true), "Failed to enable Gmail.", @@ -751,14 +759,6 @@ export class ConfigTuiApp { selection === "enable" && route.connector === "google-calendar" ) { - if (getDraftSelectedGoogleCalendarIds(this.draft).length === 0) { - const calendarRoute = createGoogleCalendarSelectionRoute([]); - pushRoute(this.ui, calendarRoute); - this.refreshView(); - await this.refreshGoogleCalendarSelection(calendarRoute); - return; - } - const hasScopes = await this.ensureGoogleScopesForConnector( "google-calendar", route.connector, @@ -767,6 +767,14 @@ export class ConfigTuiApp { return; } + if (getDraftSelectedGoogleCalendarIds(this.draft).length === 0) { + const calendarRoute = createGoogleCalendarSelectionRoute([]); + pushRoute(this.ui, calendarRoute); + this.refreshView(); + await this.refreshGoogleCalendarSelection(calendarRoute); + return; + } + const saved = await this.persistDraftMutation( (draft) => setConnectorEnabled(draft, "google-calendar", true), "Failed to enable Google Calendar.", @@ -1525,7 +1533,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 +1557,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,13 +1581,13 @@ export class ConfigTuiApp { this.paths, ); - if (!clientId || !clientSecret || !refreshToken) { + if (!oauthAppCredentials || !refreshToken) { return null; } return { - clientId, - clientSecret, + clientId: oauthAppCredentials.clientId, + clientSecret: oauthAppCredentials.clientSecret, refreshToken, }; } @@ -1573,36 +1597,60 @@ export class ConfigTuiApp { connectorTarget: "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(connectorId); + 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(connectorTarget, "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, { From ac9d4ff882b5c748f366a34a41dc690513d0b7de Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 15:16:46 +0900 Subject: [PATCH 2/3] chore: add changeset for google enable flow --- .changeset/eight-cameras-fix.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changeset/eight-cameras-fix.md 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. From 67a8b8ddea0b5d7e3822584049c5a306c4915485 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 18 Mar 2026 15:20:12 +0900 Subject: [PATCH 3/3] refactor: simplify google scope check signature --- packages/tui/src/app.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index 6fba5af..afca983 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -737,10 +737,7 @@ export class ConfigTuiApp { this.refreshView(); await this.refreshGoogleCalendarSelection(calendarRoute); } else if (selection === "enable" && route.connector === "gmail") { - const hasScopes = await this.ensureGoogleScopesForConnector( - "gmail", - route.connector, - ); + const hasScopes = await this.ensureGoogleScopesForConnector("gmail"); if (!hasScopes) { return; } @@ -759,10 +756,8 @@ export class ConfigTuiApp { selection === "enable" && route.connector === "google-calendar" ) { - const hasScopes = await this.ensureGoogleScopesForConnector( - "google-calendar", - route.connector, - ); + const hasScopes = + await this.ensureGoogleScopesForConnector("google-calendar"); if (!hasScopes) { return; } @@ -1593,11 +1588,10 @@ export class ConfigTuiApp { } private async ensureGoogleScopesForConnector( - connectorId: "gmail" | "google-calendar", - connectorTarget: "gmail" | "google-calendar", + connector: "gmail" | "google-calendar", ): Promise { const credentials = await this.getCurrentGoogleCredentials(); - const requiredScopes = await this.getRequiredGoogleScopes(connectorId); + const requiredScopes = await this.getRequiredGoogleScopes(connector); if (credentials) { try { await this.authService.validateGoogleCredentials( @@ -1623,7 +1617,7 @@ export class ConfigTuiApp { }); } - const authRoute = createConnectorAuthRoute(connectorTarget, "google-oauth"); + const authRoute = createConnectorAuthRoute(connector, "google-oauth"); pushRoute(this.ui, authRoute); const oauthAppCredentials = await this.getCurrentGoogleOAuthAppCredentials();