From 44325f9cab85621846d6fa3bd10d4230632b4323 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 5 Dec 2025 18:31:19 +0100
Subject: [PATCH 01/68] Refactor auth confirmation route and enhance URL
handling
- Removed unused imports and simplified the confirmation logic in `route.ts`.
- Introduced functions to determine if a URL is relative or from an external origin.
- Updated the confirmation schema to accept a string for the `next` parameter.
- Enhanced logging for the confirmation process and added a redirect to the confirmation page for same-origin requests.
- Added `CONFIRM` URL to the `AUTH_URLS` configuration.
- Refactored the builds repository to export functions directly.
This improves the clarity and maintainability of the authentication flow.
---
src/app/(auth)/confirm/page.tsx | 113 +++++++++++
src/app/api/auth/confirm/route.ts | 177 ++++++------------
src/configs/urls.ts | 1 +
src/server/api/models/auth.models.ts | 28 +++
.../api/repositories/auth.repository.ts | 80 ++++++++
.../api/repositories/builds.repository.ts | 11 +-
src/server/api/routers/auth.ts | 42 +++++
src/server/api/routers/builds.ts | 2 +-
src/server/api/routers/index.ts | 2 +
9 files changed, 332 insertions(+), 124 deletions(-)
create mode 100644 src/app/(auth)/confirm/page.tsx
create mode 100644 src/server/api/models/auth.models.ts
create mode 100644 src/server/api/repositories/auth.repository.ts
create mode 100644 src/server/api/routers/auth.ts
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
new file mode 100644
index 000000000..add699f80
--- /dev/null
+++ b/src/app/(auth)/confirm/page.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { AUTH_URLS } from '@/configs/urls'
+import { AuthFormMessage, AuthMessage } from '@/features/auth/form-message'
+import { type OtpType } from '@/server/api/models/auth.models'
+import { useTRPC } from '@/trpc/client'
+import { Button } from '@/ui/primitives/button'
+import { useMutation } from '@tanstack/react-query'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useMemo, useState } from 'react'
+
+const OTP_TYPE_LABELS: Record = {
+ signup: 'Sign Up',
+ recovery: 'Password Reset',
+ invite: 'Team Invitation',
+ magiclink: 'Sign In',
+ email: 'Email Verification',
+ email_change: 'Email Change',
+}
+
+const OTP_TYPE_DESCRIPTIONS: Record = {
+ signup: 'Complete your account registration',
+ recovery: 'Reset your password',
+ invite: 'Accept team invitation',
+ magiclink: 'Sign in to your account',
+ email: 'Verify your email address',
+ email_change: 'Confirm your new email address',
+}
+
+export default function ConfirmPage() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const trpc = useTRPC()
+
+ const [message, setMessage] = useState()
+
+ const params = useMemo(() => {
+ const tokenHash = searchParams.get('token_hash') ?? ''
+ const type = searchParams.get('type') as OtpType | null
+ const next = searchParams.get('next') ?? ''
+
+ return { tokenHash, type, next }
+ }, [searchParams])
+
+ const isValidParams = params.tokenHash && params.type && params.next
+ const typeLabel = params.type ? OTP_TYPE_LABELS[params.type] : 'Verification'
+ const typeDescription = params.type
+ ? OTP_TYPE_DESCRIPTIONS[params.type]
+ : 'Confirm your action'
+
+ const confirmMutation = useMutation(
+ trpc.auth.confirmEmail.mutationOptions({
+ onSuccess: (data) => {
+ router.push(data.redirectUrl)
+ },
+ onError: (error) => {
+ setMessage({ error: error.message })
+ },
+ })
+ )
+
+ const handleConfirm = () => {
+ if (!isValidParams || !params.type) return
+
+ setMessage(undefined)
+ confirmMutation.mutate({
+ token_hash: params.tokenHash,
+ type: params.type,
+ next: params.next,
+ })
+ }
+
+ return (
+
+
{typeLabel}
+
{typeDescription}
+
+
+
+
+
+
+ Changed your mind?{' '}
+
+ .
+
+
+ {!isValidParams && !message && (
+
+ )}
+
+ {message &&
}
+
+ )
+}
diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts
index 4b87c925f..b60a70ed3 100644
--- a/src/app/api/auth/confirm/route.ts
+++ b/src/app/api/auth/confirm/route.ts
@@ -1,12 +1,11 @@
-import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
+import { AUTH_URLS } from '@/configs/urls'
import { l } from '@/lib/clients/logger/logger'
-import { createClient } from '@/lib/clients/supabase/server'
import { encodedRedirect } from '@/lib/utils/auth'
import { redirect } from 'next/navigation'
-import { NextRequest, NextResponse } from 'next/server'
-import { serializeError } from 'serialize-error'
+import { NextRequest } from 'next/server'
import { z } from 'zod'
+// accepts both absolute URLs (external apps) and relative URLs (same-origin)
const confirmSchema = z.object({
token_hash: z.string().min(1),
type: z.enum([
@@ -17,12 +16,40 @@ const confirmSchema = z.object({
'email',
'email_change',
]),
- next: z.url(),
+ next: z.string().min(1),
})
const normalizeOrigin = (origin: string) =>
origin.replace('www.', '').replace(/\/$/, '')
+function isRelativeUrl(url: string): boolean {
+ return url.startsWith('/') && !url.startsWith('//')
+}
+
+function isExternalOrigin(next: string, dashboardOrigin: string): boolean {
+ // relative URLs are always same-origin
+ if (isRelativeUrl(next)) {
+ return false
+ }
+
+ try {
+ return (
+ normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
+ )
+ } catch {
+ // invalid URL format - treat as same-origin to be safe
+ return false
+ }
+}
+
+/**
+ * This route acts as an intermediary for email OTP verification.
+ *
+ * Email providers may prefetch/scan links, consuming OTP tokens before users click.
+ * To prevent this:
+ * - Same-origin requests: Redirect to /confirm page where user must click to verify
+ * - External origin requests: Redirect to Supabase client flow URL (for external apps)
+ */
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
@@ -51,133 +78,43 @@ export async function GET(request: NextRequest) {
)
}
- const supabaseTokenHash = result.data.token_hash
- const supabaseType = result.data.type
- const supabaseRedirectTo = result.data.next
- const supabaseClientFlowUrl = new URL(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/verify?token=${supabaseTokenHash}&type=${supabaseType}&redirect_to=${supabaseRedirectTo}`
- )
-
- const dashboardUrl = request.nextUrl
-
- const isDifferentOrigin =
- supabaseRedirectTo &&
- normalizeOrigin(new URL(supabaseRedirectTo).origin) !==
- normalizeOrigin(dashboardUrl.origin)
+ const { token_hash, type, next } = result.data
+ const dashboardOrigin = request.nextUrl.origin
+ const isDifferentOrigin = isExternalOrigin(next, dashboardOrigin)
l.info({
key: 'auth_confirm:init',
context: {
- supabase_token_hash: supabaseTokenHash
- ? `${supabaseTokenHash.slice(0, 10)}...`
- : null,
- supabaseType,
- supabaseRedirectTo,
+ tokenHashPrefix: token_hash.slice(0, 10),
+ type,
+ next,
isDifferentOrigin,
- supabaseClientFlowUrl,
- requestUrl: request.url,
- origin: request.nextUrl.origin,
+ origin: dashboardOrigin,
},
})
- // when the next param is an absolute URL, with a different origin,
- // we need to redirect to the supabase client flow url
+ // external origin: redirect to supabase client flow for the external app to handle
if (isDifferentOrigin) {
+ const supabaseClientFlowUrl = new URL(
+ `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/verify?token=${token_hash}&type=${type}&redirect_to=${next}`
+ )
throw redirect(supabaseClientFlowUrl.toString())
}
- try {
- const next =
- supabaseType === 'recovery'
- ? `${request.nextUrl.origin}${PROTECTED_URLS.RESET_PASSWORD}`
- : supabaseRedirectTo
-
- const redirectUrl = new URL(next)
-
- const supabase = await createClient()
+ // same origin: redirect to /confirm page for user to explicitly confirm
+ const confirmPageUrl = new URL(dashboardOrigin + AUTH_URLS.CONFIRM)
+ confirmPageUrl.searchParams.set('token_hash', token_hash)
+ confirmPageUrl.searchParams.set('type', type)
+ confirmPageUrl.searchParams.set('next', next)
- const { error, data } = await supabase.auth.verifyOtp({
- type: supabaseType,
- token_hash: supabaseTokenHash,
- })
-
- if (error) {
- l.error({
- key: 'auth_confirm:supabase_error',
- message: error.message,
- error: serializeError(error),
- context: {
- supabase_token_hash: supabaseTokenHash
- ? `${supabaseTokenHash.slice(0, 10)}...`
- : null,
- supabaseType,
- supabaseRedirectTo,
- redirectUrl: redirectUrl.toString(),
- },
- })
-
- let errorMessage = 'Invalid Token'
- if (error.status === 403 && error.code === 'otp_expired') {
- errorMessage = 'Email link has expired. Please request a new one.'
- }
-
- return encodedRedirect(
- 'error',
- dashboardSignInUrl.toString(),
- errorMessage
- )
- }
-
- // handle re-auth
- if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) {
- redirectUrl.searchParams.set('reauth', '1')
-
- return NextResponse.redirect(redirectUrl.toString())
- }
-
- l.info({
- key: 'auth_confirm:success',
- user_id: data?.user?.id,
- context: {
- supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`,
- supabaseType,
- supabaseRedirectTo,
- redirectUrl: redirectUrl.toString(),
- reauth: redirectUrl.searchParams.get('reauth'),
- },
- })
-
- return NextResponse.redirect(redirectUrl.toString())
- } catch (e) {
- const sE = serializeError(e) as object
-
- // nextjs internally throws redirects (encodedRedirect with error message in this case)
- // and captures them to do the actual redirect.
-
- // we need to throw the error to let nextjs handle it
- if (
- 'message' in sE &&
- typeof sE.message === 'string' &&
- sE.message.includes('NEXT_REDIRECT')
- ) {
- throw e
- }
-
- l.error({
- key: 'AUTH_CONFIRM:ERROR',
- message: 'message' in sE ? sE.message : undefined,
- error: sE,
- context: {
- supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`,
- supabaseType,
- supabaseRedirectTo,
- },
- })
+ l.info({
+ key: 'auth_confirm:redirect_to_confirm_page',
+ context: {
+ tokenHashPrefix: token_hash.slice(0, 10),
+ type,
+ confirmPageUrl: confirmPageUrl.toString(),
+ },
+ })
- return encodedRedirect(
- 'error',
- dashboardSignInUrl.toString(),
- 'Invalid Token'
- )
- }
+ throw redirect(confirmPageUrl.toString())
}
diff --git a/src/configs/urls.ts b/src/configs/urls.ts
index ed4af2c4a..a5d6352e9 100644
--- a/src/configs/urls.ts
+++ b/src/configs/urls.ts
@@ -2,6 +2,7 @@ export const AUTH_URLS = {
FORGOT_PASSWORD: '/forgot-password',
SIGN_IN: '/sign-in',
SIGN_UP: '/sign-up',
+ CONFIRM: '/confirm',
CALLBACK: '/api/auth/callback',
CLI: '/auth/cli',
}
diff --git a/src/server/api/models/auth.models.ts b/src/server/api/models/auth.models.ts
new file mode 100644
index 000000000..d5b1d1e80
--- /dev/null
+++ b/src/server/api/models/auth.models.ts
@@ -0,0 +1,28 @@
+import { relativeUrlSchema } from '@/lib/schemas/url'
+import z from 'zod'
+
+// otp types supported by supabase
+export const OtpTypeSchema = z.enum([
+ 'signup',
+ 'recovery',
+ 'invite',
+ 'magiclink',
+ 'email',
+ 'email_change',
+])
+
+export type OtpType = z.infer
+
+// shared schema for client form and tRPC router
+export const ConfirmEmailInputSchema = z.object({
+ token_hash: z.string().min(1),
+ type: OtpTypeSchema,
+ next: relativeUrlSchema,
+})
+
+export type ConfirmEmailInput = z.infer
+
+// response types
+export interface ConfirmEmailResult {
+ redirectUrl: string
+}
diff --git a/src/server/api/repositories/auth.repository.ts b/src/server/api/repositories/auth.repository.ts
new file mode 100644
index 000000000..01a2f626f
--- /dev/null
+++ b/src/server/api/repositories/auth.repository.ts
@@ -0,0 +1,80 @@
+import { l } from '@/lib/clients/logger/logger'
+import { createClient } from '@/lib/clients/supabase/server'
+import { TRPCError } from '@trpc/server'
+import { serializeError } from 'serialize-error'
+import type { OtpType } from '../models/auth.models'
+
+interface VerifyOtpResult {
+ userId: string
+}
+
+/**
+ * Verifies an OTP token with Supabase Auth
+ * @throws TRPCError on verification failure
+ */
+async function verifyOtp(
+ tokenHash: string,
+ type: OtpType
+): Promise {
+ const supabase = await createClient()
+
+ const { data, error } = await supabase.auth.verifyOtp({
+ type,
+ token_hash: tokenHash,
+ })
+
+ if (error) {
+ l.error(
+ {
+ key: 'auth_repository:verify_otp:error',
+ error: serializeError(error),
+ context: {
+ type,
+ tokenHashPrefix: tokenHash.slice(0, 10),
+ errorCode: error.code,
+ errorStatus: error.status,
+ },
+ },
+ `failed to verify OTP: ${error.message}`
+ )
+
+ // map supabase errors to user-friendly messages
+ if (error.status === 403 && error.code === 'otp_expired') {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Email link has expired. Please request a new one.',
+ })
+ }
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Invalid or expired verification link.',
+ })
+ }
+
+ if (!data.user) {
+ l.error(
+ {
+ key: 'auth_repository:verify_otp:no_user',
+ context: {
+ type,
+ tokenHashPrefix: tokenHash.slice(0, 10),
+ },
+ },
+ `failed to verify OTP: no user found for token hash: ${tokenHash.slice(0, 10)}`
+ )
+
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Verification failed. Please try again.',
+ })
+ }
+
+ return {
+ userId: data.user.id,
+ }
+}
+
+export const authRepo = {
+ verifyOtp,
+}
diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts
index cb0cb717f..fe1d7697a 100644
--- a/src/server/api/repositories/builds.repository.ts
+++ b/src/server/api/repositories/builds.repository.ts
@@ -15,7 +15,7 @@ function isUUID(value: string): boolean {
return z.uuid().safeParse(value).success
}
-export async function resolveTemplateId(
+async function resolveTemplateId(
templateIdOrAlias: string,
teamId: string
): Promise {
@@ -50,7 +50,7 @@ interface ListBuildsResult {
nextCursor: string | null
}
-export async function listBuilds(
+async function listBuilds(
teamId: string,
buildIdOrTemplate?: string,
statuses: BuildStatusDB[] = ['waiting', 'building', 'uploaded', 'failed'],
@@ -131,7 +131,7 @@ export async function listBuilds(
// get running build statuses
-export async function getRunningStatuses(
+async function getRunningStatuses(
teamId: string,
buildIds: string[]
): Promise {
@@ -161,3 +161,8 @@ export async function getRunningStatuses(
),
}))
}
+
+export const buildsRepo = {
+ listBuilds,
+ getRunningStatuses,
+}
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts
new file mode 100644
index 000000000..76de048a8
--- /dev/null
+++ b/src/server/api/routers/auth.ts
@@ -0,0 +1,42 @@
+import { PROTECTED_URLS } from '@/configs/urls'
+import { createTRPCRouter } from '../init'
+import {
+ ConfirmEmailInputSchema,
+ type ConfirmEmailResult,
+ type OtpType,
+} from '../models/auth.models'
+import { publicProcedure } from '../procedures'
+import { authRepo } from '../repositories/auth.repository'
+
+/**
+ * Determines the redirect URL based on OTP type and the original next parameter.
+ * Handles relative URLs (e.g., /dashboard)
+ */
+function buildRedirectUrl(type: OtpType, next: string): string {
+ // recovery flow always goes to account settings with reauth flag
+ if (type === 'recovery') {
+ return `${PROTECTED_URLS.RESET_PASSWORD}?reauth=1`
+ }
+
+ // reauth flow for account settings
+ if (next.startsWith(PROTECTED_URLS.ACCOUNT_SETTINGS)) {
+ const hasQuery = next.includes('?')
+ return hasQuery ? `${next}&reauth=1` : `${next}?reauth=1`
+ }
+
+ return next
+}
+
+export const authRouter = createTRPCRouter({
+ confirmEmail: publicProcedure
+ .input(ConfirmEmailInputSchema)
+ .mutation(async ({ input }): Promise => {
+ const { token_hash, type, next } = input
+
+ await authRepo.verifyOtp(token_hash, type)
+
+ const redirectUrl = buildRedirectUrl(type, next)
+
+ return { redirectUrl }
+ }),
+})
diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts
index 81831906c..5156daaad 100644
--- a/src/server/api/routers/builds.ts
+++ b/src/server/api/routers/builds.ts
@@ -1,7 +1,7 @@
import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
import { infra } from '@/lib/clients/api'
import { l } from '@/lib/clients/logger/logger'
-import * as buildsRepo from '@/server/api/repositories/builds.repository'
+import { buildsRepo } from '@/server/api/repositories/builds.repository'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { apiError } from '../errors'
diff --git a/src/server/api/routers/index.ts b/src/server/api/routers/index.ts
index 85602dca8..02349648c 100644
--- a/src/server/api/routers/index.ts
+++ b/src/server/api/routers/index.ts
@@ -1,9 +1,11 @@
import { createCallerFactory, createTRPCRouter } from '../init'
+import { authRouter } from './auth'
import { buildsRouter } from './builds'
import { sandboxesRouter } from './sandboxes'
import { templatesRouter } from './templates'
export const trpcAppRouter = createTRPCRouter({
+ auth: authRouter,
sandboxes: sandboxesRouter,
templates: templatesRouter,
builds: buildsRouter,
From e739b5a269df8dda24d4ddedabb6a533b1ab5c8c Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 5 Dec 2025 18:55:32 +0100
Subject: [PATCH 02/68] only allow absolute urls in schema
---
src/app/api/auth/confirm/route.ts | 23 ++++-------------------
src/server/api/models/auth.models.ts | 3 +--
src/server/api/routers/auth.ts | 13 ++++++++-----
3 files changed, 13 insertions(+), 26 deletions(-)
diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts
index b60a70ed3..22c112e53 100644
--- a/src/app/api/auth/confirm/route.ts
+++ b/src/app/api/auth/confirm/route.ts
@@ -5,7 +5,6 @@ import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
import { z } from 'zod'
-// accepts both absolute URLs (external apps) and relative URLs (same-origin)
const confirmSchema = z.object({
token_hash: z.string().min(1),
type: z.enum([
@@ -16,30 +15,16 @@ const confirmSchema = z.object({
'email',
'email_change',
]),
- next: z.string().min(1),
+ next: z.httpUrl(),
})
const normalizeOrigin = (origin: string) =>
origin.replace('www.', '').replace(/\/$/, '')
-function isRelativeUrl(url: string): boolean {
- return url.startsWith('/') && !url.startsWith('//')
-}
-
function isExternalOrigin(next: string, dashboardOrigin: string): boolean {
- // relative URLs are always same-origin
- if (isRelativeUrl(next)) {
- return false
- }
-
- try {
- return (
- normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
- )
- } catch {
- // invalid URL format - treat as same-origin to be safe
- return false
- }
+ return (
+ normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
+ )
}
/**
diff --git a/src/server/api/models/auth.models.ts b/src/server/api/models/auth.models.ts
index d5b1d1e80..41bdbf57e 100644
--- a/src/server/api/models/auth.models.ts
+++ b/src/server/api/models/auth.models.ts
@@ -1,4 +1,3 @@
-import { relativeUrlSchema } from '@/lib/schemas/url'
import z from 'zod'
// otp types supported by supabase
@@ -17,7 +16,7 @@ export type OtpType = z.infer
export const ConfirmEmailInputSchema = z.object({
token_hash: z.string().min(1),
type: OtpTypeSchema,
- next: relativeUrlSchema,
+ next: z.httpUrl(),
})
export type ConfirmEmailInput = z.infer
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts
index 76de048a8..7d6f3dee6 100644
--- a/src/server/api/routers/auth.ts
+++ b/src/server/api/routers/auth.ts
@@ -10,18 +10,21 @@ import { authRepo } from '../repositories/auth.repository'
/**
* Determines the redirect URL based on OTP type and the original next parameter.
- * Handles relative URLs (e.g., /dashboard)
*/
function buildRedirectUrl(type: OtpType, next: string): string {
+ const redirectUrl = new URL(next)
+
// recovery flow always goes to account settings with reauth flag
if (type === 'recovery') {
- return `${PROTECTED_URLS.RESET_PASSWORD}?reauth=1`
+ redirectUrl.pathname = PROTECTED_URLS.RESET_PASSWORD
+ redirectUrl.searchParams.set('reauth', '1')
+ return redirectUrl.toString()
}
// reauth flow for account settings
- if (next.startsWith(PROTECTED_URLS.ACCOUNT_SETTINGS)) {
- const hasQuery = next.includes('?')
- return hasQuery ? `${next}&reauth=1` : `${next}?reauth=1`
+ if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) {
+ redirectUrl.searchParams.set('reauth', '1')
+ return redirectUrl.toString()
}
return next
From 80c88f4bd1758d3a07bb7f8b81fd24dc5a211dad Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 5 Dec 2025 22:38:33 +0100
Subject: [PATCH 03/68] Refactor OTP verification process and enhance
confirmation page
- Updated the confirmation page to utilize a new `verifyOtp` function for handling OTP verification.
- Replaced TRPC mutation with a direct API call for improved clarity and control over the verification process.
- Enhanced error handling and user feedback on the confirmation page.
- Removed the deprecated `auth` router and adjusted related imports accordingly.
- Improved logging for the OTP verification process in the repository.
These changes streamline the authentication flow and improve maintainability.
---
src/app/(auth)/confirm/page.tsx | 77 ++++++++----
src/app/api/auth/confirm/route.ts | 52 +++++---
src/app/api/auth/verify-otp/route.ts | 117 ++++++++++++++++++
.../api/repositories/auth.repository.ts | 36 +++++-
src/server/api/routers/auth.ts | 45 -------
src/server/api/routers/index.ts | 2 -
6 files changed, 240 insertions(+), 89 deletions(-)
create mode 100644 src/app/api/auth/verify-otp/route.ts
delete mode 100644 src/server/api/routers/auth.ts
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
index add699f80..12d03d79e 100644
--- a/src/app/(auth)/confirm/page.tsx
+++ b/src/app/(auth)/confirm/page.tsx
@@ -1,13 +1,16 @@
'use client'
import { AUTH_URLS } from '@/configs/urls'
-import { AuthFormMessage, AuthMessage } from '@/features/auth/form-message'
-import { type OtpType } from '@/server/api/models/auth.models'
-import { useTRPC } from '@/trpc/client'
+import { AuthFormMessage } from '@/features/auth/form-message'
+import {
+ ConfirmEmailInputSchema,
+ type ConfirmEmailInput,
+ type OtpType,
+} from '@/server/api/models/auth.models'
import { Button } from '@/ui/primitives/button'
import { useMutation } from '@tanstack/react-query'
import { useRouter, useSearchParams } from 'next/navigation'
-import { useMemo, useState } from 'react'
+import { useMemo, useTransition } from 'react'
const OTP_TYPE_LABELS: Record = {
signup: 'Sign Up',
@@ -27,12 +30,31 @@ const OTP_TYPE_DESCRIPTIONS: Record = {
email_change: 'Confirm your new email address',
}
+interface VerifyOtpResponse {
+ redirectUrl?: string
+ error?: string
+}
+
+async function verifyOtp(input: ConfirmEmailInput): Promise {
+ const response = await fetch('/api/auth/verify-otp', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(input),
+ })
+
+ const data: VerifyOtpResponse = await response.json()
+
+ if (!response.ok || data.error) {
+ throw new Error(data.error || 'Verification failed. Please try again.')
+ }
+
+ return data
+}
+
export default function ConfirmPage() {
const router = useRouter()
const searchParams = useSearchParams()
- const trpc = useTRPC()
-
- const [message, setMessage] = useState()
+ const [isPending, startTransition] = useTransition()
const params = useMemo(() => {
const tokenHash = searchParams.get('token_hash') ?? ''
@@ -42,28 +64,32 @@ export default function ConfirmPage() {
return { tokenHash, type, next }
}, [searchParams])
- const isValidParams = params.tokenHash && params.type && params.next
+ const isValidParams = ConfirmEmailInputSchema.safeParse({
+ token_hash: params.tokenHash,
+ type: params.type,
+ next: params.next,
+ }).success
+
const typeLabel = params.type ? OTP_TYPE_LABELS[params.type] : 'Verification'
const typeDescription = params.type
? OTP_TYPE_DESCRIPTIONS[params.type]
: 'Confirm your action'
- const confirmMutation = useMutation(
- trpc.auth.confirmEmail.mutationOptions({
- onSuccess: (data) => {
- router.push(data.redirectUrl)
- },
- onError: (error) => {
- setMessage({ error: error.message })
- },
- })
- )
+ const mutation = useMutation({
+ mutationFn: verifyOtp,
+ onSuccess: (data) => {
+ if (data.redirectUrl) {
+ startTransition(() => {
+ router.push(data.redirectUrl!)
+ })
+ }
+ },
+ })
const handleConfirm = () => {
if (!isValidParams || !params.type) return
- setMessage(undefined)
- confirmMutation.mutate({
+ mutation.mutate({
token_hash: params.tokenHash,
type: params.type,
next: params.next,
@@ -78,7 +104,7 @@ export default function ConfirmPage() {
)
}
diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts
index 22c112e53..e52b5d148 100644
--- a/src/app/api/auth/confirm/route.ts
+++ b/src/app/api/auth/confirm/route.ts
@@ -67,22 +67,37 @@ export async function GET(request: NextRequest) {
const dashboardOrigin = request.nextUrl.origin
const isDifferentOrigin = isExternalOrigin(next, dashboardOrigin)
- l.info({
- key: 'auth_confirm:init',
- context: {
- tokenHashPrefix: token_hash.slice(0, 10),
- type,
- next,
- isDifferentOrigin,
- origin: dashboardOrigin,
+ l.info(
+ {
+ key: 'auth_confirm:init',
+ context: {
+ tokenHashPrefix: token_hash.slice(0, 10),
+ type,
+ next,
+ isDifferentOrigin,
+ origin: dashboardOrigin,
+ },
},
- })
+ `confirming email with OTP token hash: ${token_hash.slice(0, 10)}`
+ )
// external origin: redirect to supabase client flow for the external app to handle
if (isDifferentOrigin) {
const supabaseClientFlowUrl = new URL(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/verify?token=${token_hash}&type=${type}&redirect_to=${next}`
+ `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/verify?token=${token_hash}&type=${type}&redirect_to=${encodeURIComponent(next)}`
+ )
+
+ l.info(
+ {
+ key: 'auth_confirm:redirect_to_supabase_client_flow',
+ context: {
+ supabaseClientFlowUrl: supabaseClientFlowUrl.toString(),
+ next,
+ },
+ },
+ `redirecting to supabase client flow: ${supabaseClientFlowUrl.toString()}`
)
+
throw redirect(supabaseClientFlowUrl.toString())
}
@@ -92,14 +107,17 @@ export async function GET(request: NextRequest) {
confirmPageUrl.searchParams.set('type', type)
confirmPageUrl.searchParams.set('next', next)
- l.info({
- key: 'auth_confirm:redirect_to_confirm_page',
- context: {
- tokenHashPrefix: token_hash.slice(0, 10),
- type,
- confirmPageUrl: confirmPageUrl.toString(),
+ l.info(
+ {
+ key: 'auth_confirm:redirect_to_confirm_page',
+ context: {
+ tokenHashPrefix: token_hash.slice(0, 10),
+ type,
+ confirmPageUrl: confirmPageUrl.toString(),
+ },
},
- })
+ `redirecting to confirm page: ${confirmPageUrl.toString()}`
+ )
throw redirect(confirmPageUrl.toString())
}
diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts
new file mode 100644
index 000000000..d9cce08c2
--- /dev/null
+++ b/src/app/api/auth/verify-otp/route.ts
@@ -0,0 +1,117 @@
+import { PROTECTED_URLS } from '@/configs/urls'
+import { l } from '@/lib/clients/logger/logger'
+import {
+ ConfirmEmailInputSchema,
+ type OtpType,
+} from '@/server/api/models/auth.models'
+import { authRepo } from '@/server/api/repositories/auth.repository'
+import { NextRequest, NextResponse } from 'next/server'
+
+/**
+ * Determines the redirect URL based on OTP type and the original next parameter.
+ */
+function buildRedirectUrl(type: OtpType, next: string): string {
+ const redirectUrl = new URL(next)
+
+ // recovery flow always goes to account settings with reauth flag
+ if (type === 'recovery') {
+ redirectUrl.pathname = PROTECTED_URLS.RESET_PASSWORD
+ redirectUrl.searchParams.set('reauth', '1')
+ return redirectUrl.toString()
+ }
+
+ // reauth flow for account settings
+ if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) {
+ redirectUrl.searchParams.set('reauth', '1')
+ return redirectUrl.toString()
+ }
+
+ return next
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+
+ const result = ConfirmEmailInputSchema.safeParse(body)
+
+ if (!result.success) {
+ l.error(
+ {
+ key: 'verify_otp:invalid_input',
+ error: result.error.flatten(),
+ },
+ 'invalid input for verify OTP'
+ )
+
+ return NextResponse.json(
+ { error: 'Invalid request' },
+ { status: 400 }
+ )
+ }
+
+ const { token_hash, type, next } = result.data
+
+ l.info(
+ {
+ key: 'verify_otp:init',
+ context: {
+ type,
+ tokenHashPrefix: token_hash.slice(0, 10),
+ next,
+ },
+ },
+ `verifying OTP token: ${token_hash.slice(0, 10)}`
+ )
+
+ const { userId } = await authRepo.verifyOtp(token_hash, type)
+
+ const redirectUrl = buildRedirectUrl(type, next)
+
+ l.info(
+ {
+ key: 'verify_otp:success',
+ user_id: userId,
+ context: {
+ type,
+ redirectUrl,
+ },
+ },
+ `OTP verified for user: ${userId}, redirecting to: ${redirectUrl}`
+ )
+
+ return NextResponse.json({ redirectUrl })
+ } catch (error) {
+ // handle known errors from repository
+ if (error && typeof error === 'object' && 'message' in error) {
+ const message = (error as { message: string }).message
+
+ l.error(
+ {
+ key: 'verify_otp:error',
+ error: message,
+ },
+ `verify OTP failed: ${message}`
+ )
+
+ return NextResponse.json(
+ { error: message },
+ { status: 400 }
+ )
+ }
+
+ l.error(
+ {
+ key: 'verify_otp:unknown_error',
+ error: String(error),
+ },
+ 'verify OTP failed with unknown error'
+ )
+
+ return NextResponse.json(
+ { error: 'Verification failed. Please try again.' },
+ { status: 500 }
+ )
+ }
+}
+
diff --git a/src/server/api/repositories/auth.repository.ts b/src/server/api/repositories/auth.repository.ts
index 01a2f626f..b98e75db0 100644
--- a/src/server/api/repositories/auth.repository.ts
+++ b/src/server/api/repositories/auth.repository.ts
@@ -9,7 +9,8 @@ interface VerifyOtpResult {
}
/**
- * Verifies an OTP token with Supabase Auth
+ * Verifies an OTP token with Supabase Auth.
+ * Creates a session and sets auth cookies on success.
* @throws TRPCError on verification failure
*/
async function verifyOtp(
@@ -38,7 +39,6 @@ async function verifyOtp(
`failed to verify OTP: ${error.message}`
)
- // map supabase errors to user-friendly messages
if (error.status === 403 && error.code === 'otp_expired') {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -70,6 +70,38 @@ async function verifyOtp(
})
}
+ // verify session was created (cookies should be set by supabase client)
+ const hasSession = !!data.session
+ const hasAccessToken = !!data.session?.access_token
+ const hasRefreshToken = !!data.session?.refresh_token
+
+ l.info(
+ {
+ key: 'auth_repository:verify_otp:success',
+ user_id: data.user.id,
+ context: {
+ type,
+ tokenHashPrefix: tokenHash.slice(0, 10),
+ hasSession,
+ hasAccessToken,
+ hasRefreshToken,
+ sessionExpiresAt: data.session?.expires_at,
+ },
+ },
+ `verified OTP for user: ${data.user.id}, session: ${hasSession}`
+ )
+
+ if (!hasSession) {
+ l.warn(
+ {
+ key: 'auth_repository:verify_otp:no_session',
+ user_id: data.user.id,
+ context: { type, tokenHashPrefix: tokenHash.slice(0, 10) },
+ },
+ `OTP verified but no session returned - user may not be signed in`
+ )
+ }
+
return {
userId: data.user.id,
}
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts
deleted file mode 100644
index 7d6f3dee6..000000000
--- a/src/server/api/routers/auth.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { PROTECTED_URLS } from '@/configs/urls'
-import { createTRPCRouter } from '../init'
-import {
- ConfirmEmailInputSchema,
- type ConfirmEmailResult,
- type OtpType,
-} from '../models/auth.models'
-import { publicProcedure } from '../procedures'
-import { authRepo } from '../repositories/auth.repository'
-
-/**
- * Determines the redirect URL based on OTP type and the original next parameter.
- */
-function buildRedirectUrl(type: OtpType, next: string): string {
- const redirectUrl = new URL(next)
-
- // recovery flow always goes to account settings with reauth flag
- if (type === 'recovery') {
- redirectUrl.pathname = PROTECTED_URLS.RESET_PASSWORD
- redirectUrl.searchParams.set('reauth', '1')
- return redirectUrl.toString()
- }
-
- // reauth flow for account settings
- if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) {
- redirectUrl.searchParams.set('reauth', '1')
- return redirectUrl.toString()
- }
-
- return next
-}
-
-export const authRouter = createTRPCRouter({
- confirmEmail: publicProcedure
- .input(ConfirmEmailInputSchema)
- .mutation(async ({ input }): Promise => {
- const { token_hash, type, next } = input
-
- await authRepo.verifyOtp(token_hash, type)
-
- const redirectUrl = buildRedirectUrl(type, next)
-
- return { redirectUrl }
- }),
-})
diff --git a/src/server/api/routers/index.ts b/src/server/api/routers/index.ts
index 02349648c..85602dca8 100644
--- a/src/server/api/routers/index.ts
+++ b/src/server/api/routers/index.ts
@@ -1,11 +1,9 @@
import { createCallerFactory, createTRPCRouter } from '../init'
-import { authRouter } from './auth'
import { buildsRouter } from './builds'
import { sandboxesRouter } from './sandboxes'
import { templatesRouter } from './templates'
export const trpcAppRouter = createTRPCRouter({
- auth: authRouter,
sandboxes: sandboxesRouter,
templates: templatesRouter,
builds: buildsRouter,
From a1445c378a156de5915c2c82913c0cabb57876d9 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 5 Dec 2025 22:46:44 +0100
Subject: [PATCH 04/68] Refactor OTP verification response handling and enhance
error management
- Updated the `VerifyOtpResponse` interface to require a `redirectUrl` for successful OTP verification.
- Simplified error handling in the `verifyOtp` function to throw a generic error message when verification fails.
- Improved the confirmation page's mutation success handling to directly push the `redirectUrl` without additional checks.
- Introduced a new function to build error redirect URLs, enhancing user feedback for invalid verification attempts.
These changes streamline the OTP verification process and improve user experience during authentication.
---
src/app/(auth)/confirm/page.tsx | 15 ++++-----
src/app/api/auth/verify-otp/route.ts | 49 ++++++++++++++--------------
2 files changed, 31 insertions(+), 33 deletions(-)
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
index 12d03d79e..e74a27210 100644
--- a/src/app/(auth)/confirm/page.tsx
+++ b/src/app/(auth)/confirm/page.tsx
@@ -31,8 +31,7 @@ const OTP_TYPE_DESCRIPTIONS: Record = {
}
interface VerifyOtpResponse {
- redirectUrl?: string
- error?: string
+ redirectUrl: string
}
async function verifyOtp(input: ConfirmEmailInput): Promise {
@@ -44,8 +43,8 @@ async function verifyOtp(input: ConfirmEmailInput): Promise {
const data: VerifyOtpResponse = await response.json()
- if (!response.ok || data.error) {
- throw new Error(data.error || 'Verification failed. Please try again.')
+ if (!data.redirectUrl) {
+ throw new Error('Verification failed. Please try again.')
}
return data
@@ -78,11 +77,9 @@ export default function ConfirmPage() {
const mutation = useMutation({
mutationFn: verifyOtp,
onSuccess: (data) => {
- if (data.redirectUrl) {
- startTransition(() => {
- router.push(data.redirectUrl!)
- })
- }
+ startTransition(() => {
+ router.push(data.redirectUrl)
+ })
},
})
diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts
index d9cce08c2..8dd0604be 100644
--- a/src/app/api/auth/verify-otp/route.ts
+++ b/src/app/api/auth/verify-otp/route.ts
@@ -1,4 +1,4 @@
-import { PROTECTED_URLS } from '@/configs/urls'
+import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { l } from '@/lib/clients/logger/logger'
import {
ConfirmEmailInputSchema,
@@ -29,7 +29,18 @@ function buildRedirectUrl(type: OtpType, next: string): string {
return next
}
+/**
+ * Builds a redirect URL to sign-in with an encoded error message.
+ */
+function buildErrorRedirectUrl(origin: string, message: string): string {
+ const url = new URL(origin + AUTH_URLS.SIGN_IN)
+ url.searchParams.set('error', encodeURIComponent(message))
+ return url.toString()
+}
+
export async function POST(request: NextRequest) {
+ const origin = request.nextUrl.origin
+
try {
const body = await request.json()
@@ -44,10 +55,12 @@ export async function POST(request: NextRequest) {
'invalid input for verify OTP'
)
- return NextResponse.json(
- { error: 'Invalid request' },
- { status: 400 }
+ const errorRedirectUrl = buildErrorRedirectUrl(
+ origin,
+ 'Invalid verification link. Please request a new one.'
)
+
+ return NextResponse.json({ redirectUrl: errorRedirectUrl })
}
const { token_hash, type, next } = result.data
@@ -68,18 +81,6 @@ export async function POST(request: NextRequest) {
const redirectUrl = buildRedirectUrl(type, next)
- l.info(
- {
- key: 'verify_otp:success',
- user_id: userId,
- context: {
- type,
- redirectUrl,
- },
- },
- `OTP verified for user: ${userId}, redirecting to: ${redirectUrl}`
- )
-
return NextResponse.json({ redirectUrl })
} catch (error) {
// handle known errors from repository
@@ -94,10 +95,9 @@ export async function POST(request: NextRequest) {
`verify OTP failed: ${message}`
)
- return NextResponse.json(
- { error: message },
- { status: 400 }
- )
+ const errorRedirectUrl = buildErrorRedirectUrl(origin, message)
+
+ return NextResponse.json({ redirectUrl: errorRedirectUrl })
}
l.error(
@@ -108,10 +108,11 @@ export async function POST(request: NextRequest) {
'verify OTP failed with unknown error'
)
- return NextResponse.json(
- { error: 'Verification failed. Please try again.' },
- { status: 500 }
+ const errorRedirectUrl = buildErrorRedirectUrl(
+ origin,
+ 'Verification failed. Please try again.'
)
+
+ return NextResponse.json({ redirectUrl: errorRedirectUrl })
}
}
-
From f8d126edf3a05f9f5fbad9bb7c99615fe0c54399 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 5 Dec 2025 23:00:12 +0100
Subject: [PATCH 05/68] Enhance OTP verification process with improved redirect
handling and security
- Updated the `buildRedirectUrl` function to utilize the dashboard origin for safe redirects, preventing open redirect vulnerabilities.
- Introduced a check for external origins in the OTP verification process, rejecting requests from untrusted sources.
- Enhanced error logging for invalid OTP requests and improved user feedback with a dedicated error redirect URL.
- Simplified the handling of search parameters in the redirect URL construction.
These changes strengthen the security and user experience during the OTP verification flow.
---
src/app/api/auth/verify-otp/route.ts | 63 ++++++++++++++++++++++++----
1 file changed, 54 insertions(+), 9 deletions(-)
diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts
index 8dd0604be..eedf12f10 100644
--- a/src/app/api/auth/verify-otp/route.ts
+++ b/src/app/api/auth/verify-otp/route.ts
@@ -6,14 +6,36 @@ import {
} from '@/server/api/models/auth.models'
import { authRepo } from '@/server/api/repositories/auth.repository'
import { NextRequest, NextResponse } from 'next/server'
+import { flattenError } from 'zod'
+
+const normalizeOrigin = (origin: string) =>
+ origin.replace('www.', '').replace(/\/$/, '')
+
+function isExternalOrigin(next: string, dashboardOrigin: string): boolean {
+ return (
+ normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
+ )
+}
/**
- * Determines the redirect URL based on OTP type and the original next parameter.
+ * Determines the redirect URL based on OTP type.
+ * Uses dashboard origin to build safe redirect URLs, only preserving the pathname from next.
*/
-function buildRedirectUrl(type: OtpType, next: string): string {
- const redirectUrl = new URL(next)
-
- // recovery flow always goes to account settings with reauth flag
+function buildRedirectUrl(
+ type: OtpType,
+ next: string,
+ dashboardOrigin: string
+): string {
+ const nextUrl = new URL(next)
+ // always use dashboard origin to prevent open redirects
+ const redirectUrl = new URL(dashboardOrigin)
+ redirectUrl.pathname = nextUrl.pathname
+ // preserve search params from original next URL
+ nextUrl.searchParams.forEach((value, key) => {
+ redirectUrl.searchParams.set(key, value)
+ })
+
+ // recovery flow always goes to reset password with reauth flag
if (type === 'recovery') {
redirectUrl.pathname = PROTECTED_URLS.RESET_PASSWORD
redirectUrl.searchParams.set('reauth', '1')
@@ -26,7 +48,7 @@ function buildRedirectUrl(type: OtpType, next: string): string {
return redirectUrl.toString()
}
- return next
+ return redirectUrl.toString()
}
/**
@@ -50,7 +72,7 @@ export async function POST(request: NextRequest) {
l.error(
{
key: 'verify_otp:invalid_input',
- error: result.error.flatten(),
+ error: flattenError(result.error),
},
'invalid input for verify OTP'
)
@@ -65,6 +87,29 @@ export async function POST(request: NextRequest) {
const { token_hash, type, next } = result.data
+ // reject external origins to prevent open redirect attacks
+ if (isExternalOrigin(next, origin)) {
+ l.warn(
+ {
+ key: 'verify_otp:external_origin_rejected',
+ context: {
+ type,
+ tokenHashPrefix: token_hash.slice(0, 10),
+ nextOrigin: new URL(next).origin,
+ dashboardOrigin: origin,
+ },
+ },
+ `rejected verify OTP request with external origin: ${new URL(next).origin}`
+ )
+
+ const errorRedirectUrl = buildErrorRedirectUrl(
+ origin,
+ 'Invalid verification link. Please request a new one.'
+ )
+
+ return NextResponse.json({ redirectUrl: errorRedirectUrl })
+ }
+
l.info(
{
key: 'verify_otp:init',
@@ -77,9 +122,9 @@ export async function POST(request: NextRequest) {
`verifying OTP token: ${token_hash.slice(0, 10)}`
)
- const { userId } = await authRepo.verifyOtp(token_hash, type)
+ await authRepo.verifyOtp(token_hash, type)
- const redirectUrl = buildRedirectUrl(type, next)
+ const redirectUrl = buildRedirectUrl(type, next, origin)
return NextResponse.json({ redirectUrl })
} catch (error) {
From aa6f28be31ccf0c74b0a718c7c223d9194af089c Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 5 Dec 2025 23:01:40 +0100
Subject: [PATCH 06/68] chore
---
src/app/(auth)/confirm/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
index e74a27210..16761d9b5 100644
--- a/src/app/(auth)/confirm/page.tsx
+++ b/src/app/(auth)/confirm/page.tsx
@@ -105,7 +105,7 @@ export default function ConfirmPage() {
disabled={!isValidParams}
className="w-full"
>
- Confirm
+ Continue
From b9a3e87b63bbb2a88c0dec569f19529547bab39c Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Sat, 6 Dec 2025 11:18:12 +0100
Subject: [PATCH 07/68] fix: possible undefined ui behavior
---
src/app/(auth)/confirm/page.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
index 16761d9b5..80e8fea28 100644
--- a/src/app/(auth)/confirm/page.tsx
+++ b/src/app/(auth)/confirm/page.tsx
@@ -4,6 +4,7 @@ import { AUTH_URLS } from '@/configs/urls'
import { AuthFormMessage } from '@/features/auth/form-message'
import {
ConfirmEmailInputSchema,
+ OtpTypeSchema,
type ConfirmEmailInput,
type OtpType,
} from '@/server/api/models/auth.models'
@@ -57,7 +58,9 @@ export default function ConfirmPage() {
const params = useMemo(() => {
const tokenHash = searchParams.get('token_hash') ?? ''
- const type = searchParams.get('type') as OtpType | null
+ const typeParam = searchParams.get('type')
+ const typeResult = OtpTypeSchema.safeParse(typeParam)
+ const type: OtpType | null = typeResult.success ? typeResult.data : null
const next = searchParams.get('next') ?? ''
return { tokenHash, type, next }
From b4cef9c18206585435a171a5f4531af179cac569 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Sat, 6 Dec 2025 13:12:12 +0100
Subject: [PATCH 08/68] Refactor authentication utility functions and enhance
origin checks
- Moved the `normalizeOrigin` and `isExternalOrigin` functions to the `auth.ts` utility file for better organization and reusability.
- Updated the `confirm` and `verify-otp` routes to utilize the new `isExternalOrigin` function, improving the handling of redirect URLs.
- Removed redundant implementations of origin normalization from the routes, streamlining the codebase.
These changes improve code maintainability and enhance security checks during the authentication process.
---
src/app/api/auth/confirm/route.ts | 11 +----------
src/app/api/auth/verify-otp/route.ts | 10 +---------
src/lib/utils/auth.ts | 19 +++++++++++++++++++
3 files changed, 21 insertions(+), 19 deletions(-)
diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts
index e52b5d148..9f7923977 100644
--- a/src/app/api/auth/confirm/route.ts
+++ b/src/app/api/auth/confirm/route.ts
@@ -1,6 +1,6 @@
import { AUTH_URLS } from '@/configs/urls'
import { l } from '@/lib/clients/logger/logger'
-import { encodedRedirect } from '@/lib/utils/auth'
+import { encodedRedirect, isExternalOrigin } from '@/lib/utils/auth'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
import { z } from 'zod'
@@ -18,15 +18,6 @@ const confirmSchema = z.object({
next: z.httpUrl(),
})
-const normalizeOrigin = (origin: string) =>
- origin.replace('www.', '').replace(/\/$/, '')
-
-function isExternalOrigin(next: string, dashboardOrigin: string): boolean {
- return (
- normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
- )
-}
-
/**
* This route acts as an intermediary for email OTP verification.
*
diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts
index eedf12f10..2292b3dff 100644
--- a/src/app/api/auth/verify-otp/route.ts
+++ b/src/app/api/auth/verify-otp/route.ts
@@ -1,5 +1,6 @@
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { l } from '@/lib/clients/logger/logger'
+import { isExternalOrigin } from '@/lib/utils/auth'
import {
ConfirmEmailInputSchema,
type OtpType,
@@ -8,15 +9,6 @@ import { authRepo } from '@/server/api/repositories/auth.repository'
import { NextRequest, NextResponse } from 'next/server'
import { flattenError } from 'zod'
-const normalizeOrigin = (origin: string) =>
- origin.replace('www.', '').replace(/\/$/, '')
-
-function isExternalOrigin(next: string, dashboardOrigin: string): boolean {
- return (
- normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
- )
-}
-
/**
* Determines the redirect URL based on OTP type.
* Uses dashboard origin to build safe redirect URLs, only preserving the pathname from next.
diff --git a/src/lib/utils/auth.ts b/src/lib/utils/auth.ts
index d8282dfd4..9c8fa437b 100644
--- a/src/lib/utils/auth.ts
+++ b/src/lib/utils/auth.ts
@@ -28,3 +28,22 @@ export function encodedRedirect(
export function getUserProviders(user: User) {
return user.app_metadata.providers as string[] | undefined
}
+
+/**
+ * Normalizes an origin URL by removing 'www.' prefix and trailing slash.
+ */
+export function normalizeOrigin(origin: string): string {
+ return origin.replace('www.', '').replace(/\/$/, '')
+}
+
+/**
+ * Checks if the redirect URL points to a different origin than the dashboard.
+ */
+export function isExternalOrigin(
+ next: string,
+ dashboardOrigin: string
+): boolean {
+ return (
+ normalizeOrigin(new URL(next).origin) !== normalizeOrigin(dashboardOrigin)
+ )
+}
From bfedbe93ffdcff04fba899b23fb14e21550cabd6 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Sat, 6 Dec 2025 13:32:17 +0100
Subject: [PATCH 09/68] chore: cleanup
---
src/app/api/auth/confirm/route.ts | 22 ++++++-------------
src/app/api/auth/verify-otp/route.ts | 14 ++++--------
src/server/api/models/auth.models.ts | 3 ---
.../api/repositories/auth.repository.ts | 22 +++++++++----------
4 files changed, 22 insertions(+), 39 deletions(-)
diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts
index 9f7923977..b5400fbe1 100644
--- a/src/app/api/auth/confirm/route.ts
+++ b/src/app/api/auth/confirm/route.ts
@@ -1,20 +1,14 @@
import { AUTH_URLS } from '@/configs/urls'
import { l } from '@/lib/clients/logger/logger'
import { encodedRedirect, isExternalOrigin } from '@/lib/utils/auth'
+import { OtpTypeSchema } from '@/server/api/models/auth.models'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
import { z } from 'zod'
const confirmSchema = z.object({
token_hash: z.string().min(1),
- type: z.enum([
- 'signup',
- 'recovery',
- 'invite',
- 'magiclink',
- 'email',
- 'email_change',
- ]),
+ type: OtpTypeSchema,
next: z.httpUrl(),
})
@@ -62,17 +56,16 @@ export async function GET(request: NextRequest) {
{
key: 'auth_confirm:init',
context: {
- tokenHashPrefix: token_hash.slice(0, 10),
+ token_hash_prefix: token_hash.slice(0, 10),
type,
next,
- isDifferentOrigin,
+ is_different_origin: isDifferentOrigin,
origin: dashboardOrigin,
},
},
`confirming email with OTP token hash: ${token_hash.slice(0, 10)}`
)
- // external origin: redirect to supabase client flow for the external app to handle
if (isDifferentOrigin) {
const supabaseClientFlowUrl = new URL(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/verify?token=${token_hash}&type=${type}&redirect_to=${encodeURIComponent(next)}`
@@ -82,7 +75,7 @@ export async function GET(request: NextRequest) {
{
key: 'auth_confirm:redirect_to_supabase_client_flow',
context: {
- supabaseClientFlowUrl: supabaseClientFlowUrl.toString(),
+ supabase_client_flow_url: supabaseClientFlowUrl.toString(),
next,
},
},
@@ -92,7 +85,6 @@ export async function GET(request: NextRequest) {
throw redirect(supabaseClientFlowUrl.toString())
}
- // same origin: redirect to /confirm page for user to explicitly confirm
const confirmPageUrl = new URL(dashboardOrigin + AUTH_URLS.CONFIRM)
confirmPageUrl.searchParams.set('token_hash', token_hash)
confirmPageUrl.searchParams.set('type', type)
@@ -102,9 +94,9 @@ export async function GET(request: NextRequest) {
{
key: 'auth_confirm:redirect_to_confirm_page',
context: {
- tokenHashPrefix: token_hash.slice(0, 10),
+ token_hash_prefix: token_hash.slice(0, 10),
type,
- confirmPageUrl: confirmPageUrl.toString(),
+ confirm_page_url: confirmPageUrl.toString(),
},
},
`redirecting to confirm page: ${confirmPageUrl.toString()}`
diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts
index 2292b3dff..0ca59e12d 100644
--- a/src/app/api/auth/verify-otp/route.ts
+++ b/src/app/api/auth/verify-otp/route.ts
@@ -19,22 +19,18 @@ function buildRedirectUrl(
dashboardOrigin: string
): string {
const nextUrl = new URL(next)
- // always use dashboard origin to prevent open redirects
const redirectUrl = new URL(dashboardOrigin)
redirectUrl.pathname = nextUrl.pathname
- // preserve search params from original next URL
nextUrl.searchParams.forEach((value, key) => {
redirectUrl.searchParams.set(key, value)
})
- // recovery flow always goes to reset password with reauth flag
if (type === 'recovery') {
redirectUrl.pathname = PROTECTED_URLS.RESET_PASSWORD
redirectUrl.searchParams.set('reauth', '1')
return redirectUrl.toString()
}
- // reauth flow for account settings
if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) {
redirectUrl.searchParams.set('reauth', '1')
return redirectUrl.toString()
@@ -79,16 +75,15 @@ export async function POST(request: NextRequest) {
const { token_hash, type, next } = result.data
- // reject external origins to prevent open redirect attacks
if (isExternalOrigin(next, origin)) {
l.warn(
{
key: 'verify_otp:external_origin_rejected',
context: {
type,
- tokenHashPrefix: token_hash.slice(0, 10),
- nextOrigin: new URL(next).origin,
- dashboardOrigin: origin,
+ token_hash_prefix: token_hash.slice(0, 10),
+ next_origin: new URL(next).origin,
+ dashboard_origin: origin,
},
},
`rejected verify OTP request with external origin: ${new URL(next).origin}`
@@ -107,7 +102,7 @@ export async function POST(request: NextRequest) {
key: 'verify_otp:init',
context: {
type,
- tokenHashPrefix: token_hash.slice(0, 10),
+ token_hash_prefix: token_hash.slice(0, 10),
next,
},
},
@@ -120,7 +115,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ redirectUrl })
} catch (error) {
- // handle known errors from repository
if (error && typeof error === 'object' && 'message' in error) {
const message = (error as { message: string }).message
diff --git a/src/server/api/models/auth.models.ts b/src/server/api/models/auth.models.ts
index 41bdbf57e..3eaf6bf3b 100644
--- a/src/server/api/models/auth.models.ts
+++ b/src/server/api/models/auth.models.ts
@@ -1,6 +1,5 @@
import z from 'zod'
-// otp types supported by supabase
export const OtpTypeSchema = z.enum([
'signup',
'recovery',
@@ -12,7 +11,6 @@ export const OtpTypeSchema = z.enum([
export type OtpType = z.infer
-// shared schema for client form and tRPC router
export const ConfirmEmailInputSchema = z.object({
token_hash: z.string().min(1),
type: OtpTypeSchema,
@@ -21,7 +19,6 @@ export const ConfirmEmailInputSchema = z.object({
export type ConfirmEmailInput = z.infer
-// response types
export interface ConfirmEmailResult {
redirectUrl: string
}
diff --git a/src/server/api/repositories/auth.repository.ts b/src/server/api/repositories/auth.repository.ts
index b98e75db0..c52f4728f 100644
--- a/src/server/api/repositories/auth.repository.ts
+++ b/src/server/api/repositories/auth.repository.ts
@@ -31,9 +31,9 @@ async function verifyOtp(
error: serializeError(error),
context: {
type,
- tokenHashPrefix: tokenHash.slice(0, 10),
- errorCode: error.code,
- errorStatus: error.status,
+ token_hash_prefix: tokenHash.slice(0, 10),
+ error_code: error.code,
+ error_status: error.status,
},
},
`failed to verify OTP: ${error.message}`
@@ -58,10 +58,10 @@ async function verifyOtp(
key: 'auth_repository:verify_otp:no_user',
context: {
type,
- tokenHashPrefix: tokenHash.slice(0, 10),
+ token_hash_prefix: tokenHash.slice(0, 10),
},
},
- `failed to verify OTP: no user found for token hash: ${tokenHash.slice(0, 10)}`
+ `failed to verify OTP: no user found`
)
throw new TRPCError({
@@ -81,14 +81,14 @@ async function verifyOtp(
user_id: data.user.id,
context: {
type,
- tokenHashPrefix: tokenHash.slice(0, 10),
- hasSession,
- hasAccessToken,
- hasRefreshToken,
- sessionExpiresAt: data.session?.expires_at,
+ token_hash_prefix: tokenHash.slice(0, 10),
+ has_session: !!data.session,
+ has_access_token: !!data.session?.access_token,
+ has_refresh_token: !!data.session?.refresh_token,
+ session_expires_at: data.session?.expires_at,
},
},
- `verified OTP for user: ${data.user.id}, session: ${hasSession}`
+ `verified OTP for user: ${data.user.id}`
)
if (!hasSession) {
From a28a81a1adba21d81cff901189f958e004edb7d5 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Sat, 6 Dec 2025 13:44:43 +0100
Subject: [PATCH 10/68] chore: sync lroute and component contract
---
src/app/(auth)/confirm/page.tsx | 21 ++++++---------------
1 file changed, 6 insertions(+), 15 deletions(-)
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
index 80e8fea28..dbc0fdf9b 100644
--- a/src/app/(auth)/confirm/page.tsx
+++ b/src/app/(auth)/confirm/page.tsx
@@ -35,6 +35,10 @@ interface VerifyOtpResponse {
redirectUrl: string
}
+/**
+ * Verifies OTP and returns a redirect URL.
+ * The API always returns a redirectUrl - errors redirect to sign-in with encoded error params.
+ */
async function verifyOtp(input: ConfirmEmailInput): Promise {
const response = await fetch('/api/auth/verify-otp', {
method: 'POST',
@@ -42,13 +46,7 @@ async function verifyOtp(input: ConfirmEmailInput): Promise {
body: JSON.stringify(input),
})
- const data: VerifyOtpResponse = await response.json()
-
- if (!data.redirectUrl) {
- throw new Error('Verification failed. Please try again.')
- }
-
- return data
+ return response.json()
}
export default function ConfirmPage() {
@@ -124,7 +122,7 @@ export default function ConfirmPage() {
.
- {!isValidParams && !mutation.error && (
+ {!isValidParams && (
)}
-
- {mutation.error && (
-
- )}
)
}
From 3ebd3063b4d83584d6ab1f73ec3535f87b4f1a22 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Sat, 6 Dec 2025 14:10:56 +0100
Subject: [PATCH 11/68] improve: confirm page prose
---
src/app/(auth)/confirm/page.tsx | 35 ++++++++++++++++++++++-----------
1 file changed, 24 insertions(+), 11 deletions(-)
diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx
index dbc0fdf9b..071c34228 100644
--- a/src/app/(auth)/confirm/page.tsx
+++ b/src/app/(auth)/confirm/page.tsx
@@ -14,21 +14,31 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { useMemo, useTransition } from 'react'
const OTP_TYPE_LABELS: Record = {
- signup: 'Sign Up',
- recovery: 'Password Reset',
+ signup: 'Welcome to E2B',
+ recovery: 'Password Recovery',
invite: 'Team Invitation',
magiclink: 'Sign In',
- email: 'Email Verification',
- email_change: 'Email Change',
+ email: 'Verify Email',
+ email_change: 'Confirm Email Change',
}
const OTP_TYPE_DESCRIPTIONS: Record = {
- signup: 'Complete your account registration',
- recovery: 'Reset your password',
- invite: 'Accept team invitation',
- magiclink: 'Sign in to your account',
- email: 'Verify your email address',
- email_change: 'Confirm your new email address',
+ signup: 'Click below to verify your email and create your account.',
+ recovery:
+ 'Click below to sign in. You will be forwarded to the account settings page where you can change your password.',
+ invite: 'Click below to accept the invitation and join the team.',
+ magiclink: 'Click below to sign in to your account.',
+ email: 'Click below to verify your email address.',
+ email_change: 'Click below to confirm your new email address.',
+}
+
+const OTP_TYPE_BUTTON_LABELS: Record = {
+ signup: 'Create Account',
+ recovery: 'Sign In',
+ invite: 'Join Team',
+ magiclink: 'Sign In',
+ email: 'Verify Email',
+ email_change: 'Confirm Email',
}
interface VerifyOtpResponse {
@@ -74,6 +84,9 @@ export default function ConfirmPage() {
const typeDescription = params.type
? OTP_TYPE_DESCRIPTIONS[params.type]
: 'Confirm your action'
+ const buttonLabel = params.type
+ ? OTP_TYPE_BUTTON_LABELS[params.type]
+ : 'Continue'
const mutation = useMutation({
mutationFn: verifyOtp,
@@ -106,7 +119,7 @@ export default function ConfirmPage() {
disabled={!isValidParams}
className="w-full"
>
- Continue
+ {buttonLabel}
From d96ae85596484467bd2e44b3209a83ac7292c5cc Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Tue, 9 Dec 2025 21:38:59 +0100
Subject: [PATCH 12/68] wip: integrate components
---
bun.lock | 16 +-
package.json | 8 +-
.../dashboard/sandboxes/list/table-body.tsx | 10 +-
.../sandboxes/list/table-filters.tsx | 10 +-
.../components/range-label.tsx | 4 -
.../settings/webhooks/add-edit-dialog.tsx | 6 +-
.../settings/webhooks/edit-secret-dialog.tsx | 2 +-
.../dashboard/settings/webhooks/table-row.tsx | 2 +-
.../dashboard/sidebar/create-team-dialog.tsx | 40 +++--
src/features/dashboard/sidebar/toggle.tsx | 7 +-
.../dashboard/templates/builds/header.tsx | 6 +-
.../templates/list/table-filters.tsx | 2 +-
src/lib/hooks/use-clipboard.ts | 24 +--
src/ui/brand.tsx | 2 +-
src/ui/copy-button.tsx | 61 ++++---
src/ui/data-table.tsx | 17 +-
src/ui/icons.tsx | 2 +-
src/ui/not-found.tsx | 6 +-
src/ui/number-input.tsx | 8 +-
src/ui/polling-button.tsx | 3 +-
src/ui/primitives/badge.tsx | 34 ++--
src/ui/primitives/button.tsx | 112 ++++++-------
src/ui/primitives/calendar.tsx | 4 +-
src/ui/primitives/dialog.tsx | 43 +++--
src/ui/primitives/input.tsx | 2 +-
src/ui/primitives/popover.tsx | 81 +++++----
src/ui/primitives/radio-group.tsx | 42 ++++-
src/ui/primitives/sidebar.tsx | 2 +-
src/ui/primitives/sonner.tsx | 24 +--
src/ui/survey.tsx | 12 +-
src/ui/table-filter-button.tsx | 3 +-
src/ui/theme-switcher.tsx | 2 +-
src/ui/time-input.tsx | 154 +++++++++---------
src/ui/time-range-picker.tsx | 2 +-
34 files changed, 404 insertions(+), 349 deletions(-)
diff --git a/bun.lock b/bun.lock
index c38694ae4..53aaa39cf 100644
--- a/bun.lock
+++ b/bun.lock
@@ -20,16 +20,16 @@
"@opentelemetry/semantic-conventions": "^1.36.0",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.3",
- "@radix-ui/react-dialog": "^1.1.7",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3",
- "@radix-ui/react-popover": "^1.1.7",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
- "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-tooltip": "^1.2.0",
@@ -74,7 +74,7 @@
"immer": "^10.1.1",
"lucide-react": "^0.525.0",
"micromatch": "^4.0.8",
- "motion": "^12.18.1",
+ "motion": "^12.23.25",
"nanoid": "^5.0.9",
"next": "^16.0.7",
"next-safe-action": "^8.0.11",
@@ -156,11 +156,11 @@
},
},
"overrides": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "@nodelib/fs.stat": "2.0.5",
"@shikijs/core": "2.3.2",
"@shikijs/themes": "2.3.2",
"shiki": "2.3.2",
- "@nodelib/fs.stat": "2.0.5",
- "@nodelib/fs.scandir": "2.1.5",
"whatwg-url": "^13",
},
"packages": {
@@ -1772,7 +1772,7 @@
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
- "framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="],
+ "framer-motion": ["framer-motion@12.23.25", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ=="],
"fs-extra": ["fs-extra@4.0.3", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg=="],
@@ -2208,7 +2208,7 @@
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
- "motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="],
+ "motion": ["motion@12.23.25", "", { "dependencies": { "framer-motion": "^12.23.25", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Fk5Y1kcgxYiTYOUjmwfXQAP7tP+iGqw/on1UID9WEL/6KpzxPr9jY2169OsjgZvXJdpraKXy0orkjaCVIl5fgQ=="],
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
diff --git a/package.json b/package.json
index 56daeb0c2..c000df7e9 100644
--- a/package.json
+++ b/package.json
@@ -57,16 +57,16 @@
"@opentelemetry/semantic-conventions": "^1.36.0",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.3",
- "@radix-ui/react-dialog": "^1.1.7",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3",
- "@radix-ui/react-popover": "^1.1.7",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
- "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-tooltip": "^1.2.0",
@@ -111,7 +111,7 @@
"immer": "^10.1.1",
"lucide-react": "^0.525.0",
"micromatch": "^4.0.8",
- "motion": "^12.18.1",
+ "motion": "^12.23.25",
"nanoid": "^5.0.9",
"next": "^16.0.7",
"next-safe-action": "^8.0.11",
diff --git a/src/features/dashboard/sandboxes/list/table-body.tsx b/src/features/dashboard/sandboxes/list/table-body.tsx
index 97f388f5a..eebc82db4 100644
--- a/src/features/dashboard/sandboxes/list/table-body.tsx
+++ b/src/features/dashboard/sandboxes/list/table-body.tsx
@@ -2,8 +2,8 @@ import { Sandbox } from '@/types/api.types'
import { DataTableBody } from '@/ui/data-table'
import Empty from '@/ui/empty'
import { Button } from '@/ui/primitives/button'
+import { AddIcon, CloseIcon } from '@/ui/primitives/icons'
import { Row } from '@tanstack/react-table'
-import { ExternalLink, X } from 'lucide-react'
import { memo, useMemo } from 'react'
import { useSandboxTableStore } from './stores/table-store'
import { SandboxesTable, SandboxWithMetrics } from './table-config'
@@ -43,8 +43,8 @@ export const TableBody = memo(function TableBody({
title="No Results Found"
description="No sandboxes match your current filters"
message={
-