|
| 1 | +import Link from 'next/link' |
| 2 | +import type { Metadata } from 'next/types' |
| 3 | +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' |
| 4 | +import { AUTH_URLS } from '@/configs/urls' |
| 5 | +import type { TeamModel } from '@/core/modules/teams/models' |
| 6 | +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' |
| 7 | +import { |
| 8 | + createDefaultTemplatesRepository, |
| 9 | + createTemplatesRepository, |
| 10 | +} from '@/core/modules/templates/repository.server' |
| 11 | +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' |
| 12 | +import getUserByToken from '@/core/server/functions/auth/get-user-by-token' |
| 13 | +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' |
| 14 | +import { infra } from '@/core/shared/clients/api' |
| 15 | +import { SandboxIdSchema } from '@/core/shared/schemas/api' |
| 16 | +import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' |
| 17 | +import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' |
| 18 | +import { Button } from '@/ui/primitives/button' |
| 19 | + |
| 20 | +export const metadata: Metadata = { |
| 21 | + title: 'Terminal - E2B', |
| 22 | + robots: 'noindex, nofollow', |
| 23 | +} |
| 24 | + |
| 25 | +interface TerminalPageProps { |
| 26 | + searchParams: Promise<{ |
| 27 | + command?: string |
| 28 | + sandboxId?: string |
| 29 | + template?: string |
| 30 | + }> |
| 31 | +} |
| 32 | + |
| 33 | +export default async function TerminalPage({ |
| 34 | + searchParams, |
| 35 | +}: TerminalPageProps) { |
| 36 | + const { command = '', sandboxId, template } = await searchParams |
| 37 | + const terminalTemplate = normalizeTerminalTemplate(template) |
| 38 | + const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) |
| 39 | + |
| 40 | + if (!terminalTemplate) { |
| 41 | + return <TerminalUnavailable message="The terminal template is invalid." /> |
| 42 | + } |
| 43 | + |
| 44 | + if (terminalSandboxId === null) { |
| 45 | + return <TerminalUnavailable message="The terminal sandbox ID is invalid." /> |
| 46 | + } |
| 47 | + |
| 48 | + const session = await getSessionInsecure() |
| 49 | + const { data, error } = await getUserByToken(session?.access_token) |
| 50 | + |
| 51 | + if (error || !data.user || !session) { |
| 52 | + return ( |
| 53 | + <TerminalSignIn |
| 54 | + sandboxId={terminalSandboxId} |
| 55 | + template={terminalTemplate} |
| 56 | + /> |
| 57 | + ) |
| 58 | + } |
| 59 | + |
| 60 | + const teamsRepository = createUserTeamsRepository({ |
| 61 | + accessToken: session.access_token, |
| 62 | + }) |
| 63 | + const teamsResult = await teamsRepository.listUserTeams() |
| 64 | + |
| 65 | + if (!teamsResult.ok) { |
| 66 | + return <TerminalUnavailable /> |
| 67 | + } |
| 68 | + |
| 69 | + const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token) |
| 70 | + const team = terminalSandboxId |
| 71 | + ? await resolveTerminalSandboxTeam({ |
| 72 | + accessToken: session.access_token, |
| 73 | + preferredTeamId: resolvedTeam?.id, |
| 74 | + sandboxId: terminalSandboxId, |
| 75 | + teams: teamsResult.data, |
| 76 | + }) |
| 77 | + : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id) |
| 78 | + |
| 79 | + if (!team) { |
| 80 | + return <TerminalUnavailable /> |
| 81 | + } |
| 82 | + |
| 83 | + const templateAvailable = terminalSandboxId |
| 84 | + ? { ok: true as const, available: true } |
| 85 | + : await isTerminalTemplateAvailable({ |
| 86 | + accessToken: session.access_token, |
| 87 | + teamId: team.id, |
| 88 | + template: terminalTemplate, |
| 89 | + }) |
| 90 | + |
| 91 | + if (!templateAvailable.ok) { |
| 92 | + return ( |
| 93 | + <TerminalUnavailable message="We could not verify the terminal template for this account." /> |
| 94 | + ) |
| 95 | + } |
| 96 | + |
| 97 | + if (!templateAvailable.available) { |
| 98 | + return ( |
| 99 | + <TerminalUnavailable |
| 100 | + message={`Template "${terminalTemplate}" is not available for this account.`} |
| 101 | + /> |
| 102 | + ) |
| 103 | + } |
| 104 | + |
| 105 | + return ( |
| 106 | + <main className="h-dvh min-h-[360px] bg-bg p-3"> |
| 107 | + <DashboardTerminal |
| 108 | + autoStart |
| 109 | + initialCommand={command} |
| 110 | + initialSandboxId={terminalSandboxId} |
| 111 | + initialTemplate={terminalTemplate} |
| 112 | + teamId={team.id} |
| 113 | + /> |
| 114 | + </main> |
| 115 | + ) |
| 116 | +} |
| 117 | + |
| 118 | +function normalizeTerminalSandboxId(sandboxId?: string) { |
| 119 | + const value = sandboxId?.trim() |
| 120 | + if (!value) return undefined |
| 121 | + |
| 122 | + const parsedSandboxId = SandboxIdSchema.safeParse(value) |
| 123 | + return parsedSandboxId.success ? parsedSandboxId.data : null |
| 124 | +} |
| 125 | + |
| 126 | +async function resolveTerminalSandboxTeam({ |
| 127 | + accessToken, |
| 128 | + preferredTeamId, |
| 129 | + sandboxId, |
| 130 | + teams, |
| 131 | +}: { |
| 132 | + accessToken: string |
| 133 | + preferredTeamId?: string |
| 134 | + sandboxId: string |
| 135 | + teams: TeamModel[] |
| 136 | +}) { |
| 137 | + if (preferredTeamId) { |
| 138 | + const preferredTeam = teams.find((team) => team.id === preferredTeamId) |
| 139 | + if ( |
| 140 | + preferredTeam && |
| 141 | + (await hasSandboxInTeam({ |
| 142 | + accessToken, |
| 143 | + sandboxId, |
| 144 | + teamId: preferredTeam.id, |
| 145 | + })) |
| 146 | + ) { |
| 147 | + return preferredTeam |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + const candidateTeams = teams.filter((team) => team.id !== preferredTeamId) |
| 152 | + const teamMatches = await Promise.all( |
| 153 | + candidateTeams.map(async (team) => ({ |
| 154 | + team, |
| 155 | + ownsSandbox: await hasSandboxInTeam({ |
| 156 | + accessToken, |
| 157 | + sandboxId, |
| 158 | + teamId: team.id, |
| 159 | + }), |
| 160 | + })) |
| 161 | + ) |
| 162 | + |
| 163 | + return teamMatches.find((match) => match.ownsSandbox)?.team ?? null |
| 164 | +} |
| 165 | + |
| 166 | +async function hasSandboxInTeam({ |
| 167 | + accessToken, |
| 168 | + sandboxId, |
| 169 | + teamId, |
| 170 | +}: { |
| 171 | + accessToken: string |
| 172 | + sandboxId: string |
| 173 | + teamId: string |
| 174 | +}) { |
| 175 | + try { |
| 176 | + const result = await infra.GET('/sandboxes/{sandboxID}', { |
| 177 | + params: { |
| 178 | + path: { |
| 179 | + sandboxID: sandboxId, |
| 180 | + }, |
| 181 | + }, |
| 182 | + headers: { |
| 183 | + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), |
| 184 | + }, |
| 185 | + cache: 'no-store', |
| 186 | + }) |
| 187 | + |
| 188 | + return result.response.ok && Boolean(result.data) |
| 189 | + } catch { |
| 190 | + return false |
| 191 | + } |
| 192 | +} |
| 193 | + |
| 194 | +async function isTerminalTemplateAvailable({ |
| 195 | + accessToken, |
| 196 | + teamId, |
| 197 | + template, |
| 198 | +}: { |
| 199 | + accessToken: string |
| 200 | + teamId: string |
| 201 | + template: string |
| 202 | +}) { |
| 203 | + if (template === 'base') { |
| 204 | + return { ok: true as const, available: true } |
| 205 | + } |
| 206 | + |
| 207 | + const defaultTemplatesRepository = createDefaultTemplatesRepository({ |
| 208 | + accessToken, |
| 209 | + }) |
| 210 | + const teamTemplatesRepository = createTemplatesRepository({ |
| 211 | + accessToken, |
| 212 | + teamId, |
| 213 | + }) |
| 214 | + const [defaultTemplates, teamTemplates] = await Promise.all([ |
| 215 | + defaultTemplatesRepository.getDefaultTemplatesCached(), |
| 216 | + teamTemplatesRepository.getTeamTemplates(), |
| 217 | + ]) |
| 218 | + |
| 219 | + if (!defaultTemplates.ok || !teamTemplates.ok) { |
| 220 | + return { ok: false as const } |
| 221 | + } |
| 222 | + |
| 223 | + const templates = [ |
| 224 | + ...defaultTemplates.data.templates, |
| 225 | + ...teamTemplates.data.templates, |
| 226 | + ] |
| 227 | + |
| 228 | + return { |
| 229 | + ok: true as const, |
| 230 | + available: templates.some((candidate) => |
| 231 | + [ |
| 232 | + candidate.templateID, |
| 233 | + ...(candidate.aliases ?? []), |
| 234 | + ...(candidate.names ?? []), |
| 235 | + ].includes(template) |
| 236 | + ), |
| 237 | + } |
| 238 | +} |
| 239 | + |
| 240 | +function TerminalSignIn({ |
| 241 | + sandboxId, |
| 242 | + template, |
| 243 | +}: { |
| 244 | + sandboxId?: string |
| 245 | + template: string |
| 246 | +}) { |
| 247 | + const returnToParams = new URLSearchParams() |
| 248 | + |
| 249 | + if (template) { |
| 250 | + returnToParams.set('template', template) |
| 251 | + } |
| 252 | + |
| 253 | + if (sandboxId) { |
| 254 | + returnToParams.set('sandboxId', sandboxId) |
| 255 | + } |
| 256 | + |
| 257 | + const returnToQuery = returnToParams.toString() |
| 258 | + const returnTo = `/dashboard/terminal${ |
| 259 | + returnToQuery ? `?${returnToQuery}` : '' |
| 260 | + }` |
| 261 | + const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ |
| 262 | + returnTo, |
| 263 | + }).toString()}` |
| 264 | + |
| 265 | + return ( |
| 266 | + <main className="flex h-dvh min-h-[360px] items-center justify-center bg-bg p-6"> |
| 267 | + <div className="flex max-w-sm flex-col items-center gap-4 text-center"> |
| 268 | + <div> |
| 269 | + <h1 className="text-lg font-medium">Sign in to open a terminal</h1> |
| 270 | + <p className="text-fg-secondary mt-2 text-sm"> |
| 271 | + The terminal runs in your E2B dashboard account. |
| 272 | + </p> |
| 273 | + </div> |
| 274 | + <Button asChild> |
| 275 | + <Link href={signInHref} target="_top"> |
| 276 | + Sign in |
| 277 | + </Link> |
| 278 | + </Button> |
| 279 | + </div> |
| 280 | + </main> |
| 281 | + ) |
| 282 | +} |
| 283 | + |
| 284 | +function TerminalUnavailable({ |
| 285 | + message = 'We could not resolve a dashboard team for this account.', |
| 286 | +}: { |
| 287 | + message?: string |
| 288 | +}) { |
| 289 | + return ( |
| 290 | + <main className="flex h-dvh min-h-[360px] items-center justify-center bg-bg p-6"> |
| 291 | + <div className="max-w-sm text-center"> |
| 292 | + <h1 className="text-lg font-medium">Terminal unavailable</h1> |
| 293 | + <p className="text-fg-secondary mt-2 text-sm">{message}</p> |
| 294 | + </div> |
| 295 | + </main> |
| 296 | + ) |
| 297 | +} |
0 commit comments