diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx
similarity index 67%
rename from src/app/dashboard/terminal/page.tsx
rename to src/app/dashboard/[teamSlug]/terminal/page.tsx
index ffdf1680f..9aa4103ae 100644
--- a/src/app/dashboard/terminal/page.tsx
+++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx
@@ -1,16 +1,15 @@
import Link from 'next/link'
import type { Metadata } from 'next/types'
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 { 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 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'
@@ -22,7 +21,10 @@ export const metadata: Metadata = {
robots: 'noindex, nofollow',
}
-interface TerminalPageProps {
+interface TeamTerminalPageProps {
+ params: Promise<{
+ teamSlug: string
+ }>
searchParams: Promise<{
command?: string
sandboxId?: string
@@ -30,12 +32,14 @@ interface TerminalPageProps {
}>
}
-export default async function TerminalPage({
+export default async function TeamTerminalPage({
+ params,
searchParams,
-}: TerminalPageProps) {
- const { command = '', sandboxId, template } = await searchParams
- const terminalSandboxId = normalizeTerminalSandboxId(sandboxId)
+}: TeamTerminalPageProps) {
+ const [{ teamSlug }, { command = '', sandboxId, template }] =
+ await Promise.all([params, searchParams])
const requestedTemplate = normalizeTerminalTemplate(template)
+ const terminalSandboxId = normalizeTerminalSandboxId(sandboxId)
if (!terminalSandboxId && !requestedTemplate) {
return
@@ -50,8 +54,12 @@ export default async function TerminalPage({
if (!authContext) {
return (
)
}
@@ -65,41 +73,40 @@ export default async function TerminalPage({
return
}
- const resolvedTeam = await resolveUserTeam(
- authContext.user.id,
- authContext.accessToken
- )
+ const team = teamsResult.data.find((candidate) => candidate.slug === teamSlug)
+
+ if (!team) {
+ return
+ }
+
const terminalSandbox = terminalSandboxId
- ? await resolveTerminalSandbox({
+ ? await getSandboxInTeam({
accessToken: authContext.accessToken,
- preferredTeamId: resolvedTeam?.id,
sandboxId: terminalSandboxId,
- teams: teamsResult.data,
+ teamId: team.id,
})
- : null
- const team = terminalSandbox
- ? terminalSandbox.team
- : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id)
- const terminalTemplate = terminalSandbox?.template ?? requestedTemplate
+ : undefined
- if (!team) {
+ 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({
accessToken: authContext.accessToken,
teamId: team.id,
- template: terminalTemplate ?? 'base',
+ template: terminalTemplate,
})
if (!templateAvailable.ok) {
@@ -117,13 +124,13 @@ export default async function TerminalPage({
}
return (
-
+
-
+
)
}
@@ -143,66 +150,6 @@ function normalizeTerminalSandboxId(sandboxId?: string) {
return parsedSandboxId.success ? parsedSandboxId.data : null
}
-async function resolveTerminalSandbox({
- accessToken,
- preferredTeamId,
- sandboxId,
- teams,
-}: {
- accessToken: string
- preferredTeamId?: string
- sandboxId: string
- teams: TeamModel[]
-}) {
- if (preferredTeamId) {
- const preferredTeam = teams.find((team) => team.id === preferredTeamId)
- if (preferredTeam) {
- const preferredSandbox = await getSandboxInTeam({
- accessToken,
- sandboxId,
- teamId: preferredTeam.id,
- })
-
- if (preferredSandbox) {
- return {
- team: preferredTeam,
- template: getSandboxTemplate(preferredSandbox),
- }
- }
- }
- }
-
- const candidateTeams = teams.filter((team) => team.id !== preferredTeamId)
- const teamMatches = await Promise.all(
- candidateTeams.map(async (team) => ({
- sandbox: await getSandboxInTeam({
- accessToken,
- sandboxId,
- teamId: team.id,
- }),
- team,
- }))
- )
-
- const match = teamMatches.find(({ sandbox }) => sandbox)
-
- return match?.sandbox
- ? {
- team: match.team,
- template: getSandboxTemplate(match.sandbox),
- }
- : null
-}
-
-type TerminalSandbox = {
- alias?: string
- templateID: string
-}
-
-function getSandboxTemplate(sandbox: TerminalSandbox) {
- return sandbox.alias ?? sandbox.templateID
-}
-
async function getSandboxInTeam({
accessToken,
sandboxId,
@@ -211,7 +158,7 @@ async function getSandboxInTeam({
accessToken: string
sandboxId: string
teamId: string
-}) {
+}): Promise {
try {
const result = await infra.GET('/sandboxes/{sandboxID}', {
params: {
@@ -225,7 +172,8 @@ async function getSandboxInTeam({
cache: 'no-store',
})
- return result.response.ok && result.data ? result.data : null
+ if (!result.response.ok || !result.data) return null
+ return result.data
} catch {
return null
}
@@ -278,24 +226,22 @@ async function isTerminalTemplateAvailable({
}
function TerminalSignIn({
+ command,
sandboxId,
+ teamSlug,
template,
}: {
+ command?: string
sandboxId?: string
- template: string
+ teamSlug: 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${
+ const returnToQuery = new URLSearchParams({
+ ...(command ? { command } : {}),
+ ...(sandboxId ? { sandboxId } : {}),
+ ...(template ? { template } : {}),
+ }).toString()
+ const returnTo = `${PROTECTED_URLS.TERMINAL(teamSlug)}${
returnToQuery ? `?${returnToQuery}` : ''
}`
const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({
diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts
index 3da9fbe97..0d5312c40 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)
}
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)
+}
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),
}
diff --git a/src/configs/layout.ts b/src/configs/layout.ts
index 6b3de107b..4513697d1 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',
+ }),
'/dashboard/*/agents': () => ({
title: 'Agents',
type: 'default',
diff --git a/src/configs/urls.ts b/src/configs/urls.ts
index 3f5ab5201..116402005 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`,
+ 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..15f187763 100644
--- a/src/core/server/proxy/handlers.ts
+++ b/src/core/server/proxy/handlers.ts
@@ -7,22 +7,18 @@ import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects'
import { getRewriteForPath } from '@/lib/utils/rewrites'
import { isProxyAuthRoute, isProxyDashboardRoute } from './classifier'
-function isDashboardTerminalRoute(pathname: string): boolean {
- return (
- pathname === '/dashboard/terminal' || pathname === '/dashboard/terminal/'
- )
-}
-
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)
+ 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..ce02c759d 100644
--- a/tests/unit/proxy-handlers.test.ts
+++ b/tests/unit/proxy-handlers.test.ts
@@ -67,9 +67,29 @@ 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
+ )
+
+ const location = response.headers.get('location')
+ expect(location).toContain('/sign-in')
+ expect(location).toContain(
+ 'returnTo=%2Fdashboard%2Fteam-x%3Ftab%3Dsettings'
+ )
+ })
- 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)}`)
+ }
})
})