feat(auth): resume workspace selection on /welcome with valid tokenPair cookie (#20575)
## Summary After a user completes a multi-workspace social-SSO sign-in, [auth.service.ts:988-1011](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L988-L1011) issues a **workspace-agnostic** access + refresh token pair and lands them on `app.twenty.com/welcome?tokenPair=…`. [SignInUpGlobalScopeFormEffect.tsx](packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx) reads the URL param, writes the cookie, pushes them to `SignInUpStep.WorkspaceSelection`. The problem: if the user revisits `app.twenty.com/welcome` later (e.g. ChatGPT pings `/authorize` and the global page-change effect redirects them to `/welcome` with `returnToPath=/authorize?…`), the existing branch is a no-op — the URL param is gone. The user sees the regular email/SSO form and has to re-authenticate, even though the workspace-agnostic cookie is still valid. This PR adds a second branch in the same `useEffect` that handles the "valid cookie, no URL param" case: ```ts if (signInUpStep !== SignInUpStep.Init) return; if (!hasAccessTokenPair) return; loadCurrentUser(); setSignInUpStep(SignInUpStep.WorkspaceSelection); ``` Single `useEffect`, no `useRef`, no async then/catch. The synchronous `setSignInUpStep(WorkspaceSelection)` is the gate — once the step transitions, subsequent effect runs early-return. Mirrors the existing URL-param branch's pattern exactly. If the cookie is stale, `loadCurrentUser` triggers Apollo's renewal middleware. Renewal of a workspace-agnostic refresh token is supported end-to-end (verified in audit, see below) — if it succeeds the user sees their workspaces; if both tokens are expired, `onUnauthenticatedError` clears the cookie and the next render lands them on the regular sign-in form. Same fallback as if the cookie had never been there. ## Behavior matrix | State on /welcome mount | Before | After | |---|---|---| | No tokenPair anywhere | Show sign-in form | Show sign-in form | | tokenPair in URL (just bounced from SSO) | Set tokens → WorkspaceSelection | (unchanged) Set tokens → WorkspaceSelection | | tokenPair in cookie, access valid | Show sign-in form ❌ | **→ WorkspaceSelection ✓** | | tokenPair in cookie, access expired, refresh valid | Show sign-in form (Apollo eventually 401s on a query) | Renewal succeeds silently → WorkspaceSelection ✓ | | tokenPair in cookie, both expired | Show sign-in form | `onUnauthenticatedError` clears cookie → fall back to sign-in form | ## Workspace-agnostic renewal: confirmed working end-to-end Audit summary: - **Refresh token carries the type**: [refresh-token.service.ts:104](packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts) preserves `targetedTokenType` in the JWT payload and returns it from `verifyRefreshToken`. - **Renewal branches on type** ([renew-token.service.ts:70-87](packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts)): ```ts const accessToken = isDefined(authProvider) && targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC && !isDefined(workspaceId) ? await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken({...}) : await this.accessTokenService.generateAccessToken({...}); ``` Renewed refresh token preserves `targetedTokenType` (line 93). - **Resolver is workspace-agnostic**: `@UseGuards(PublicEndpointGuard, NoPermissionGuard)` on `renewToken` ([auth.resolver.ts:796-804](packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts)) — no `@AuthWorkspace()` requirement, callable from `app.twenty.com`. - **Frontend middleware is type-agnostic**: [apollo.factory.ts:180-209](packages/twenty-front/src/modules/apollo/services/apollo.factory.ts) just passes the refresh token blob. Net: no backend change needed. The full workspace-agnostic lifecycle (issue → cookie → renew → re-issue) already works. ## Test plan - [x] `npx oxlint` + `prettier --check` — clean. - [x] `npx nx typecheck twenty-front` — clean. - [ ] Manual: complete one full SSO flow ending on a workspace subdomain. Visit `https://app.twenty.com/welcome` directly — expect the workspace picker, not the sign-in form. - [ ] Manual: same but with tokenPair cookie cleared — expect the regular sign-in form (no regression). - [ ] Manual: sign-out from a workspace, then visit `app.twenty.com/welcome` — expect the regular form (sign-out clears the cookie via full page reload). - [ ] Manual: stale/expired tokenPair cookie — Apollo renewal kicks in transparently; if renewal fails, regular form (no infinite loop, no crash). - [ ] Manual: pair with #20572 — visit `app.twenty.com/authorize?…` with a stale workspace-agnostic cookie. Expected chain: `/authorize` renders → `PageChangeEffect` redirects to `/welcome?returnToPath=/authorize?…` → this effect lands the user on WorkspaceSelection → picking a workspace bounces to `<workspace>/authorize?…` where consent renders. ## Out of scope - Fixing `lastAuthenticatedWorkspaceDomain` for custom-domain users (separate cookie-scoping issue, tracked separately).
This commit is contained in:
+25
-4
@@ -1,35 +1,56 @@
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
|
||||
import {
|
||||
SignInUpStep,
|
||||
signInUpStepState,
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const SignInUpGlobalScopeFormEffect = () => {
|
||||
const setSignInUpStep = useSetAtomState(signInUpStepState);
|
||||
const signInUpStep = useAtomStateValue(signInUpStepState);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setAuthTokens } = useAuth();
|
||||
const { loadCurrentUser } = useLoadCurrentUser();
|
||||
const hasAccessTokenPair = useHasAccessTokenPair();
|
||||
|
||||
useEffect(() => {
|
||||
const tokenPair = searchParams.get('tokenPair');
|
||||
if (isDefined(tokenPair)) {
|
||||
setAuthTokens(JSON.parse(tokenPair));
|
||||
// Path 1: user just bounced back from social SSO with a workspace-agnostic
|
||||
// tokenPair in the URL. Honor it unconditionally.
|
||||
const tokenPairFromUrl = searchParams.get('tokenPair');
|
||||
if (isDefined(tokenPairFromUrl)) {
|
||||
setAuthTokens(JSON.parse(tokenPairFromUrl));
|
||||
searchParams.delete('tokenPair');
|
||||
setSearchParams(searchParams);
|
||||
loadCurrentUser();
|
||||
setSignInUpStep(SignInUpStep.WorkspaceSelection);
|
||||
return;
|
||||
}
|
||||
|
||||
// Path 2: user revisits /welcome with a still-valid workspace-agnostic
|
||||
// tokenPair cookie left over from a prior SSO landing. Resume straight
|
||||
// to workspace selection instead of forcing them through the sign-in
|
||||
// form again. The step transition gates re-entry; if the cookie is
|
||||
// stale, loadCurrentUser triggers Apollo's renewal -> onUnauthenticatedError
|
||||
// path which clears the cookie and falls back to the normal form.
|
||||
if (signInUpStep !== SignInUpStep.Init) return;
|
||||
if (!hasAccessTokenPair) return;
|
||||
|
||||
loadCurrentUser();
|
||||
setSignInUpStep(SignInUpStep.WorkspaceSelection);
|
||||
}, [
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
setSignInUpStep,
|
||||
loadCurrentUser,
|
||||
setAuthTokens,
|
||||
signInUpStep,
|
||||
hasAccessTokenPair,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
||||
Reference in New Issue
Block a user