From 4c6a345d604888920e45e0ba13c937705135f8d0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 14:21:11 +0530 Subject: [PATCH] feat(onboarding): honor return hint in email OAuth callback (#14567) When connecting a Gmail or Outlook inbox during onboarding, the OAuth flow used to drop users in inbox settings, breaking onboarding. The OAuth start endpoint now accepts an optional `return_to=onboarding` hint, carried tamper-proof inside the signed `state`, and the callback uses it to return the user to the onboarding inbox-setup screen. Without the hint, behavior is unchanged. This is the backend half only; the frontend that sends `return_to=onboarding` ships separately. Co-authored-by: Muhsin Keloth --- .../accounts/oauth_authorization_controller.rb | 10 +++++++++- app/controllers/oauth_callback_controller.rb | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59..7fbca86da 100644 --- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC end def state - Current.account.to_sgid(expires_in: 15.minutes).to_s + # The sgid purpose doubles as a return hint: onboarding tags it so the callback + # can route the user back to inbox setup. The purpose is part of the signed + # payload (tamper-proof), and a non-onboarding request keeps the default + # purpose, leaving callers like Notion byte-identical. + Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s + end + + def state_purpose + params[:return_to] == 'onboarding' ? 'onboarding' : 'default' end def base_url diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 8929db987..4a3d049a6 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController def handle_response inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding' + if already_exists redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) else @@ -88,10 +90,19 @@ class OauthCallbackController < ApplicationController decoded_token[0] end + # The sgid purpose carries the onboarding return hint (see + # OauthAuthorizationController#state). Try the onboarding purpose first — a match + # both resolves the account and records the return target — then fall back to the + # default purpose used by every other caller. def account_from_signed_id raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? - account = GlobalID::Locator.locate_signed(params[:state]) + if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) + @return_to = 'onboarding' + else + account = GlobalID::Locator.locate_signed(params[:state]) + end + raise 'Invalid or expired state' if account.nil? account @@ -101,6 +112,11 @@ class OauthCallbackController < ApplicationController @account ||= account_from_signed_id end + def return_to + account # resolving the sgid records which purpose matched + @return_to + end + # Fallback name, for when name field is missing from users_data def fallback_name users_data['email'].split('@').first.parameterize.titleize