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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"@vercel/kv": "^3.0.0",
"@vercel/otel": "^1.13.0",
"@vercel/speed-insights": "^1.2.0",
"@xterm/xterm": "^6.0.0",
"cheerio": "^1.0.0",
"chrono-node": "^2.8.4",
"class-variance-authority": "^0.7.1",
Expand Down
9 changes: 9 additions & 0 deletions src/app/dashboard/terminal/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import '@xterm/xterm/css/xterm.css'

export default function TerminalLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
297 changes: 297 additions & 0 deletions src/app/dashboard/terminal/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import Link from 'next/link'
import type { Metadata } from 'next/types'
import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
import { AUTH_URLS } from '@/configs/urls'
import type { 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 { getSessionInsecure } from '@/core/server/functions/auth/get-session'
import getUserByToken from '@/core/server/functions/auth/get-user-by-token'
import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
import { infra } from '@/core/shared/clients/api'
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<{
command?: string
sandboxId?: string
template?: string
}>
}

export default async function TerminalPage({
searchParams,
}: TerminalPageProps) {
const { command = '', sandboxId, template } = await searchParams
const terminalTemplate = normalizeTerminalTemplate(template)
const terminalSandboxId = normalizeTerminalSandboxId(sandboxId)

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

if (terminalSandboxId === null) {
return <TerminalUnavailable message="The terminal sandbox ID is invalid." />
}

const session = await getSessionInsecure()
const { data, error } = await getUserByToken(session?.access_token)

if (error || !data.user || !session) {
return (
<TerminalSignIn
sandboxId={terminalSandboxId}
template={terminalTemplate}
/>
)
}

const teamsRepository = createUserTeamsRepository({
accessToken: session.access_token,
})
const teamsResult = await teamsRepository.listUserTeams()

if (!teamsResult.ok) {
return <TerminalUnavailable />
}

const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token)
const team = terminalSandboxId
? await resolveTerminalSandboxTeam({
accessToken: session.access_token,
preferredTeamId: resolvedTeam?.id,
sandboxId: terminalSandboxId,
teams: teamsResult.data,
})
: teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id)

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

const templateAvailable = terminalSandboxId
? { ok: true as const, available: true }
: await isTerminalTemplateAvailable({
accessToken: session.access_token,
teamId: team.id,
template: terminalTemplate,
})

if (!templateAvailable.ok) {
return (
<TerminalUnavailable message="We could not verify the terminal template for this account." />
)
}

if (!templateAvailable.available) {
return (
<TerminalUnavailable
message={`Template "${terminalTemplate}" is not available for this account.`}
/>
)
}

return (
<main className="h-dvh min-h-[360px] bg-bg p-3">
<DashboardTerminal
autoStart
initialCommand={command}
initialSandboxId={terminalSandboxId}
initialTemplate={terminalTemplate}
teamId={team.id}
/>
</main>
)
}

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 resolveTerminalSandboxTeam({
accessToken,
preferredTeamId,
sandboxId,
teams,
}: {
accessToken: string
preferredTeamId?: string
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 candidateTeams = teams.filter((team) => team.id !== preferredTeamId)
const teamMatches = await Promise.all(
candidateTeams.map(async (team) => ({
team,
ownsSandbox: await hasSandboxInTeam({
accessToken,
sandboxId,
teamId: team.id,
}),
}))
)

return teamMatches.find((match) => match.ownsSandbox)?.team ?? 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: {
...SUPABASE_AUTH_HEADERS(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({
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}` : ''
}`
Comment thread
matthewlouisbrockman marked this conversation as resolved.
const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({
returnTo,
}).toString()}`

return (
<main className="flex h-dvh min-h-[360px] items-center justify-center bg-bg p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div>
<h1 className="text-lg font-medium">Sign in to open a terminal</h1>
<p className="text-fg-secondary mt-2 text-sm">
The terminal runs in your E2B dashboard account.
</p>
</div>
<Button asChild>
<Link href={signInHref} target="_top">
Sign in
</Link>
</Button>
</div>
</main>
)
}

function TerminalUnavailable({
message = 'We could not resolve a dashboard team for this account.',
}: {
message?: string
}) {
return (
<main className="flex h-dvh min-h-[360px] items-center justify-center bg-bg p-6">
<div className="max-w-sm text-center">
<h1 className="text-lg font-medium">Terminal unavailable</h1>
<p className="text-fg-secondary mt-2 text-sm">{message}</p>
</div>
</main>
)
}
3 changes: 2 additions & 1 deletion src/core/server/api/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { context, SpanStatusCode, trace } from '@opentelemetry/api'
import {
createServerClient,
parseCookieHeader,
type SetAllCookies,
serializeCookieHeader,
} from '@supabase/ssr'
import { unauthorizedUserError } from '@/core/server/adapters/errors'
Expand All @@ -19,7 +20,7 @@ const createSupabaseServerClient = (headers: Headers) => {
getAll() {
return parseCookieHeader(headers.get('cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet: Parameters<SetAllCookies>[0]) {
cookiesToSet.forEach(({ name, value, options }) => {
headers.append(
'Set-Cookie',
Expand Down
12 changes: 11 additions & 1 deletion src/core/server/http/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export function isDashboardRoute(pathname: string): boolean {
return pathname.startsWith(PROTECTED_URLS.DASHBOARD)
}

function isDashboardTerminalRoute(pathname: string): boolean {
return (
pathname === '/dashboard/terminal' || pathname === '/dashboard/terminal/'
)
}

export function buildRedirectUrl(path: string, request: NextRequest): URL {
return new URL(path, request.url)
}
Expand All @@ -23,7 +29,11 @@ export function getAuthRedirect(
request: NextRequest,
isAuthenticated: boolean
): NextResponse | null {
if (isDashboardRoute(request.nextUrl.pathname) && !isAuthenticated) {
if (
isDashboardRoute(request.nextUrl.pathname) &&
!isDashboardTerminalRoute(request.nextUrl.pathname) &&
!isAuthenticated
) {
Comment thread
matthewlouisbrockman marked this conversation as resolved.
return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request))
}

Expand Down
2 changes: 1 addition & 1 deletion src/features/dashboard/layouts/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default function DashboardLayoutHeader({
)}
</div>

<ClientOnly className="flex items-center pl-2 pr-2">
<ClientOnly className="flex items-center gap-2 pl-2 pr-2">
<ThemeSwitcher />
</ClientOnly>

Expand Down
7 changes: 7 additions & 0 deletions src/features/dashboard/terminal/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const TERMINAL_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000
export const DEFAULT_COLS = 100
export const DEFAULT_ROWS = 28
export const DEFAULT_PANEL_HEIGHT = 260
export const MAX_TERMINAL_TRANSCRIPT_CHARS = 200_000
export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session'
export const DEFAULT_CWD = '/home/user'
Loading
Loading