Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,20 +21,25 @@ export const metadata: Metadata = {
robots: 'noindex, nofollow',
}

interface TerminalPageProps {
interface TeamTerminalPageProps {
params: Promise<{
teamSlug: string
}>
searchParams: Promise<{
command?: string
sandboxId?: string
template?: string
}>
}

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 <TerminalUnavailable message="The terminal template is invalid." />
Expand All @@ -50,8 +54,12 @@ export default async function TerminalPage({
if (!authContext) {
return (
<TerminalSignIn
command={command}
sandboxId={terminalSandboxId}
template={requestedTemplate ?? 'base'}
teamSlug={teamSlug}
template={
terminalSandboxId ? template : (requestedTemplate ?? undefined)
}
/>
)
}
Expand All @@ -65,41 +73,40 @@ export default async function TerminalPage({
return <TerminalUnavailable />
}

const resolvedTeam = await resolveUserTeam(
authContext.user.id,
authContext.accessToken
)
const team = teamsResult.data.find((candidate) => candidate.slug === teamSlug)

if (!team) {
return <TerminalUnavailable />
}

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 (
<TerminalUnavailable
message={
terminalSandboxId
? 'Sandbox not found or you do not have access to it.'
: undefined
}
/>
<TerminalUnavailable message="Sandbox not found or you do not have access to it." />
)
}

const terminalTemplate = terminalSandbox
? (terminalSandbox.alias ?? terminalSandbox.templateID)
: requestedTemplate

if (!terminalTemplate) {
return <TerminalUnavailable message="The terminal template is invalid." />
}

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) {
Expand All @@ -117,21 +124,21 @@ export default async function TerminalPage({
}

return (
<main className="h-dvh min-h-[360px] bg-bg p-3">
<div className="flex h-full min-h-0 overflow-hidden p-3 md:p-6">
<DashboardTerminal
autoStart
launchTarget={{
command,
sandboxId: terminalSandboxId,
template: terminalTemplate ?? 'base',
template: terminalTemplate,
}}
sandboxManagementAuth={createSandboxManagementAuth(
authContext,
team.id
)}
teamSlug={team.slug}
/>
</main>
</div>
)
}

Expand All @@ -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,
Expand All @@ -211,7 +158,7 @@ async function getSandboxInTeam({
accessToken: string
sandboxId: string
teamId: string
}) {
}): Promise<InfraComponents['schemas']['SandboxDetail'] | null> {
try {
const result = await infra.GET('/sandboxes/{sandboxID}', {
params: {
Expand All @@ -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
}
Expand Down Expand Up @@ -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 } : {}),
Comment thread
matthewlouisbrockman marked this conversation as resolved.
...(sandboxId ? { sandboxId } : {}),
...(template ? { template } : {}),
}).toString()
const returnTo = `${PROTECTED_URLS.TERMINAL(teamSlug)}${
returnToQuery ? `?${returnToQuery}` : ''
}`
const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({
Expand Down
11 changes: 11 additions & 0 deletions src/app/dashboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Comment thread
cursor[bot] marked this conversation as resolved.

return NextResponse.redirect(redirectUrl)
}
10 changes: 10 additions & 0 deletions src/app/dashboard/terminal/route.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we never referenced this url anywhere, let's remove it 🙏🏼

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

o, u want to yeet /dashboard/terminal? that works, can see if can move it to /embed/terminal instead although in that case maybe can stick it in another repo

Original file line number Diff line number Diff line change
@@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion src/configs/dashboard-tab-url-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export const TAB_URL_MAP: Record<string, (teamSlug: string) => 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),
}
4 changes: 4 additions & 0 deletions src/configs/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
}),
'/dashboard/*/sandboxes/*/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 41 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const sandboxId = parts[4]!

Check warning on line 42 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand Down Expand Up @@ -70,8 +70,8 @@
}),
'/dashboard/*/templates/*/builds/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 73 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const buildId = parts.pop()!

Check warning on line 74 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const buildIdSliced = `${buildId.slice(0, 6)}...${buildId.slice(-6)}`

return {
Expand Down Expand Up @@ -118,6 +118,10 @@
title: 'Members',
type: 'default',
}),
'/dashboard/*/terminal': () => ({
title: 'Terminal',
type: 'custom',
}),
'/dashboard/*/agents': () => ({
title: 'Agents',
type: 'default',
Expand All @@ -141,7 +145,7 @@
}),
'/dashboard/*/billing/plan': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 148 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand All @@ -155,7 +159,7 @@
},
'/dashboard/*/billing/plan/select': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 162 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand All @@ -179,8 +183,8 @@
// Pathname fallback for detail tabs; usePageTitle replaces with the friendly template name once data loads.
function templateDetailLayoutConfig(pathname: string): DashboardLayoutConfig {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 186 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const templateId = parts[4]!

Check warning on line 187 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const templateIdSliced =
templateId.length > 14
? `${templateId.slice(0, 6)}...${templateId.slice(-6)}`
Expand Down
1 change: 1 addition & 0 deletions src/configs/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
20 changes: 8 additions & 12 deletions src/core/server/proxy/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
matthewlouisbrockman marked this conversation as resolved.

if (isProxyAuthRoute(request.nextUrl.pathname) && isAuthenticated) {
Expand Down
26 changes: 23 additions & 3 deletions tests/unit/proxy-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`)
}
})
})
Loading