From 8540954569063f2119ca74d3ecc2eff9622ca040 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 15:45:12 -0700 Subject: [PATCH 01/14] Make dashboard terminal team scoped --- .../dashboard/[teamSlug]/terminal/page.tsx | 270 ++++++++++++++++ src/app/dashboard/terminal/page.tsx | 292 ++++-------------- src/configs/layout.ts | 4 + src/configs/urls.ts | 1 + src/core/server/proxy/handlers.ts | 21 +- tests/unit/proxy-handlers.test.ts | 13 + 6 files changed, 369 insertions(+), 232 deletions(-) create mode 100644 src/app/dashboard/[teamSlug]/terminal/page.tsx diff --git a/src/app/dashboard/[teamSlug]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx new file mode 100644 index 000000000..44100da38 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx @@ -0,0 +1,270 @@ +import Link from 'next/link' +import type { Metadata } from 'next/types' +import { authHeaders } from '@/configs/api' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { + createDefaultTemplatesRepository, + createTemplatesRepository, +} from '@/core/modules/templates/repository.server' +import { getAuthContext } from '@/core/server/auth' +import { infra } from '@/core/shared/clients/api' +import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' +import { SandboxIdSchema } from '@/core/shared/schemas/api' +import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' +import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' +import { Button } from '@/ui/primitives/button' + +export const metadata: Metadata = { + title: 'Terminal - E2B', + robots: 'noindex, nofollow', +} + +interface TeamTerminalPageProps { + params: Promise<{ + teamSlug: string + }> + searchParams: Promise<{ + command?: string + sandboxId?: string + template?: string + }> +} + +export default async function TeamTerminalPage({ + params, + searchParams, +}: TeamTerminalPageProps) { + const [{ teamSlug }, { command = '', sandboxId, template }] = + await Promise.all([params, searchParams]) + const terminalTemplate = normalizeTerminalTemplate(template) + const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) + + if (!terminalTemplate) { + return + } + + if (terminalSandboxId === null) { + return + } + + const authContext = await getAuthContext() + + if (!authContext) { + return ( + + ) + } + + const teamsRepository = createUserTeamsRepository({ + accessToken: authContext.accessToken, + }) + const teamsResult = await teamsRepository.listUserTeams() + + if (!teamsResult.ok) { + return + } + + const team = teamsResult.data.find((candidate) => candidate.slug === teamSlug) + + if (!team) { + return + } + + if ( + terminalSandboxId && + !(await hasSandboxInTeam({ + accessToken: authContext.accessToken, + sandboxId: terminalSandboxId, + teamId: team.id, + })) + ) { + return ( + + ) + } + + const templateAvailable = terminalSandboxId + ? { ok: true as const, available: true } + : await isTerminalTemplateAvailable({ + accessToken: authContext.accessToken, + teamId: team.id, + template: terminalTemplate, + }) + + if (!templateAvailable.ok) { + return ( + + ) + } + + if (!templateAvailable.available) { + return ( + + ) + } + + return ( +
+ +
+ ) +} + +function normalizeTerminalSandboxId(sandboxId?: string) { + const value = sandboxId?.trim() + if (!value) return undefined + + const parsedSandboxId = SandboxIdSchema.safeParse(value) + return parsedSandboxId.success ? parsedSandboxId.data : null +} + +async function hasSandboxInTeam({ + accessToken, + sandboxId, + teamId, +}: { + accessToken: string + sandboxId: string + teamId: string +}) { + try { + const result = await infra.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...authHeaders(accessToken, teamId), + }, + cache: 'no-store', + }) + + return result.response.ok && Boolean(result.data) + } catch { + return false + } +} + +async function isTerminalTemplateAvailable({ + accessToken, + teamId, + template, +}: { + accessToken: string + teamId: string + template: string +}) { + if (template === 'base') { + return { ok: true as const, available: true } + } + + const defaultTemplatesRepository = createDefaultTemplatesRepository({ + accessToken, + }) + const teamTemplatesRepository = createTemplatesRepository({ + accessToken, + teamId, + }) + const [defaultTemplates, teamTemplates] = await Promise.all([ + defaultTemplatesRepository.getDefaultTemplatesCached(), + teamTemplatesRepository.getTeamTemplates(), + ]) + + if (!defaultTemplates.ok || !teamTemplates.ok) { + return { ok: false as const } + } + + const templates = [ + ...defaultTemplates.data.templates, + ...teamTemplates.data.templates, + ] + + return { + ok: true as const, + available: templates.some((candidate) => + [ + candidate.templateID, + ...(candidate.aliases ?? []), + ...(candidate.names ?? []), + ].includes(template) + ), + } +} + +function TerminalSignIn({ + command, + sandboxId, + teamSlug, + template, +}: { + command?: string + sandboxId?: string + teamSlug: string + template: string +}) { + const returnToQuery = new URLSearchParams({ + ...(command ? { command } : {}), + ...(sandboxId ? { sandboxId } : {}), + template, + }).toString() + const returnTo = `${PROTECTED_URLS.TEAM_TERMINAL(teamSlug)}${ + returnToQuery ? `?${returnToQuery}` : '' + }` + const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ + returnTo, + }).toString()}` + + return ( +
+
+
+

Sign in to open a terminal

+

+ The terminal runs in your E2B dashboard account. +

+
+ +
+
+ ) +} + +function TerminalUnavailable({ + message = 'We could not resolve a dashboard team for this account.', +}: { + message?: string +}) { + return ( +
+
+

Terminal unavailable

+

{message}

+
+
+ ) +} diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index 60d40b4b9..20dfe9d0e 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -1,26 +1,12 @@ -import Link from 'next/link' -import type { Metadata } from 'next/types' +import { redirect } from 'next/navigation' import { authHeaders } from '@/configs/api' -import { AUTH_URLS } from '@/configs/urls' -import type { TeamModel } from '@/core/modules/teams/models' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' -import { - createDefaultTemplatesRepository, - createTemplatesRepository, -} from '@/core/modules/templates/repository.server' import { getAuthContext } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' import { infra } from '@/core/shared/clients/api' -import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import { SandboxIdSchema } from '@/core/shared/schemas/api' -import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' -import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' -import { Button } from '@/ui/primitives/button' - -export const metadata: Metadata = { - title: 'Terminal - E2B', - robots: 'noindex, nofollow', -} interface TerminalPageProps { searchParams: Promise<{ @@ -33,150 +19,111 @@ interface TerminalPageProps { export default async function TerminalPage({ searchParams, }: TerminalPageProps) { - const { command = '', sandboxId, template } = await searchParams - const terminalTemplate = normalizeTerminalTemplate(template) - const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) - - if (!terminalTemplate) { - return - } - - if (terminalSandboxId === null) { - return - } + const { command, sandboxId, template } = await searchParams + const queryString = buildTerminalQueryString({ command, sandboxId, template }) const authContext = await getAuthContext() if (!authContext) { - return ( - + const returnTo = `/dashboard/terminal${queryString ? `?${queryString}` : ''}` + redirect( + `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ returnTo }).toString()}` ) } - const teamsRepository = createUserTeamsRepository({ - accessToken: authContext.accessToken, - }) - const teamsResult = await teamsRepository.listUserTeams() - - if (!teamsResult.ok) { - return - } - const resolvedTeam = await resolveUserTeam( authContext.user.id, authContext.accessToken ) - const team = terminalSandboxId - ? await resolveTerminalSandboxTeam({ + const team = sandboxId + ? await resolveSandboxTeam({ accessToken: authContext.accessToken, - preferredTeamId: resolvedTeam?.id, - sandboxId: terminalSandboxId, - teams: teamsResult.data, + preferredTeam: resolvedTeam, + sandboxId, }) - : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id) + : resolvedTeam if (!team) { - return ( - - ) - } - - const templateAvailable = terminalSandboxId - ? { ok: true as const, available: true } - : await isTerminalTemplateAvailable({ - accessToken: authContext.accessToken, - teamId: team.id, - template: terminalTemplate, - }) - - if (!templateAvailable.ok) { - return ( - - ) + redirect(PROTECTED_URLS.DASHBOARD) } - if (!templateAvailable.available) { - return ( - - ) - } - - return ( -
- -
+ redirect( + `${PROTECTED_URLS.TEAM_TERMINAL(team.slug)}${ + queryString ? `?${queryString}` : '' + }` ) } -function normalizeTerminalSandboxId(sandboxId?: string) { - const value = sandboxId?.trim() - if (!value) return undefined +function buildTerminalQueryString({ + command, + sandboxId, + template, +}: { + command?: string + sandboxId?: string + template?: string +}) { + const params = new URLSearchParams() + + if (command) params.set('command', command) + if (template) params.set('template', template) + if (sandboxId) params.set('sandboxId', sandboxId) - const parsedSandboxId = SandboxIdSchema.safeParse(value) - return parsedSandboxId.success ? parsedSandboxId.data : null + return params.toString() } -async function resolveTerminalSandboxTeam({ +async function resolveSandboxTeam({ accessToken, - preferredTeamId, + preferredTeam, sandboxId, - teams, }: { accessToken: string - preferredTeamId?: string + preferredTeam: ResolvedTeam | null sandboxId: string - teams: TeamModel[] }) { - if (preferredTeamId) { - const preferredTeam = teams.find((team) => team.id === preferredTeamId) - if ( - preferredTeam && - (await hasSandboxInTeam({ - accessToken, - sandboxId, - teamId: preferredTeam.id, - })) - ) { - return preferredTeam - } + const parsedSandboxId = SandboxIdSchema.safeParse(sandboxId.trim()) + if (!parsedSandboxId.success) return preferredTeam + + if ( + preferredTeam && + (await hasSandboxInTeam({ + accessToken, + sandboxId: parsedSandboxId.data, + teamId: preferredTeam.id, + })) + ) { + return preferredTeam } - const candidateTeams = teams.filter((team) => team.id !== preferredTeamId) + const teamsRepository = createUserTeamsRepository({ accessToken }) + const teamsResult = await teamsRepository.listUserTeams() + if (!teamsResult.ok) return preferredTeam + + const candidateTeams = teamsResult.data.filter( + (team) => team.id !== preferredTeam?.id + ) const teamMatches = await Promise.all( candidateTeams.map(async (team) => ({ team, ownsSandbox: await hasSandboxInTeam({ accessToken, - sandboxId, + sandboxId: parsedSandboxId.data, teamId: team.id, }), })) ) - return teamMatches.find((match) => match.ownsSandbox)?.team ?? null + const match = teamMatches.find(({ ownsSandbox }) => ownsSandbox) + return match ? toResolvedTeam(match.team) : preferredTeam +} + +function toResolvedTeam(team: TeamModel): ResolvedTeam | null { + return team.slug + ? { + id: team.id, + slug: team.slug, + } + : null } async function hasSandboxInTeam({ @@ -206,108 +153,3 @@ async function hasSandboxInTeam({ return false } } - -async function isTerminalTemplateAvailable({ - accessToken, - teamId, - template, -}: { - accessToken: string - teamId: string - template: string -}) { - if (template === 'base') { - return { ok: true as const, available: true } - } - - const defaultTemplatesRepository = createDefaultTemplatesRepository({ - accessToken, - }) - const teamTemplatesRepository = createTemplatesRepository({ - accessToken, - teamId, - }) - const [defaultTemplates, teamTemplates] = await Promise.all([ - defaultTemplatesRepository.getDefaultTemplatesCached(), - teamTemplatesRepository.getTeamTemplates(), - ]) - - if (!defaultTemplates.ok || !teamTemplates.ok) { - return { ok: false as const } - } - - const templates = [ - ...defaultTemplates.data.templates, - ...teamTemplates.data.templates, - ] - - return { - ok: true as const, - available: templates.some((candidate) => - [ - candidate.templateID, - ...(candidate.aliases ?? []), - ...(candidate.names ?? []), - ].includes(template) - ), - } -} - -function TerminalSignIn({ - sandboxId, - template, -}: { - sandboxId?: string - template: string -}) { - const returnToParams = new URLSearchParams() - - if (template) { - returnToParams.set('template', template) - } - - if (sandboxId) { - returnToParams.set('sandboxId', sandboxId) - } - - const returnToQuery = returnToParams.toString() - const returnTo = `/dashboard/terminal${ - returnToQuery ? `?${returnToQuery}` : '' - }` - const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ - returnTo, - }).toString()}` - - return ( -
-
-
-

Sign in to open a terminal

-

- The terminal runs in your E2B dashboard account. -

-
- -
-
- ) -} - -function TerminalUnavailable({ - message = 'We could not resolve a dashboard team for this account.', -}: { - message?: string -}) { - return ( -
-
-

Terminal unavailable

-

{message}

-
-
- ) -} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index f8087009c..d0cd19b9e 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -118,6 +118,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Members', type: 'default', }), + '/dashboard/*/terminal': () => ({ + title: 'Terminal', + type: 'custom', + }), // billing '/dashboard/*/usage': () => ({ diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 3f5ab5201..989046936 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -18,6 +18,7 @@ export const PROTECTED_URLS = { GENERAL: (teamSlug: string) => `/dashboard/${teamSlug}/general`, KEYS: (teamSlug: string) => `/dashboard/${teamSlug}/keys`, MEMBERS: (teamSlug: string) => `/dashboard/${teamSlug}/members`, + TEAM_TERMINAL: (teamSlug: string) => `/dashboard/${teamSlug}/terminal`, SANDBOXES: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/monitoring`, diff --git a/src/core/server/proxy/handlers.ts b/src/core/server/proxy/handlers.ts index 799f988d8..e9c379185 100644 --- a/src/core/server/proxy/handlers.ts +++ b/src/core/server/proxy/handlers.ts @@ -8,8 +8,10 @@ import { getRewriteForPath } from '@/lib/utils/rewrites' import { isProxyAuthRoute, isProxyDashboardRoute } from './classifier' function isDashboardTerminalRoute(pathname: string): boolean { + const normalizedPathname = pathname.replace(/\/+$/, '') return ( - pathname === '/dashboard/terminal' || pathname === '/dashboard/terminal/' + normalizedPathname === '/dashboard/terminal' || + /^\/dashboard\/[^/]+\/terminal$/.test(normalizedPathname) ) } @@ -17,12 +19,17 @@ export function getAuthRedirect( request: NextRequest, isAuthenticated: boolean ): NextResponse | null { - if ( - isProxyDashboardRoute(request.nextUrl.pathname) && - !isDashboardTerminalRoute(request.nextUrl.pathname) && - !isAuthenticated - ) { - return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, request.url)) + if (isProxyDashboardRoute(request.nextUrl.pathname) && !isAuthenticated) { + const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) + + if (isDashboardTerminalRoute(request.nextUrl.pathname)) { + signInUrl.searchParams.set( + 'returnTo', + `${request.nextUrl.pathname}${request.nextUrl.search}` + ) + } + + return NextResponse.redirect(signInUrl) } if (isProxyAuthRoute(request.nextUrl.pathname) && isAuthenticated) { diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts index c23898ef3..c0aa0f0b0 100644 --- a/tests/unit/proxy-handlers.test.ts +++ b/tests/unit/proxy-handlers.test.ts @@ -72,4 +72,17 @@ describe('proxy handlers', () => { expect(response.headers.get('location')).toContain('/sign-in') }) + + it('preserves terminal paths and query params for sign-in', async () => { + for (const path of [ + '/dashboard/terminal?template=base&command=ls', + '/dashboard/team-x/terminal?template=base&command=ls', + ]) { + const response = await handleAuthGate(request(path), false) + + const location = response.headers.get('location') + expect(location).toContain('/sign-in') + expect(location).toContain(`returnTo=${encodeURIComponent(path)}`) + } + }) }) From 00015f00b6e64272ec3b52269f71663ba113e6ad Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 16:44:27 -0700 Subject: [PATCH 02/14] Stop terminal redirect when team lookup fails --- src/app/dashboard/terminal/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index 20dfe9d0e..9c6905a0b 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -97,7 +97,7 @@ async function resolveSandboxTeam({ const teamsRepository = createUserTeamsRepository({ accessToken }) const teamsResult = await teamsRepository.listUserTeams() - if (!teamsResult.ok) return preferredTeam + if (!teamsResult.ok) return null const candidateTeams = teamsResult.data.filter( (team) => team.id !== preferredTeam?.id From 3353d82aa4ccbd7463f42295dda1cc837a398514 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 16:48:23 -0700 Subject: [PATCH 03/14] Give team terminal wrapper full height --- src/app/dashboard/[teamSlug]/terminal/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/dashboard/[teamSlug]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx index 44100da38..6ad3c0dd3 100644 --- a/src/app/dashboard/[teamSlug]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx @@ -112,7 +112,7 @@ export default async function TeamTerminalPage({ } return ( -
+
Date: Thu, 18 Jun 2026 18:00:27 -0700 Subject: [PATCH 04/14] Rename team terminal URL helper --- src/app/dashboard/[teamSlug]/terminal/page.tsx | 2 +- src/app/dashboard/terminal/page.tsx | 2 +- src/configs/urls.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx index 6ad3c0dd3..b4f208e9e 100644 --- a/src/app/dashboard/[teamSlug]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx @@ -228,7 +228,7 @@ function TerminalSignIn({ ...(sandboxId ? { sandboxId } : {}), template, }).toString() - const returnTo = `${PROTECTED_URLS.TEAM_TERMINAL(teamSlug)}${ + const returnTo = `${PROTECTED_URLS.TERMINAL(teamSlug)}${ returnToQuery ? `?${returnToQuery}` : '' }` const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index 9c6905a0b..03125f2a2 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -48,7 +48,7 @@ export default async function TerminalPage({ } redirect( - `${PROTECTED_URLS.TEAM_TERMINAL(team.slug)}${ + `${PROTECTED_URLS.TERMINAL(team.slug)}${ queryString ? `?${queryString}` : '' }` ) diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 989046936..116402005 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -18,7 +18,7 @@ export const PROTECTED_URLS = { GENERAL: (teamSlug: string) => `/dashboard/${teamSlug}/general`, KEYS: (teamSlug: string) => `/dashboard/${teamSlug}/keys`, MEMBERS: (teamSlug: string) => `/dashboard/${teamSlug}/members`, - TEAM_TERMINAL: (teamSlug: string) => `/dashboard/${teamSlug}/terminal`, + TERMINAL: (teamSlug: string) => `/dashboard/${teamSlug}/terminal`, SANDBOXES: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/monitoring`, From a4c27676619ef5624b89ebc408b02677b72d8d29 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:01:53 -0700 Subject: [PATCH 05/14] Move legacy terminal redirect to route handler --- .../dashboard/terminal/{page.tsx => route.ts} | 40 +++++++++---------- src/core/server/proxy/handlers.ts | 19 ++------- tests/unit/proxy-handlers.test.ts | 13 ++++-- 3 files changed, 32 insertions(+), 40 deletions(-) rename src/app/dashboard/terminal/{page.tsx => route.ts} (81%) diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/route.ts similarity index 81% rename from src/app/dashboard/terminal/page.tsx rename to src/app/dashboard/terminal/route.ts index 03125f2a2..5eb847151 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/route.ts @@ -1,4 +1,4 @@ -import { redirect } from 'next/navigation' +import { type NextRequest, NextResponse } from 'next/server' import { authHeaders } from '@/configs/api' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' @@ -8,26 +8,22 @@ import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' import { infra } from '@/core/shared/clients/api' import { SandboxIdSchema } from '@/core/shared/schemas/api' -interface TerminalPageProps { - searchParams: Promise<{ - command?: string - sandboxId?: string - template?: string - }> -} - -export default async function TerminalPage({ - searchParams, -}: TerminalPageProps) { - const { command, sandboxId, template } = await searchParams +export async function GET(request: NextRequest) { + const { command, sandboxId, template } = Object.fromEntries( + request.nextUrl.searchParams + ) const queryString = buildTerminalQueryString({ command, sandboxId, template }) const authContext = await getAuthContext() if (!authContext) { - const returnTo = `/dashboard/terminal${queryString ? `?${queryString}` : ''}` - redirect( - `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ returnTo }).toString()}` + return NextResponse.redirect( + new URL( + `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ + returnTo: `${request.nextUrl.pathname}${request.nextUrl.search}`, + }).toString()}`, + request.url + ) ) } @@ -44,14 +40,14 @@ export default async function TerminalPage({ : resolvedTeam if (!team) { - redirect(PROTECTED_URLS.DASHBOARD) + return NextResponse.redirect(new URL(PROTECTED_URLS.DASHBOARD, request.url)) } - redirect( - `${PROTECTED_URLS.TERMINAL(team.slug)}${ - queryString ? `?${queryString}` : '' - }` - ) + const terminalUrl = `${PROTECTED_URLS.TERMINAL(team.slug)}${ + queryString ? `?${queryString}` : '' + }` + + return NextResponse.redirect(new URL(terminalUrl, request.url)) } function buildTerminalQueryString({ diff --git a/src/core/server/proxy/handlers.ts b/src/core/server/proxy/handlers.ts index e9c379185..15f187763 100644 --- a/src/core/server/proxy/handlers.ts +++ b/src/core/server/proxy/handlers.ts @@ -7,27 +7,16 @@ import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects' import { getRewriteForPath } from '@/lib/utils/rewrites' import { isProxyAuthRoute, isProxyDashboardRoute } from './classifier' -function isDashboardTerminalRoute(pathname: string): boolean { - const normalizedPathname = pathname.replace(/\/+$/, '') - return ( - normalizedPathname === '/dashboard/terminal' || - /^\/dashboard\/[^/]+\/terminal$/.test(normalizedPathname) - ) -} - export function getAuthRedirect( request: NextRequest, isAuthenticated: boolean ): NextResponse | null { if (isProxyDashboardRoute(request.nextUrl.pathname) && !isAuthenticated) { const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) - - if (isDashboardTerminalRoute(request.nextUrl.pathname)) { - signInUrl.searchParams.set( - 'returnTo', - `${request.nextUrl.pathname}${request.nextUrl.search}` - ) - } + signInUrl.searchParams.set( + 'returnTo', + `${request.nextUrl.pathname}${request.nextUrl.search}` + ) return NextResponse.redirect(signInUrl) } diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts index c0aa0f0b0..ce02c759d 100644 --- a/tests/unit/proxy-handlers.test.ts +++ b/tests/unit/proxy-handlers.test.ts @@ -67,10 +67,17 @@ describe('proxy handlers', () => { expect(response?.headers.get('X-Robots-Tag')).toBe('noindex, nofollow') }) - it('redirects unauthenticated dashboard pages to sign-in', async () => { - const response = await handleAuthGate(request('/dashboard/team-x'), false) + it('redirects unauthenticated dashboard pages to sign-in with returnTo', async () => { + const response = await handleAuthGate( + request('/dashboard/team-x?tab=settings'), + false + ) - expect(response.headers.get('location')).toContain('/sign-in') + const location = response.headers.get('location') + expect(location).toContain('/sign-in') + expect(location).toContain( + 'returnTo=%2Fdashboard%2Fteam-x%3Ftab%3Dsettings' + ) }) it('preserves terminal paths and query params for sign-in', async () => { From 3ccfbea877bdf6231fbef1d7041cb49cdd0d2d73 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:14:12 -0700 Subject: [PATCH 06/14] Use sandbox template for team terminal --- .../dashboard/[teamSlug]/terminal/page.tsx | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx index b4f208e9e..9aa4103ae 100644 --- a/src/app/dashboard/[teamSlug]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx @@ -9,6 +9,7 @@ import { } from '@/core/modules/templates/repository.server' import { getAuthContext } from '@/core/server/auth' import { infra } from '@/core/shared/clients/api' +import type { components as InfraComponents } from '@/core/shared/contracts/infra-api.types' import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import { SandboxIdSchema } from '@/core/shared/schemas/api' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' @@ -37,10 +38,10 @@ export default async function TeamTerminalPage({ }: TeamTerminalPageProps) { const [{ teamSlug }, { command = '', sandboxId, template }] = await Promise.all([params, searchParams]) - const terminalTemplate = normalizeTerminalTemplate(template) + const requestedTemplate = normalizeTerminalTemplate(template) const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) - if (!terminalTemplate) { + if (!terminalSandboxId && !requestedTemplate) { return } @@ -56,7 +57,9 @@ export default async function TeamTerminalPage({ command={command} sandboxId={terminalSandboxId} teamSlug={teamSlug} - template={terminalTemplate} + template={ + terminalSandboxId ? template : (requestedTemplate ?? undefined) + } /> ) } @@ -76,19 +79,28 @@ export default async function TeamTerminalPage({ return } - if ( - terminalSandboxId && - !(await hasSandboxInTeam({ - accessToken: authContext.accessToken, - sandboxId: terminalSandboxId, - teamId: team.id, - })) - ) { + const terminalSandbox = terminalSandboxId + ? await getSandboxInTeam({ + accessToken: authContext.accessToken, + sandboxId: terminalSandboxId, + teamId: team.id, + }) + : undefined + + if (terminalSandboxId && !terminalSandbox) { return ( ) } + const terminalTemplate = terminalSandbox + ? (terminalSandbox.alias ?? terminalSandbox.templateID) + : requestedTemplate + + if (!terminalTemplate) { + return + } + const templateAvailable = terminalSandboxId ? { ok: true as const, available: true } : await isTerminalTemplateAvailable({ @@ -138,7 +150,7 @@ function normalizeTerminalSandboxId(sandboxId?: string) { return parsedSandboxId.success ? parsedSandboxId.data : null } -async function hasSandboxInTeam({ +async function getSandboxInTeam({ accessToken, sandboxId, teamId, @@ -146,7 +158,7 @@ async function hasSandboxInTeam({ accessToken: string sandboxId: string teamId: string -}) { +}): Promise { try { const result = await infra.GET('/sandboxes/{sandboxID}', { params: { @@ -160,9 +172,10 @@ async function hasSandboxInTeam({ cache: 'no-store', }) - return result.response.ok && Boolean(result.data) + if (!result.response.ok || !result.data) return null + return result.data } catch { - return false + return null } } @@ -221,12 +234,12 @@ function TerminalSignIn({ command?: string sandboxId?: string teamSlug: string - template: string + template?: string }) { const returnToQuery = new URLSearchParams({ ...(command ? { command } : {}), ...(sandboxId ? { sandboxId } : {}), - template, + ...(template ? { template } : {}), }).toString() const returnTo = `${PROTECTED_URLS.TERMINAL(teamSlug)}${ returnToQuery ? `?${returnToQuery}` : '' From 3bef5bfa030f57f86e446b5e57dc14497b2e3a4f Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:43:34 -0700 Subject: [PATCH 07/14] Route legacy dashboard terminal through dashboard resolver --- src/app/dashboard/route.ts | 136 ++++++++++++++++++- src/app/dashboard/terminal/route.ts | 151 ---------------------- src/core/server/proxy/handlers.ts | 33 ++++- tests/integration/dashboard-route.test.ts | 98 +++++++++++++- tests/unit/proxy-handlers.test.ts | 13 ++ 5 files changed, 272 insertions(+), 159 deletions(-) delete mode 100644 src/app/dashboard/terminal/route.ts diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index 3da9fbe97..fba750dd1 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -1,11 +1,19 @@ import { type NextRequest, NextResponse } from 'next/server' +import { authHeaders } from '@/configs/api' import { TAB_URL_MAP } from '@/configs/dashboard-tab-url-map' import { PROTECTED_URLS } from '@/configs/urls' +import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { getAuthContext, signOut } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' +import { infra } from '@/core/shared/clients/api' import { l } from '@/core/shared/clients/logger/logger' +import { SandboxIdSchema } from '@/core/shared/schemas/api' import { setTeamCookies } from '@/lib/utils/cookies' +const TERMINAL_REDIRECT_PARAM = '__terminal' +const LEGACY_DASHBOARD_TERMINAL_PATH = '/dashboard/terminal' + function getTabRedirectPath(tab: string | null, teamSlug: string) { if (tab && Object.hasOwn(TAB_URL_MAP, tab)) { const urlGenerator = TAB_URL_MAP[tab] @@ -18,9 +26,26 @@ function getTabRedirectPath(tab: string | null, teamSlug: string) { return PROTECTED_URLS.SANDBOXES(teamSlug) } +function buildTerminalQueryString(searchParams: URLSearchParams) { + const params = new URLSearchParams() + const command = searchParams.get('command') + const sandboxId = searchParams.get('sandboxId') + const template = searchParams.get('template') + + if (command) params.set('command', command) + if (template) params.set('template', template) + if (sandboxId) params.set('sandboxId', sandboxId) + + return params.toString() +} + export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const tab = searchParams.get('tab') + const shouldRedirectToTerminal = + searchParams.get(TERMINAL_REDIRECT_PARAM) === '1' || + request.nextUrl.pathname.replace(/\/+$/, '') === + LEGACY_DASHBOARD_TERMINAL_PATH const authContext = await getAuthContext() @@ -32,8 +57,22 @@ export async function GET(request: NextRequest) { authContext.user.id, authContext.accessToken ) + const redirectTeam = + shouldRedirectToTerminal && searchParams.get('sandboxId') + ? await resolveSandboxTeam({ + accessToken: authContext.accessToken, + preferredTeam: team, + sandboxId: searchParams.get('sandboxId') ?? '', + }) + : team + + if (!redirectTeam) { + if (shouldRedirectToTerminal) { + return NextResponse.redirect( + new URL(PROTECTED_URLS.DASHBOARD, request.url) + ) + } - if (!team) { l.warn( { key: 'dashboard:no_personal_team', @@ -49,15 +88,104 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL(redirectTo, request.url)) } - await setTeamCookies(team.id, team.slug) + await setTeamCookies(redirectTeam.id, redirectTeam.slug) - const redirectPath = getTabRedirectPath(tab, team.slug) + const redirectPath = shouldRedirectToTerminal + ? PROTECTED_URLS.TERMINAL(redirectTeam.slug) + : getTabRedirectPath(tab, redirectTeam.slug) const redirectUrl = new URL(redirectPath, request.url) - if (searchParams.get('support') === 'true') { + if (shouldRedirectToTerminal) { + const terminalQueryString = buildTerminalQueryString(searchParams) + if (terminalQueryString) { + redirectUrl.search = terminalQueryString + } + } else if (searchParams.get('support') === 'true') { redirectUrl.searchParams.set('support', 'true') } return NextResponse.redirect(redirectUrl) } + +async function resolveSandboxTeam({ + accessToken, + preferredTeam, + sandboxId, +}: { + accessToken: string + preferredTeam: ResolvedTeam | null + sandboxId: string +}) { + const parsedSandboxId = SandboxIdSchema.safeParse(sandboxId.trim()) + if (!parsedSandboxId.success) return preferredTeam + + if ( + preferredTeam && + (await hasSandboxInTeam({ + accessToken, + sandboxId: parsedSandboxId.data, + teamId: preferredTeam.id, + })) + ) { + return preferredTeam + } + + const teamsRepository = createUserTeamsRepository({ accessToken }) + const teamsResult = await teamsRepository.listUserTeams() + if (!teamsResult.ok) return null + + const candidateTeams = teamsResult.data.filter( + (team) => team.id !== preferredTeam?.id + ) + const teamMatches = await Promise.all( + candidateTeams.map(async (team) => ({ + team, + ownsSandbox: await hasSandboxInTeam({ + accessToken, + sandboxId: parsedSandboxId.data, + teamId: team.id, + }), + })) + ) + + const match = teamMatches.find(({ ownsSandbox }) => ownsSandbox) + return match ? toResolvedTeam(match.team) : preferredTeam +} + +function toResolvedTeam(team: TeamModel): ResolvedTeam | null { + return team.slug + ? { + id: team.id, + slug: team.slug, + } + : null +} + +async function hasSandboxInTeam({ + accessToken, + sandboxId, + teamId, +}: { + accessToken: string + sandboxId: string + teamId: string +}) { + try { + const result = await infra.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...authHeaders(accessToken, teamId), + }, + cache: 'no-store', + }) + + return result.response.ok && Boolean(result.data) + } catch { + return false + } +} diff --git a/src/app/dashboard/terminal/route.ts b/src/app/dashboard/terminal/route.ts deleted file mode 100644 index 5eb847151..000000000 --- a/src/app/dashboard/terminal/route.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { authHeaders } from '@/configs/api' -import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' -import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' -import { getAuthContext } from '@/core/server/auth' -import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { infra } from '@/core/shared/clients/api' -import { SandboxIdSchema } from '@/core/shared/schemas/api' - -export async function GET(request: NextRequest) { - const { command, sandboxId, template } = Object.fromEntries( - request.nextUrl.searchParams - ) - const queryString = buildTerminalQueryString({ command, sandboxId, template }) - - const authContext = await getAuthContext() - - if (!authContext) { - return NextResponse.redirect( - new URL( - `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ - returnTo: `${request.nextUrl.pathname}${request.nextUrl.search}`, - }).toString()}`, - request.url - ) - ) - } - - const resolvedTeam = await resolveUserTeam( - authContext.user.id, - authContext.accessToken - ) - const team = sandboxId - ? await resolveSandboxTeam({ - accessToken: authContext.accessToken, - preferredTeam: resolvedTeam, - sandboxId, - }) - : resolvedTeam - - if (!team) { - return NextResponse.redirect(new URL(PROTECTED_URLS.DASHBOARD, request.url)) - } - - const terminalUrl = `${PROTECTED_URLS.TERMINAL(team.slug)}${ - queryString ? `?${queryString}` : '' - }` - - return NextResponse.redirect(new URL(terminalUrl, request.url)) -} - -function buildTerminalQueryString({ - command, - sandboxId, - template, -}: { - command?: string - sandboxId?: string - template?: string -}) { - const params = new URLSearchParams() - - if (command) params.set('command', command) - if (template) params.set('template', template) - if (sandboxId) params.set('sandboxId', sandboxId) - - return params.toString() -} - -async function resolveSandboxTeam({ - accessToken, - preferredTeam, - sandboxId, -}: { - accessToken: string - preferredTeam: ResolvedTeam | null - sandboxId: string -}) { - const parsedSandboxId = SandboxIdSchema.safeParse(sandboxId.trim()) - if (!parsedSandboxId.success) return preferredTeam - - if ( - preferredTeam && - (await hasSandboxInTeam({ - accessToken, - sandboxId: parsedSandboxId.data, - teamId: preferredTeam.id, - })) - ) { - return preferredTeam - } - - const teamsRepository = createUserTeamsRepository({ accessToken }) - const teamsResult = await teamsRepository.listUserTeams() - if (!teamsResult.ok) return null - - const candidateTeams = teamsResult.data.filter( - (team) => team.id !== preferredTeam?.id - ) - const teamMatches = await Promise.all( - candidateTeams.map(async (team) => ({ - team, - ownsSandbox: await hasSandboxInTeam({ - accessToken, - sandboxId: parsedSandboxId.data, - teamId: team.id, - }), - })) - ) - - const match = teamMatches.find(({ ownsSandbox }) => ownsSandbox) - return match ? toResolvedTeam(match.team) : preferredTeam -} - -function toResolvedTeam(team: TeamModel): ResolvedTeam | null { - return team.slug - ? { - id: team.id, - slug: team.slug, - } - : null -} - -async function hasSandboxInTeam({ - accessToken, - sandboxId, - teamId, -}: { - accessToken: string - sandboxId: string - teamId: string -}) { - try { - const result = await infra.GET('/sandboxes/{sandboxID}', { - params: { - path: { - sandboxID: sandboxId, - }, - }, - headers: { - ...authHeaders(accessToken, teamId), - }, - cache: 'no-store', - }) - - return result.response.ok && Boolean(result.data) - } catch { - return false - } -} diff --git a/src/core/server/proxy/handlers.ts b/src/core/server/proxy/handlers.ts index 15f187763..ed481a314 100644 --- a/src/core/server/proxy/handlers.ts +++ b/src/core/server/proxy/handlers.ts @@ -7,6 +7,13 @@ import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects' import { getRewriteForPath } from '@/lib/utils/rewrites' import { isProxyAuthRoute, isProxyDashboardRoute } from './classifier' +const LEGACY_DASHBOARD_TERMINAL_PATH = '/dashboard/terminal' +const TERMINAL_REDIRECT_PARAM = '__terminal' + +function isLegacyDashboardTerminalRoute(pathname: string): boolean { + return pathname.replace(/\/+$/, '') === LEGACY_DASHBOARD_TERMINAL_PATH +} + export function getAuthRedirect( request: NextRequest, isAuthenticated: boolean @@ -28,6 +35,26 @@ export function getAuthRedirect( return null } +export function handleLegacyDashboardTerminalRewrite( + request: NextRequest, + isAuthenticated: boolean +): NextResponse | null { + if ( + !isAuthenticated || + !isLegacyDashboardTerminalRoute(request.nextUrl.pathname) + ) { + return null + } + + const rewriteUrl = new URL(PROTECTED_URLS.DASHBOARD, request.url) + request.nextUrl.searchParams.forEach((value, key) => { + rewriteUrl.searchParams.append(key, value) + }) + rewriteUrl.searchParams.set(TERMINAL_REDIRECT_PARAM, '1') + + return NextResponse.rewrite(rewriteUrl) +} + export function handleMiddlewareRedirect( request: NextRequest ): NextResponse | null { @@ -84,5 +111,9 @@ export function handleAuthGate( isAuthenticated: boolean ): Response { const response = NextResponse.next({ request }) - return getAuthRedirect(request, isAuthenticated) ?? response + return ( + getAuthRedirect(request, isAuthenticated) ?? + handleLegacyDashboardTerminalRewrite(request, isAuthenticated) ?? + response + ) } diff --git a/tests/integration/dashboard-route.test.ts b/tests/integration/dashboard-route.test.ts index 29623fdff..ff2c042f3 100644 --- a/tests/integration/dashboard-route.test.ts +++ b/tests/integration/dashboard-route.test.ts @@ -21,7 +21,13 @@ import { PROTECTED_URLS } from '@/configs/urls' */ // create hoisted mocks -const { mockAuth, mockCookieStore, mockResolveUserTeam } = vi.hoisted(() => ({ +const { + mockAuth, + mockCookieStore, + mockInfraGet, + mockListUserTeams, + mockResolveUserTeam, +} = vi.hoisted(() => ({ mockAuth: { getAuthContext: vi.fn(), signOut: vi.fn(), @@ -30,6 +36,8 @@ const { mockAuth, mockCookieStore, mockResolveUserTeam } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn(), }, + mockInfraGet: vi.fn(), + mockListUserTeams: vi.fn(), mockResolveUserTeam: vi.fn(), })) @@ -54,6 +62,18 @@ vi.mock('@/core/server/functions/team/resolve-user-team', () => ({ resolveUserTeam: mockResolveUserTeam, })) +vi.mock('@/core/modules/teams/user-teams-repository.server', () => ({ + createUserTeamsRepository: vi.fn(() => ({ + listUserTeams: mockListUserTeams, + })), +})) + +vi.mock('@/core/shared/clients/api', () => ({ + infra: { + GET: mockInfraGet, + }, +})) + vi.mock('@/lib/utils/cookies', () => ({ setTeamCookies: vi.fn(), getTeamCookies: vi.fn(), @@ -70,6 +90,14 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { user: { id: 'user-123' }, accessToken: 'session-token', }) + mockInfraGet.mockResolvedValue({ + response: { ok: false }, + data: undefined, + }) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [], + }) }) afterEach(() => { @@ -80,9 +108,10 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { * Helper to create a NextRequest with optional query params */ function createRequest( - searchParams: Record = {} + searchParams: Record = {}, + pathname = '/dashboard' ): NextRequest { - const url = new URL('http://localhost:3000/dashboard') + const url = new URL(`http://localhost:3000${pathname}`) Object.entries(searchParams).forEach(([key, value]) => { url.searchParams.set(key, value) }) @@ -130,6 +159,69 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { '/dashboard/my-team/billing' ) }) + + it('should redirect legacy terminal requests to the team terminal page', async () => { + mockResolveUserTeam.mockResolvedValue({ + id: 'team-456', + slug: 'my-team', + }) + + const request = createRequest( + { + command: 'ls', + template: 'base', + }, + '/dashboard/terminal' + ) + + const response = await GET(request) + const location = response.headers.get('location') + + expect(location).toContain('/dashboard/my-team/terminal') + expect(location).toContain('command=ls') + expect(location).toContain('template=base') + expect(location).not.toContain('__terminal') + }) + + it('should redirect legacy terminal sandbox requests to the sandbox owner team', async () => { + mockResolveUserTeam.mockResolvedValue({ + id: 'preferred-team', + slug: 'preferred', + }) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [ + { + id: 'owner-team', + slug: 'owner', + }, + ], + }) + mockInfraGet + .mockResolvedValueOnce({ + response: { ok: false }, + data: undefined, + }) + .mockResolvedValueOnce({ + response: { ok: true }, + data: { sandboxID: 'ih1km3nxsd8472pml1kkb' }, + }) + + const request = createRequest( + { + sandboxId: 'ih1km3nxsd8472pml1kkb', + template: 'base', + }, + '/dashboard/terminal' + ) + + const response = await GET(request) + const location = response.headers.get('location') + + expect(location).toContain('/dashboard/owner/terminal') + expect(location).toContain('sandboxId=ih1km3nxsd8472pml1kkb') + expect(location).toContain('template=base') + }) }) describe('Authenticated Users - No Team Found (Unexpected State)', () => { diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts index ce02c759d..154301114 100644 --- a/tests/unit/proxy-handlers.test.ts +++ b/tests/unit/proxy-handlers.test.ts @@ -92,4 +92,17 @@ describe('proxy handlers', () => { expect(location).toContain(`returnTo=${encodeURIComponent(path)}`) } }) + + it('rewrites authenticated legacy terminal paths through the dashboard resolver', async () => { + const response = await handleAuthGate( + request('/dashboard/terminal?template=base&command=ls'), + true + ) + + const rewrite = response.headers.get('x-middleware-rewrite') + expect(rewrite).toContain('/dashboard?') + expect(rewrite).toContain('template=base') + expect(rewrite).toContain('command=ls') + expect(rewrite).toContain('__terminal=1') + }) }) From a93e3b08f0fcbc351a59e8b8981e95e062ffe15a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:47:06 -0700 Subject: [PATCH 08/14] Revert "Route legacy dashboard terminal through dashboard resolver" This reverts commit 3bef5bfa030f57f86e446b5e57dc14497b2e3a4f. --- src/app/dashboard/route.ts | 136 +------------------ src/app/dashboard/terminal/route.ts | 151 ++++++++++++++++++++++ src/core/server/proxy/handlers.ts | 33 +---- tests/integration/dashboard-route.test.ts | 98 +------------- tests/unit/proxy-handlers.test.ts | 13 -- 5 files changed, 159 insertions(+), 272 deletions(-) create mode 100644 src/app/dashboard/terminal/route.ts diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index fba750dd1..3da9fbe97 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -1,19 +1,11 @@ import { type NextRequest, NextResponse } from 'next/server' -import { authHeaders } from '@/configs/api' import { TAB_URL_MAP } from '@/configs/dashboard-tab-url-map' import { PROTECTED_URLS } from '@/configs/urls' -import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' -import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { getAuthContext, signOut } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { infra } from '@/core/shared/clients/api' import { l } from '@/core/shared/clients/logger/logger' -import { SandboxIdSchema } from '@/core/shared/schemas/api' import { setTeamCookies } from '@/lib/utils/cookies' -const TERMINAL_REDIRECT_PARAM = '__terminal' -const LEGACY_DASHBOARD_TERMINAL_PATH = '/dashboard/terminal' - function getTabRedirectPath(tab: string | null, teamSlug: string) { if (tab && Object.hasOwn(TAB_URL_MAP, tab)) { const urlGenerator = TAB_URL_MAP[tab] @@ -26,26 +18,9 @@ function getTabRedirectPath(tab: string | null, teamSlug: string) { return PROTECTED_URLS.SANDBOXES(teamSlug) } -function buildTerminalQueryString(searchParams: URLSearchParams) { - const params = new URLSearchParams() - const command = searchParams.get('command') - const sandboxId = searchParams.get('sandboxId') - const template = searchParams.get('template') - - if (command) params.set('command', command) - if (template) params.set('template', template) - if (sandboxId) params.set('sandboxId', sandboxId) - - return params.toString() -} - export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const tab = searchParams.get('tab') - const shouldRedirectToTerminal = - searchParams.get(TERMINAL_REDIRECT_PARAM) === '1' || - request.nextUrl.pathname.replace(/\/+$/, '') === - LEGACY_DASHBOARD_TERMINAL_PATH const authContext = await getAuthContext() @@ -57,22 +32,8 @@ export async function GET(request: NextRequest) { authContext.user.id, authContext.accessToken ) - const redirectTeam = - shouldRedirectToTerminal && searchParams.get('sandboxId') - ? await resolveSandboxTeam({ - accessToken: authContext.accessToken, - preferredTeam: team, - sandboxId: searchParams.get('sandboxId') ?? '', - }) - : team - - if (!redirectTeam) { - if (shouldRedirectToTerminal) { - return NextResponse.redirect( - new URL(PROTECTED_URLS.DASHBOARD, request.url) - ) - } + if (!team) { l.warn( { key: 'dashboard:no_personal_team', @@ -88,104 +49,15 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL(redirectTo, request.url)) } - await setTeamCookies(redirectTeam.id, redirectTeam.slug) + await setTeamCookies(team.id, team.slug) - const redirectPath = shouldRedirectToTerminal - ? PROTECTED_URLS.TERMINAL(redirectTeam.slug) - : getTabRedirectPath(tab, redirectTeam.slug) + const redirectPath = getTabRedirectPath(tab, team.slug) const redirectUrl = new URL(redirectPath, request.url) - if (shouldRedirectToTerminal) { - const terminalQueryString = buildTerminalQueryString(searchParams) - if (terminalQueryString) { - redirectUrl.search = terminalQueryString - } - } else if (searchParams.get('support') === 'true') { + if (searchParams.get('support') === 'true') { redirectUrl.searchParams.set('support', 'true') } return NextResponse.redirect(redirectUrl) } - -async function resolveSandboxTeam({ - accessToken, - preferredTeam, - sandboxId, -}: { - accessToken: string - preferredTeam: ResolvedTeam | null - sandboxId: string -}) { - const parsedSandboxId = SandboxIdSchema.safeParse(sandboxId.trim()) - if (!parsedSandboxId.success) return preferredTeam - - if ( - preferredTeam && - (await hasSandboxInTeam({ - accessToken, - sandboxId: parsedSandboxId.data, - teamId: preferredTeam.id, - })) - ) { - return preferredTeam - } - - const teamsRepository = createUserTeamsRepository({ accessToken }) - const teamsResult = await teamsRepository.listUserTeams() - if (!teamsResult.ok) return null - - const candidateTeams = teamsResult.data.filter( - (team) => team.id !== preferredTeam?.id - ) - const teamMatches = await Promise.all( - candidateTeams.map(async (team) => ({ - team, - ownsSandbox: await hasSandboxInTeam({ - accessToken, - sandboxId: parsedSandboxId.data, - teamId: team.id, - }), - })) - ) - - const match = teamMatches.find(({ ownsSandbox }) => ownsSandbox) - return match ? toResolvedTeam(match.team) : preferredTeam -} - -function toResolvedTeam(team: TeamModel): ResolvedTeam | null { - return team.slug - ? { - id: team.id, - slug: team.slug, - } - : null -} - -async function hasSandboxInTeam({ - accessToken, - sandboxId, - teamId, -}: { - accessToken: string - sandboxId: string - teamId: string -}) { - try { - const result = await infra.GET('/sandboxes/{sandboxID}', { - params: { - path: { - sandboxID: sandboxId, - }, - }, - headers: { - ...authHeaders(accessToken, teamId), - }, - cache: 'no-store', - }) - - return result.response.ok && Boolean(result.data) - } catch { - return false - } -} diff --git a/src/app/dashboard/terminal/route.ts b/src/app/dashboard/terminal/route.ts new file mode 100644 index 000000000..5eb847151 --- /dev/null +++ b/src/app/dashboard/terminal/route.ts @@ -0,0 +1,151 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { authHeaders } from '@/configs/api' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { getAuthContext } from '@/core/server/auth' +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' +import { infra } from '@/core/shared/clients/api' +import { SandboxIdSchema } from '@/core/shared/schemas/api' + +export async function GET(request: NextRequest) { + const { command, sandboxId, template } = Object.fromEntries( + request.nextUrl.searchParams + ) + const queryString = buildTerminalQueryString({ command, sandboxId, template }) + + const authContext = await getAuthContext() + + if (!authContext) { + return NextResponse.redirect( + new URL( + `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ + returnTo: `${request.nextUrl.pathname}${request.nextUrl.search}`, + }).toString()}`, + request.url + ) + ) + } + + const resolvedTeam = await resolveUserTeam( + authContext.user.id, + authContext.accessToken + ) + const team = sandboxId + ? await resolveSandboxTeam({ + accessToken: authContext.accessToken, + preferredTeam: resolvedTeam, + sandboxId, + }) + : resolvedTeam + + if (!team) { + return NextResponse.redirect(new URL(PROTECTED_URLS.DASHBOARD, request.url)) + } + + const terminalUrl = `${PROTECTED_URLS.TERMINAL(team.slug)}${ + queryString ? `?${queryString}` : '' + }` + + return NextResponse.redirect(new URL(terminalUrl, request.url)) +} + +function buildTerminalQueryString({ + command, + sandboxId, + template, +}: { + command?: string + sandboxId?: string + template?: string +}) { + const params = new URLSearchParams() + + if (command) params.set('command', command) + if (template) params.set('template', template) + if (sandboxId) params.set('sandboxId', sandboxId) + + return params.toString() +} + +async function resolveSandboxTeam({ + accessToken, + preferredTeam, + sandboxId, +}: { + accessToken: string + preferredTeam: ResolvedTeam | null + sandboxId: string +}) { + const parsedSandboxId = SandboxIdSchema.safeParse(sandboxId.trim()) + if (!parsedSandboxId.success) return preferredTeam + + if ( + preferredTeam && + (await hasSandboxInTeam({ + accessToken, + sandboxId: parsedSandboxId.data, + teamId: preferredTeam.id, + })) + ) { + return preferredTeam + } + + const teamsRepository = createUserTeamsRepository({ accessToken }) + const teamsResult = await teamsRepository.listUserTeams() + if (!teamsResult.ok) return null + + const candidateTeams = teamsResult.data.filter( + (team) => team.id !== preferredTeam?.id + ) + const teamMatches = await Promise.all( + candidateTeams.map(async (team) => ({ + team, + ownsSandbox: await hasSandboxInTeam({ + accessToken, + sandboxId: parsedSandboxId.data, + teamId: team.id, + }), + })) + ) + + const match = teamMatches.find(({ ownsSandbox }) => ownsSandbox) + return match ? toResolvedTeam(match.team) : preferredTeam +} + +function toResolvedTeam(team: TeamModel): ResolvedTeam | null { + return team.slug + ? { + id: team.id, + slug: team.slug, + } + : null +} + +async function hasSandboxInTeam({ + accessToken, + sandboxId, + teamId, +}: { + accessToken: string + sandboxId: string + teamId: string +}) { + try { + const result = await infra.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...authHeaders(accessToken, teamId), + }, + cache: 'no-store', + }) + + return result.response.ok && Boolean(result.data) + } catch { + return false + } +} diff --git a/src/core/server/proxy/handlers.ts b/src/core/server/proxy/handlers.ts index ed481a314..15f187763 100644 --- a/src/core/server/proxy/handlers.ts +++ b/src/core/server/proxy/handlers.ts @@ -7,13 +7,6 @@ import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects' import { getRewriteForPath } from '@/lib/utils/rewrites' import { isProxyAuthRoute, isProxyDashboardRoute } from './classifier' -const LEGACY_DASHBOARD_TERMINAL_PATH = '/dashboard/terminal' -const TERMINAL_REDIRECT_PARAM = '__terminal' - -function isLegacyDashboardTerminalRoute(pathname: string): boolean { - return pathname.replace(/\/+$/, '') === LEGACY_DASHBOARD_TERMINAL_PATH -} - export function getAuthRedirect( request: NextRequest, isAuthenticated: boolean @@ -35,26 +28,6 @@ export function getAuthRedirect( return null } -export function handleLegacyDashboardTerminalRewrite( - request: NextRequest, - isAuthenticated: boolean -): NextResponse | null { - if ( - !isAuthenticated || - !isLegacyDashboardTerminalRoute(request.nextUrl.pathname) - ) { - return null - } - - const rewriteUrl = new URL(PROTECTED_URLS.DASHBOARD, request.url) - request.nextUrl.searchParams.forEach((value, key) => { - rewriteUrl.searchParams.append(key, value) - }) - rewriteUrl.searchParams.set(TERMINAL_REDIRECT_PARAM, '1') - - return NextResponse.rewrite(rewriteUrl) -} - export function handleMiddlewareRedirect( request: NextRequest ): NextResponse | null { @@ -111,9 +84,5 @@ export function handleAuthGate( isAuthenticated: boolean ): Response { const response = NextResponse.next({ request }) - return ( - getAuthRedirect(request, isAuthenticated) ?? - handleLegacyDashboardTerminalRewrite(request, isAuthenticated) ?? - response - ) + return getAuthRedirect(request, isAuthenticated) ?? response } diff --git a/tests/integration/dashboard-route.test.ts b/tests/integration/dashboard-route.test.ts index ff2c042f3..29623fdff 100644 --- a/tests/integration/dashboard-route.test.ts +++ b/tests/integration/dashboard-route.test.ts @@ -21,13 +21,7 @@ import { PROTECTED_URLS } from '@/configs/urls' */ // create hoisted mocks -const { - mockAuth, - mockCookieStore, - mockInfraGet, - mockListUserTeams, - mockResolveUserTeam, -} = vi.hoisted(() => ({ +const { mockAuth, mockCookieStore, mockResolveUserTeam } = vi.hoisted(() => ({ mockAuth: { getAuthContext: vi.fn(), signOut: vi.fn(), @@ -36,8 +30,6 @@ const { get: vi.fn(), set: vi.fn(), }, - mockInfraGet: vi.fn(), - mockListUserTeams: vi.fn(), mockResolveUserTeam: vi.fn(), })) @@ -62,18 +54,6 @@ vi.mock('@/core/server/functions/team/resolve-user-team', () => ({ resolveUserTeam: mockResolveUserTeam, })) -vi.mock('@/core/modules/teams/user-teams-repository.server', () => ({ - createUserTeamsRepository: vi.fn(() => ({ - listUserTeams: mockListUserTeams, - })), -})) - -vi.mock('@/core/shared/clients/api', () => ({ - infra: { - GET: mockInfraGet, - }, -})) - vi.mock('@/lib/utils/cookies', () => ({ setTeamCookies: vi.fn(), getTeamCookies: vi.fn(), @@ -90,14 +70,6 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { user: { id: 'user-123' }, accessToken: 'session-token', }) - mockInfraGet.mockResolvedValue({ - response: { ok: false }, - data: undefined, - }) - mockListUserTeams.mockResolvedValue({ - ok: true, - data: [], - }) }) afterEach(() => { @@ -108,10 +80,9 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { * Helper to create a NextRequest with optional query params */ function createRequest( - searchParams: Record = {}, - pathname = '/dashboard' + searchParams: Record = {} ): NextRequest { - const url = new URL(`http://localhost:3000${pathname}`) + const url = new URL('http://localhost:3000/dashboard') Object.entries(searchParams).forEach(([key, value]) => { url.searchParams.set(key, value) }) @@ -159,69 +130,6 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { '/dashboard/my-team/billing' ) }) - - it('should redirect legacy terminal requests to the team terminal page', async () => { - mockResolveUserTeam.mockResolvedValue({ - id: 'team-456', - slug: 'my-team', - }) - - const request = createRequest( - { - command: 'ls', - template: 'base', - }, - '/dashboard/terminal' - ) - - const response = await GET(request) - const location = response.headers.get('location') - - expect(location).toContain('/dashboard/my-team/terminal') - expect(location).toContain('command=ls') - expect(location).toContain('template=base') - expect(location).not.toContain('__terminal') - }) - - it('should redirect legacy terminal sandbox requests to the sandbox owner team', async () => { - mockResolveUserTeam.mockResolvedValue({ - id: 'preferred-team', - slug: 'preferred', - }) - mockListUserTeams.mockResolvedValue({ - ok: true, - data: [ - { - id: 'owner-team', - slug: 'owner', - }, - ], - }) - mockInfraGet - .mockResolvedValueOnce({ - response: { ok: false }, - data: undefined, - }) - .mockResolvedValueOnce({ - response: { ok: true }, - data: { sandboxID: 'ih1km3nxsd8472pml1kkb' }, - }) - - const request = createRequest( - { - sandboxId: 'ih1km3nxsd8472pml1kkb', - template: 'base', - }, - '/dashboard/terminal' - ) - - const response = await GET(request) - const location = response.headers.get('location') - - expect(location).toContain('/dashboard/owner/terminal') - expect(location).toContain('sandboxId=ih1km3nxsd8472pml1kkb') - expect(location).toContain('template=base') - }) }) describe('Authenticated Users - No Team Found (Unexpected State)', () => { diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts index 154301114..ce02c759d 100644 --- a/tests/unit/proxy-handlers.test.ts +++ b/tests/unit/proxy-handlers.test.ts @@ -92,17 +92,4 @@ describe('proxy handlers', () => { expect(location).toContain(`returnTo=${encodeURIComponent(path)}`) } }) - - it('rewrites authenticated legacy terminal paths through the dashboard resolver', async () => { - const response = await handleAuthGate( - request('/dashboard/terminal?template=base&command=ls'), - true - ) - - const rewrite = response.headers.get('x-middleware-rewrite') - expect(rewrite).toContain('/dashboard?') - expect(rewrite).toContain('template=base') - expect(rewrite).toContain('command=ls') - expect(rewrite).toContain('__terminal=1') - }) }) From 31496a578883594d904caea3eb1d0cf0b9971c3a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:48:15 -0700 Subject: [PATCH 09/14] Simplify legacy terminal redirect --- src/app/dashboard/terminal/route.ts | 141 +++------------------------- 1 file changed, 12 insertions(+), 129 deletions(-) diff --git a/src/app/dashboard/terminal/route.ts b/src/app/dashboard/terminal/route.ts index 5eb847151..f9b160bf5 100644 --- a/src/app/dashboard/terminal/route.ts +++ b/src/app/dashboard/terminal/route.ts @@ -1,151 +1,34 @@ import { type NextRequest, NextResponse } from 'next/server' -import { authHeaders } from '@/configs/api' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import type { ResolvedTeam, TeamModel } from '@/core/modules/teams/models' -import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { getAuthContext } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { infra } from '@/core/shared/clients/api' -import { SandboxIdSchema } from '@/core/shared/schemas/api' export async function GET(request: NextRequest) { - const { command, sandboxId, template } = Object.fromEntries( - request.nextUrl.searchParams - ) - const queryString = buildTerminalQueryString({ command, sandboxId, template }) - const authContext = await getAuthContext() if (!authContext) { - return NextResponse.redirect( - new URL( - `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ - returnTo: `${request.nextUrl.pathname}${request.nextUrl.search}`, - }).toString()}`, - request.url - ) + const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) + signInUrl.searchParams.set( + 'returnTo', + `${request.nextUrl.pathname}${request.nextUrl.search}` ) + + return NextResponse.redirect(signInUrl) } - const resolvedTeam = await resolveUserTeam( + const team = await resolveUserTeam( authContext.user.id, authContext.accessToken ) - const team = sandboxId - ? await resolveSandboxTeam({ - accessToken: authContext.accessToken, - preferredTeam: resolvedTeam, - sandboxId, - }) - : resolvedTeam if (!team) { return NextResponse.redirect(new URL(PROTECTED_URLS.DASHBOARD, request.url)) } - const terminalUrl = `${PROTECTED_URLS.TERMINAL(team.slug)}${ - queryString ? `?${queryString}` : '' - }` - - return NextResponse.redirect(new URL(terminalUrl, request.url)) -} - -function buildTerminalQueryString({ - command, - sandboxId, - template, -}: { - command?: string - sandboxId?: string - template?: string -}) { - const params = new URLSearchParams() - - if (command) params.set('command', command) - if (template) params.set('template', template) - if (sandboxId) params.set('sandboxId', sandboxId) - - return params.toString() -} - -async function resolveSandboxTeam({ - accessToken, - preferredTeam, - sandboxId, -}: { - accessToken: string - preferredTeam: ResolvedTeam | null - sandboxId: string -}) { - const parsedSandboxId = SandboxIdSchema.safeParse(sandboxId.trim()) - if (!parsedSandboxId.success) return preferredTeam - - if ( - preferredTeam && - (await hasSandboxInTeam({ - accessToken, - sandboxId: parsedSandboxId.data, - teamId: preferredTeam.id, - })) - ) { - return preferredTeam - } - - const teamsRepository = createUserTeamsRepository({ accessToken }) - const teamsResult = await teamsRepository.listUserTeams() - if (!teamsResult.ok) return null - - const candidateTeams = teamsResult.data.filter( - (team) => team.id !== preferredTeam?.id - ) - const teamMatches = await Promise.all( - candidateTeams.map(async (team) => ({ - team, - ownsSandbox: await hasSandboxInTeam({ - accessToken, - sandboxId: parsedSandboxId.data, - teamId: team.id, - }), - })) - ) + const terminalUrl = new URL(PROTECTED_URLS.TERMINAL(team.slug), request.url) + request.nextUrl.searchParams.forEach((value, key) => { + terminalUrl.searchParams.append(key, value) + }) - const match = teamMatches.find(({ ownsSandbox }) => ownsSandbox) - return match ? toResolvedTeam(match.team) : preferredTeam -} - -function toResolvedTeam(team: TeamModel): ResolvedTeam | null { - return team.slug - ? { - id: team.id, - slug: team.slug, - } - : null -} - -async function hasSandboxInTeam({ - accessToken, - sandboxId, - teamId, -}: { - accessToken: string - sandboxId: string - teamId: string -}) { - try { - const result = await infra.GET('/sandboxes/{sandboxID}', { - params: { - path: { - sandboxID: sandboxId, - }, - }, - headers: { - ...authHeaders(accessToken, teamId), - }, - cache: 'no-store', - }) - - return result.response.ok && Boolean(result.data) - } catch { - return false - } + return NextResponse.redirect(terminalUrl) } From a5256b8bd4da7ed9d258179be4990aa7f27d2d7c Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:53:33 -0700 Subject: [PATCH 10/14] Use dashboard tab map for terminal redirect --- src/app/dashboard/terminal/route.ts | 34 ---------------------------- src/configs/dashboard-tab-url-map.ts | 2 +- 2 files changed, 1 insertion(+), 35 deletions(-) delete mode 100644 src/app/dashboard/terminal/route.ts diff --git a/src/app/dashboard/terminal/route.ts b/src/app/dashboard/terminal/route.ts deleted file mode 100644 index f9b160bf5..000000000 --- a/src/app/dashboard/terminal/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { getAuthContext } from '@/core/server/auth' -import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' - -export async function GET(request: NextRequest) { - const authContext = await getAuthContext() - - if (!authContext) { - const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) - signInUrl.searchParams.set( - 'returnTo', - `${request.nextUrl.pathname}${request.nextUrl.search}` - ) - - return NextResponse.redirect(signInUrl) - } - - const team = await resolveUserTeam( - authContext.user.id, - authContext.accessToken - ) - - if (!team) { - return NextResponse.redirect(new URL(PROTECTED_URLS.DASHBOARD, request.url)) - } - - const terminalUrl = new URL(PROTECTED_URLS.TERMINAL(team.slug), request.url) - request.nextUrl.searchParams.forEach((value, key) => { - terminalUrl.searchParams.append(key, value) - }) - - return NextResponse.redirect(terminalUrl) -} diff --git a/src/configs/dashboard-tab-url-map.ts b/src/configs/dashboard-tab-url-map.ts index 1cf2e06df..0b1b12c02 100644 --- a/src/configs/dashboard-tab-url-map.ts +++ b/src/configs/dashboard-tab-url-map.ts @@ -13,6 +13,6 @@ export const TAB_URL_MAP: Record string> = { members: (teamSlug) => PROTECTED_URLS.MEMBERS(teamSlug), account: (_) => PROTECTED_URLS.ACCOUNT_SETTINGS, personal: (_) => PROTECTED_URLS.ACCOUNT_SETTINGS, - + terminal: (teamSlug) => PROTECTED_URLS.TERMINAL(teamSlug), budget: (teamSlug) => PROTECTED_URLS.LIMITS(teamSlug), } From a36cfbe0c653b9525a60978fe1baae4d3f2e8921 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:54:55 -0700 Subject: [PATCH 11/14] Add legacy terminal path shim --- src/app/dashboard/terminal/route.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/app/dashboard/terminal/route.ts diff --git a/src/app/dashboard/terminal/route.ts b/src/app/dashboard/terminal/route.ts new file mode 100644 index 000000000..e0926c9af --- /dev/null +++ b/src/app/dashboard/terminal/route.ts @@ -0,0 +1,10 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { PROTECTED_URLS } from '@/configs/urls' + +export function GET(request: NextRequest) { + const redirectUrl = new URL(PROTECTED_URLS.DASHBOARD, request.url) + redirectUrl.search = request.nextUrl.search + redirectUrl.searchParams.set('tab', 'terminal') + + return NextResponse.redirect(redirectUrl) +} From dde5dd7a25550fb12d335d7755f8d383e76e6580 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 18:56:33 -0700 Subject: [PATCH 12/14] pass query params to tab --- src/app/dashboard/route.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index 3da9fbe97..dbfe50d6c 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -59,5 +59,16 @@ export async function GET(request: NextRequest) { redirectUrl.searchParams.set('support', 'true') } + // send everything to terminal if it's terminal + if (tab === 'terminal') { + const terminalParams = ["template", "sandboxId", "command"] + terminalParams.forEach((param) => { + const value = searchParams.get(param) + if (value) { + redirectUrl.searchParams.set(param, value) + } + }) + } + return NextResponse.redirect(redirectUrl) } From 9af02631b65d6497d45e45bf90fef3fcf4f1f4be Mon Sep 17 00:00:00 2001 From: matthewlouisbrockman <18491105+matthewlouisbrockman@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:57:04 +0000 Subject: [PATCH 13/14] style: apply biome formatting --- src/app/dashboard/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index dbfe50d6c..0d5312c40 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -61,7 +61,7 @@ export async function GET(request: NextRequest) { // send everything to terminal if it's terminal if (tab === 'terminal') { - const terminalParams = ["template", "sandboxId", "command"] + const terminalParams = ['template', 'sandboxId', 'command'] terminalParams.forEach((param) => { const value = searchParams.get(param) if (value) { From 578ae01010cf89049ba21550542ace28a10cdd6b Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 18 Jun 2026 19:00:43 -0700 Subject: [PATCH 14/14] Format terminal redirect params --- src/app/dashboard/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index dbfe50d6c..0d5312c40 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -61,7 +61,7 @@ export async function GET(request: NextRequest) { // send everything to terminal if it's terminal if (tab === 'terminal') { - const terminalParams = ["template", "sandboxId", "command"] + const terminalParams = ['template', 'sandboxId', 'command'] terminalParams.forEach((param) => { const value = searchParams.get(param) if (value) {