Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(setup): setup wizard with browser-based Chat key handoff#5911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
9a9132e090342caf8ad4465e6ef1d362e68209e6ec43de45d08d13fa9db7998e00b02d8e8c5cac5a6607fd1a900cb49a61cf61a14a393319f24f237740d91d3bea50f2136a031c919fba25efdcf220b3a0c3e2218eca1ed28b60479be0821af78720108f777c5dac2cc1f81d8File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| /** | ||
| * Where the user goes once authentication finishes, carried across the login → | ||
| * signup → verify hops. Written only after `validateCallbackUrl` accepts it, and | ||
| * re-validated on read. | ||
| */ | ||
| export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl' | ||
| interface AuthCrossLinkParams { | ||
| /** Validated post-auth destination to carry over, or null to drop it. */ | ||
| callbackUrl: string | null | ||
| isInviteFlow: boolean | ||
| } | ||
| /** | ||
| * Builds the login ⇄ signup cross-link, preserving the post-auth destination so | ||
| * a visitor who signs up instead of signing in still lands where they were | ||
| * headed. `URLSearchParams` does the encoding — a destination that carries its | ||
| * own query string (`/cli/auth?callback=…&state=…`) must survive intact. | ||
| */ | ||
| export function buildAuthCrossLink( | ||
| path: '/login' | '/signup', | ||
| { callbackUrl, isInviteFlow }: AuthCrossLinkParams | ||
| ): string { | ||
| const params = new URLSearchParams() | ||
| if (isInviteFlow) params.set('invite_flow', 'true') | ||
| if (callbackUrl) params.set('callbackUrl', callbackUrl) | ||
| const query = params.toString() | ||
| return query ? `${path}?${query}` : path | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -10,6 +10,7 @@ import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' | ||
| import { validateCallbackUrl } from '@/lib/core/security/input-validation' | ||
| import { quickValidateEmail } from '@/lib/messaging/email/validation' | ||
| import { captureClientEvent, captureEvent } from '@/lib/posthog/client' | ||
| import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' | ||
| import { | ||
| AuthDivider, | ||
| AuthField, | ||
| @@ -343,9 +344,12 @@ function SignupFormContent({ | ||
| if (typeof window !== 'undefined') { | ||
| sessionStorage.setItem('verificationEmail', emailValue) | ||
| if (isInviteFlow && redirectUrl) { | ||
| sessionStorage.setItem('inviteRedirectUrl', redirectUrl) | ||
| sessionStorage.setItem('isInviteFlow', 'true') | ||
| if (redirectUrl) { | ||
| sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) | ||
TheodoreSpeaks marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } else { | ||
| // Clear any leftover from an earlier signup in this tab — otherwise a | ||
| // signup with no callbackUrl inherits the previous CLI/invite destination. | ||
| sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) | ||
| } | ||
| } | ||
| @@ -468,7 +472,7 @@ function SignupFormContent({ | ||
| <AuthNavPrompt | ||
| prompt='Already have an account?' | ||
| href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'} | ||
| href={buildAuthCrossLink('/login', { callbackUrl: redirectUrl || null, isInviteFlow })} | ||
| linkLabel='Sign in' | ||
| /> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -6,9 +6,39 @@ import { normalizeEmail } from '@sim/utils/string' | ||
| import { useRouter, useSearchParams } from 'next/navigation' | ||
| import { client, useSession } from '@/lib/auth/auth-client' | ||
| import { validateCallbackUrl } from '@/lib/core/security/input-validation' | ||
| import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' | ||
| const logger = createLogger('useVerification') | ||
| /** | ||
| * Resolves the post-auth destination at the moment of redirect rather than | ||
| * caching it in state. | ||
| * | ||
| * Both redirect sites run in the same commit as the effect that reads session | ||
| * storage, so a cached value is still `null` when they fire and the stored | ||
| * destination is silently replaced by `/workspace`. Reading here removes that | ||
| * race. `redirectAfter` wins over the stored URL; anything failing callback | ||
| * validation is discarded, and an unsafe stored value is evicted. | ||
| */ | ||
| function resolveRedirectUrl(redirectParam: string | null): string | null { | ||
| let resolved: string | null = null | ||
| const stored = sessionStorage.getItem(POST_AUTH_REDIRECT_STORAGE_KEY) | ||
| if (stored && validateCallbackUrl(stored)) { | ||
| resolved = stored | ||
| } else if (stored) { | ||
| logger.warn('Ignoring unsafe stored post-auth redirect URL', { url: stored }) | ||
| sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) | ||
| } | ||
| if (redirectParam) { | ||
| if (validateCallbackUrl(redirectParam)) resolved = redirectParam | ||
| else logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam }) | ||
| } | ||
| return resolved | ||
| } | ||
| /** | ||
| * Mutually-exclusive phases of the email-OTP verification machine. | ||
| * - `idle`: awaiting input | ||
| @@ -53,44 +83,11 @@ export function useVerification({ | ||
| const [isResending, setIsResending] = useState(false) | ||
| const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false) | ||
| const [errorMessage, setErrorMessage] = useState('') | ||
| const [redirectUrl, setRedirectUrl] = useState<string | null>(null) | ||
| const [isInviteFlow, setIsInviteFlow] = useState(false) | ||
| useEffect(() => { | ||
| if (typeof window !== 'undefined') { | ||
| const storedEmail = sessionStorage.getItem('verificationEmail') | ||
| if (storedEmail) { | ||
| setEmail(storedEmail) | ||
| } | ||
| const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl') | ||
| if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) { | ||
| setRedirectUrl(storedRedirectUrl) | ||
| } else if (storedRedirectUrl) { | ||
| logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl }) | ||
| sessionStorage.removeItem('inviteRedirectUrl') | ||
| } | ||
| const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow') | ||
| if (storedIsInviteFlow === 'true') { | ||
| setIsInviteFlow(true) | ||
| } | ||
| } | ||
| const redirectParam = searchParams.get('redirectAfter') | ||
| if (redirectParam) { | ||
| if (validateCallbackUrl(redirectParam)) { | ||
| setRedirectUrl(redirectParam) | ||
| } else { | ||
| logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam }) | ||
| } | ||
| } | ||
| const inviteFlowParam = searchParams.get('invite_flow') | ||
| if (inviteFlowParam === 'true') { | ||
| setIsInviteFlow(true) | ||
| } | ||
| }, [searchParams]) | ||
| const storedEmail = sessionStorage.getItem('verificationEmail') | ||
| if (storedEmail) setEmail(storedEmail) | ||
| }, []) | ||
| useEffect(() => { | ||
| if (email && !isSendingInitialOtp && hasEmailService) { | ||
| @@ -122,21 +119,12 @@ export function useVerification({ | ||
| logger.warn('Failed to refetch session after verification', e) | ||
| } | ||
| if (typeof window !== 'undefined') { | ||
| sessionStorage.removeItem('verificationEmail') | ||
| if (isInviteFlow) { | ||
| sessionStorage.removeItem('inviteRedirectUrl') | ||
| sessionStorage.removeItem('isInviteFlow') | ||
| } | ||
| } | ||
| const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace' | ||
| sessionStorage.removeItem('verificationEmail') | ||
| sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) | ||
| setTimeout(() => { | ||
| if (isInviteFlow && redirectUrl) { | ||
| window.location.href = redirectUrl | ||
| } else { | ||
| window.location.href = '/workspace' | ||
| } | ||
| window.location.href = destination | ||
| }, 1000) | ||
| } else { | ||
| logger.info('Setting invalid OTP state - API error response') | ||
| @@ -217,28 +205,31 @@ export function useVerification({ | ||
| }, [otp, email, status, isResending]) | ||
| useEffect(() => { | ||
| if (typeof window !== 'undefined') { | ||
| if (!isEmailVerificationEnabled) { | ||
| setStatus('verified') | ||
| if (isEmailVerificationEnabled) return | ||
| const handleRedirect = async () => { | ||
| try { | ||
| await refetchSession() | ||
| } catch (error) { | ||
| logger.warn('Failed to refetch session during verification skip:', error) | ||
| } | ||
| if (isInviteFlow && redirectUrl) { | ||
| window.location.href = redirectUrl | ||
| } else { | ||
| router.push('/workspace') | ||
| } | ||
| } | ||
| setStatus('verified') | ||
| handleRedirect() | ||
| const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) | ||
| // Single-use: consume the stored destination here too, or it survives this | ||
| // flow and reapplies to a later sign-in in the same tab. | ||
| sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) | ||
| const handleRedirect = async () => { | ||
| try { | ||
| await refetchSession() | ||
| } catch (error) { | ||
| logger.warn('Failed to refetch session during verification skip:', error) | ||
| } | ||
| if (destination) { | ||
| window.location.href = destination | ||
| } else { | ||
| router.push('/workspace') | ||
| } | ||
| } | ||
| }, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl]) | ||
| handleRedirect() | ||
| }, [isEmailVerificationEnabled, router, searchParams]) | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return { | ||
| otp, | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.