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)}`) + } }) })