Files
supabase__supabase/apps/studio/lib/gotrue.ts
Danny White ee931e49a1 fix(studio): send new signups to org creation directly (#41041)
## What kind of change does this PR introduce?

Bug fix. Resolves DEPR-318.

## What is the current behavior?

New users who confirm their email land on `/sign-in`, then
`/organizations`, then get bounced to `/new` via a `useEffect`.
Cancelling org creation with zero orgs sends them back to
`/organizations`, which immediately redirects into `/new` again.

## What is the new behavior?

- Signup email verification redirects to `/new` directly
- `/organizations` with zero orgs shows the existing empty state instead
of force-redirecting

## To test

### One-time setup

Assuming you don’t already have a staging account with **zero** orgs:

1. On **supabase.green**, sign up with a fresh email and confirm it
2. Stop at org creation. Do **not** create an org

### On this PR

Using the [studio-staging
preview](https://studio-staging-git-dnywh-fixremove-org-redirect-supabase.vercel.app/)
from Vercel checks:

4. Sign in on the preview with that account
5. Open `/dashboard/organizations`. Expect the **Create an
organization** empty state, with no redirect to `/new`
6. Open `/dashboard/new`, click **Cancel**. Expect to land on
`/organizations` and stay there

### Compare on supabase.green

Optional. Just to show what happens currently on `master`:

7. Repeat steps 3–5 on **supabase.green**. `/organizations` should
bounce to `/new`, and **Cancel** should send you back into org creation

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved signup redirects by preserving valid destinations and
relevant query parameters.
* Added safer fallback behavior for missing, invalid, or unsupported
destinations.
  * Improved handling of signup redirects provided in multiple formats.
* Prevented automatic redirection from the organizations page when no
organizations exist.

* **Style**
  * Updated the organizations page title capitalization for consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-24 17:23:25 +10:00

151 lines
4.9 KiB
TypeScript

import type { JwtPayload } from '@supabase/supabase-js'
import { type User } from 'common/auth'
import { gotrueClient } from 'common/gotrue'
export const auth = gotrueClient
export const DEFAULT_FALLBACK_PATH = '/organizations'
export const DEFAULT_SIGNUP_RETURN_PATH = '/new'
/**
* When unauthenticated users hit protected dashboard routes, withAuth sets
* returnTo to the current path. Marketing entry via /dashboard often ends up
* as returnTo=/org (or /organizations). New users who switch to sign-up should
* start org creation instead.
*/
export function getSignUpReturnTo(returnTo: string | string[] | undefined): string {
const value = Array.isArray(returnTo) ? returnTo[0] : returnTo
if (!value) {
return DEFAULT_SIGNUP_RETURN_PATH
}
const [pathname, query] = value.split('?', 2)
if (pathname === DEFAULT_FALLBACK_PATH || pathname === '/org') {
return query ? `${DEFAULT_SIGNUP_RETURN_PATH}?${query}` : DEFAULT_SIGNUP_RETURN_PATH
}
return value
}
/** Post-signup redirect path, normalising returnTo and excluding it from merged query params. */
export function buildSignUpReturnPath(returnTo: string | string[] | undefined): string {
const basePath = validateReturnTo(getSignUpReturnTo(returnTo), DEFAULT_SIGNUP_RETURN_PATH)
const [pathOnly, pathQuery] = basePath.split('?', 2)
const pathnameSearchParams = new URLSearchParams(pathQuery || '')
if (typeof location === 'undefined') {
const queryString = pathnameSearchParams.toString()
return queryString ? `${pathOnly}?${queryString}` : pathOnly
}
const mergedParams = new URLSearchParams(location.search)
mergedParams.delete('returnTo')
for (const [key, val] of pathnameSearchParams.entries()) {
mergedParams.set(key, val)
}
const queryString = mergedParams.toString()
return queryString ? `${pathOnly}?${queryString}` : pathOnly
}
export const validateReturnTo = (
returnTo: string,
fallback: string = DEFAULT_FALLBACK_PATH
): string => {
// Block protocol-relative URLs and external URLs
if (returnTo.startsWith('//') || returnTo.includes('://')) {
return fallback
}
// For internal paths:
// 1. Must start with /
// 2. Only allow alphanumeric chars, slashes, hyphens, underscores
// 3. For query params, also allow =, &, and ?
const safePathPattern = /^\/[a-zA-Z0-9/\-_]*(?:\?[a-zA-Z0-9\-_=&]*)?$/
return safePathPattern.test(returnTo) ? returnTo : fallback
}
export const getUserClaims = async (
token: String
): Promise<{ error: any | null; claims: JwtPayload | null }> => {
try {
const { data, error } = await auth.getClaims(token.replace(/bearer /i, ''))
if (error) throw error
return { claims: data?.claims ?? null, error: null }
} catch (err) {
console.error(err)
return { claims: null, error: err }
}
}
export const getAuth0Id = (provider: String, providerId: String): String => {
return `${provider}|${providerId}`
}
export const getIdentity = (gotrueUser: User) => {
try {
if (gotrueUser !== undefined && gotrueUser.identities !== undefined) {
return { identity: gotrueUser.identities[0], error: null }
}
throw 'Missing identity'
} catch (err) {
return { identity: null, error: err }
}
}
/**
* Transfers the search params from the current location path to a newly built path
*/
export const buildPathWithParams = (pathname: string) => {
const [basePath, existingParams] = pathname.split('?', 2)
const pathnameSearchParams = new URLSearchParams(existingParams || '')
// Merge the parameters, with pathname parameters taking precedence
// over the current location's search parameters
const mergedParams = new URLSearchParams(location.search)
for (const [key, value] of pathnameSearchParams.entries()) {
mergedParams.set(key, value)
}
const queryString = mergedParams.toString()
return queryString ? `${basePath}?${queryString}` : basePath
}
export const getReturnToPath = (fallback = DEFAULT_FALLBACK_PATH) => {
// If we're in a server environment, return the fallback
if (typeof location === 'undefined') {
return fallback
}
const searchParams = new URLSearchParams(location.search)
let returnTo = searchParams.get('returnTo') ?? fallback
if (process.env.NEXT_PUBLIC_BASE_PATH) {
returnTo = returnTo.replace(process.env.NEXT_PUBLIC_BASE_PATH, '')
}
searchParams.delete('returnTo')
const remainingSearchParams = searchParams.toString()
const validReturnTo = validateReturnTo(returnTo, fallback)
const [path, existingQuery] = validReturnTo.split('?')
const finalSearchParams = new URLSearchParams(existingQuery || '')
// Add all remaining search params to the final search params
if (remainingSearchParams) {
const remainingParams = new URLSearchParams(remainingSearchParams)
remainingParams.forEach((value, key) => {
finalSearchParams.append(key, value)
})
}
const finalQuery = finalSearchParams.toString()
return path + (finalQuery ? `?${finalQuery}` : '')
}